diff --git a/apps/sim/tools/gmail/utils.test.ts b/apps/sim/tools/gmail/utils.test.ts index 2b0d226c5e7..826968261b5 100644 --- a/apps/sim/tools/gmail/utils.test.ts +++ b/apps/sim/tools/gmail/utils.test.ts @@ -9,6 +9,7 @@ import { escapeHtml, htmlToPlainText, plainTextToHtml, + sanitizeHeaderValue, } from './utils' function decodeSimpleMessage(encoded: string): string { @@ -60,6 +61,27 @@ describe('encodeRfc2047', () => { }) }) +describe('sanitizeHeaderValue', () => { + it('collapses embedded CRLF to a single space', () => { + expect(sanitizeHeaderValue('foo\r\nBcc: attacker@example.com')).toBe( + 'foo Bcc: attacker@example.com' + ) + }) + + it('collapses embedded bare LF or CR to a single space', () => { + expect(sanitizeHeaderValue('foo\nbar')).toBe('foo bar') + expect(sanitizeHeaderValue('foo\rbar')).toBe('foo bar') + }) + + it('collapses consecutive newlines to a single space', () => { + expect(sanitizeHeaderValue('foo\r\n\r\nbar')).toBe('foo bar') + }) + + it('leaves ordinary values unchanged', () => { + expect(sanitizeHeaderValue('a@example.com, b@example.com')).toBe('a@example.com, b@example.com') + }) +}) + describe('escapeHtml', () => { it('escapes the five HTML special characters', () => { expect(escapeHtml(``)).toBe( @@ -167,6 +189,39 @@ describe('buildSimpleEmailMessage', () => { expect(decoded).toContain('In-Reply-To: ') expect(decoded).toContain('References: ') }) + + it('strips embedded CRLF from to/cc/bcc/subject/inReplyTo/references so no extra header line is produced', () => { + const injected = 'innocuous\r\nBcc: attacker@example.com' + const encoded = buildSimpleEmailMessage({ + to: injected, + cc: injected, + bcc: injected, + subject: injected, + body: 'hello', + inReplyTo: injected, + references: injected, + }) + const decoded = decodeSimpleMessage(encoded) + const lines = decoded.split('\n') + const bccLines = lines.filter((line) => line.startsWith('Bcc:')) + // Exactly one Bcc header line (the legitimate one), holding the sanitized, single-line value. + expect(bccLines).toHaveLength(1) + expect(bccLines[0]).toBe('Bcc: innocuous Bcc: attacker@example.com') + expect(lines).not.toContain('Bcc: attacker@example.com') + }) + + it('preserves legitimate ASCII, Unicode, and multi-recipient values unchanged', () => { + const encoded = buildSimpleEmailMessage({ + to: 'a@example.com, b@example.com', + cc: 'c@example.com', + subject: 'Café meeting 🎉', + body: 'hello', + }) + const decoded = decodeSimpleMessage(encoded) + expect(decoded).toContain('To: a@example.com, b@example.com') + expect(decoded).toContain('Cc: c@example.com') + expect(decoded).toContain(`Subject: ${encodeRfc2047('Café meeting 🎉')}`) + }) }) describe('buildMimeMessage', () => { @@ -199,4 +254,71 @@ describe('buildMimeMessage', () => { expect(message).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/) expect(message).not.toContain('multipart/mixed') }) + + it('strips embedded CRLF from header fields and the attachment filename', () => { + const injected = 'innocuous\r\nBcc: attacker@example.com' + const message = buildMimeMessage({ + to: injected, + cc: injected, + bcc: injected, + subject: injected, + body: 'hello', + inReplyTo: injected, + references: injected, + attachments: [ + { + filename: injected, + mimeType: 'text/plain', + content: Buffer.from('hi'), + }, + ], + }) + const lines = message.split('\n') + const bccLines = lines.filter((line) => line.startsWith('Bcc:')) + expect(bccLines).toHaveLength(1) + expect(bccLines[0]).toBe('Bcc: innocuous Bcc: attacker@example.com') + expect(lines).not.toContain('Bcc: attacker@example.com') + expect(message).toContain( + 'Content-Disposition: attachment; filename="innocuous Bcc: attacker@example.com"' + ) + }) + + it('strips embedded CRLF from the attachment mimeType', () => { + const injected = 'text/plain\r\nBcc: attacker@example.com' + const message = buildMimeMessage({ + to: 'a@example.com', + body: 'hello', + attachments: [ + { + filename: 'note.txt', + mimeType: injected, + content: Buffer.from('hi'), + }, + ], + }) + const lines = message.split('\n') + const bccLines = lines.filter((line) => line.startsWith('Bcc:')) + expect(bccLines).toHaveLength(0) + expect(message).toContain('Content-Type: text/plain Bcc: attacker@example.com') + }) + + it('preserves legitimate ASCII, Unicode, and multi-recipient values unchanged', () => { + const message = buildMimeMessage({ + to: 'a@example.com, b@example.com', + cc: 'c@example.com', + subject: 'Café meeting 🎉', + body: 'hello', + attachments: [ + { + filename: 'note.txt', + mimeType: 'text/plain', + content: Buffer.from('hi'), + }, + ], + }) + expect(message).toContain('To: a@example.com, b@example.com') + expect(message).toContain('Cc: c@example.com') + expect(message).toContain(`Subject: ${encodeRfc2047('Café meeting 🎉')}`) + expect(message).toContain('Content-Disposition: attachment; filename="note.txt"') + }) }) diff --git a/apps/sim/tools/gmail/utils.ts b/apps/sim/tools/gmail/utils.ts index a4653d3a438..f913e84ddcc 100644 --- a/apps/sim/tools/gmail/utils.ts +++ b/apps/sim/tools/gmail/utils.ts @@ -309,6 +309,13 @@ export function encodeRfc2047(value: string): string { return `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=` } +/** + * Strips CR/LF so a value can't introduce extra lines when placed into a MIME header. + */ +export function sanitizeHeaderValue(value: string): string { + return value.replace(/[\r\n]+/g, ' ') +} + /** * Encode string or buffer to base64url format (URL-safe base64) * Gmail API requires base64url encoding for the raw message field @@ -432,20 +439,23 @@ export function buildSimpleEmailMessage(params: { const boundary = generateBoundary() const { plain, html } = buildBodyAlternatives(body, contentType) - const emailHeaders = ['MIME-Version: 1.0', `To: ${to}`] + const emailHeaders = ['MIME-Version: 1.0', `To: ${sanitizeHeaderValue(to)}`] if (cc) { - emailHeaders.push(`Cc: ${cc}`) + emailHeaders.push(`Cc: ${sanitizeHeaderValue(cc)}`) } if (bcc) { - emailHeaders.push(`Bcc: ${bcc}`) + emailHeaders.push(`Bcc: ${sanitizeHeaderValue(bcc)}`) } - emailHeaders.push(`Subject: ${encodeRfc2047(subject || '')}`) + emailHeaders.push(`Subject: ${encodeRfc2047(sanitizeHeaderValue(subject || ''))}`) if (inReplyTo) { - emailHeaders.push(`In-Reply-To: ${inReplyTo}`) - const referencesChain = references ? `${references} ${inReplyTo}` : inReplyTo + const sanitizedInReplyTo = sanitizeHeaderValue(inReplyTo) + emailHeaders.push(`In-Reply-To: ${sanitizedInReplyTo}`) + const referencesChain = references + ? `${sanitizeHeaderValue(references)} ${sanitizedInReplyTo}` + : sanitizedInReplyTo emailHeaders.push(`References: ${referencesChain}`) } @@ -483,23 +493,28 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string { const messageParts: string[] = [] const { plain, html } = buildBodyAlternatives(body, contentType) - messageParts.push(`To: ${to}`) + messageParts.push(`To: ${sanitizeHeaderValue(to)}`) if (cc) { - messageParts.push(`Cc: ${cc}`) + messageParts.push(`Cc: ${sanitizeHeaderValue(cc)}`) } if (bcc) { - messageParts.push(`Bcc: ${bcc}`) + messageParts.push(`Bcc: ${sanitizeHeaderValue(bcc)}`) } - messageParts.push(`Subject: ${encodeRfc2047(subject || '')}`) + messageParts.push(`Subject: ${encodeRfc2047(sanitizeHeaderValue(subject || ''))}`) - if (inReplyTo) { - messageParts.push(`In-Reply-To: ${inReplyTo}`) + const sanitizedInReplyTo = inReplyTo ? sanitizeHeaderValue(inReplyTo) : undefined + const sanitizedReferences = references ? sanitizeHeaderValue(references) : undefined + + if (sanitizedInReplyTo) { + messageParts.push(`In-Reply-To: ${sanitizedInReplyTo}`) } - if (references) { - const referencesChain = inReplyTo ? `${references} ${inReplyTo}` : references + if (sanitizedReferences) { + const referencesChain = sanitizedInReplyTo + ? `${sanitizedReferences} ${sanitizedInReplyTo}` + : sanitizedReferences messageParts.push(`References: ${referencesChain}`) - } else if (inReplyTo) { - messageParts.push(`References: ${inReplyTo}`) + } else if (sanitizedInReplyTo) { + messageParts.push(`References: ${sanitizedInReplyTo}`) } messageParts.push('MIME-Version: 1.0') @@ -518,8 +533,10 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string { for (const attachment of attachments) { messageParts.push(`--${mixedBoundary}`) - messageParts.push(`Content-Type: ${attachment.mimeType}`) - messageParts.push(`Content-Disposition: attachment; filename="${attachment.filename}"`) + messageParts.push(`Content-Type: ${sanitizeHeaderValue(attachment.mimeType)}`) + messageParts.push( + `Content-Disposition: attachment; filename="${sanitizeHeaderValue(attachment.filename)}"` + ) messageParts.push('Content-Transfer-Encoding: base64') messageParts.push('')