fix(gmail): strip CR/LF from header values before MIME assembly (#5395)

* fix(gmail): strip CR/LF from header values before MIME assembly

Adds sanitizeHeaderValue and applies it to to/cc/bcc/subject/
inReplyTo/references and the attachment filename in
buildSimpleEmailMessage and buildMimeMessage before they're placed
into MIME header lines.

* fix(gmail): sanitize attachment mimeType in Content-Type header

attachment.mimeType was written verbatim into the Content-Type header,
unlike the other header fields this PR sanitizes. Route it through the
same sanitizeHeaderValue helper for consistency.
This commit is contained in:
Waleed
2026-07-03 14:25:12 -07:00
committed by GitHub
parent 2e3574407b
commit fd98218e58
2 changed files with 157 additions and 18 deletions
+122
View File
@@ -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(`<script>alert("x & y's")</script>`)).toBe(
@@ -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', () => {
@@ -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"')
})
})
+35 -18
View File
@@ -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('')