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('')