mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user