Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions apps/sim/tools/gmail/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
escapeHtml,
htmlToPlainText,
plainTextToHtml,
sanitizeHeaderValue,
} from './utils'

function decodeSimpleMessage(encoded: string): string {
Expand Down Expand Up @@ -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(`<script>alert("x & y's")</script>`)).toBe(
Expand Down Expand Up @@ -167,6 +189,39 @@ describe('buildSimpleEmailMessage', () => {
expect(decoded).toContain('In-Reply-To: <msg-1@example.com>')
expect(decoded).toContain('References: <root@example.com> <msg-1@example.com>')
})

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', () => {
Expand Down Expand Up @@ -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"')
})
})
53 changes: 35 additions & 18 deletions apps/sim/tools/gmail/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`)
}

Expand Down Expand Up @@ -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')
Expand All @@ -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('')

Expand Down
Loading