Skip to content

Commit 730097d

Browse files
author
Theodore Li
committed
Sanatize CRLF characters from emails
1 parent 4cf445f commit 730097d

File tree

5 files changed

+126
-16
lines changed

5 files changed

+126
-16
lines changed

apps/sim/app/api/help/integration-request/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
1010
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
1111

1212
const logger = createLogger('IntegrationRequestAPI')
13+
const NO_EMAIL_HEADER_CONTROL_CHARS_REGEX = /^[^\r\n]*$/
1314

1415
const rateLimiter = new RateLimiter()
1516

@@ -20,7 +21,12 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
2021
}
2122

2223
const integrationRequestSchema = z.object({
23-
integrationName: z.string().min(1, 'Integration name is required').max(200),
24+
integrationName: z
25+
.string()
26+
.trim()
27+
.min(1, 'Integration name is required')
28+
.max(200)
29+
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
2430
email: z.string().email('A valid email is required'),
2531
useCase: z.string().max(2000).optional(),
2632
})

apps/sim/app/api/help/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
1010
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
1111

1212
const logger = createLogger('HelpAPI')
13+
const NO_EMAIL_HEADER_CONTROL_CHARS_REGEX = /^[^\r\n]*$/
1314

1415
const helpFormSchema = z.object({
15-
subject: z.string().min(1, 'Subject is required'),
16+
subject: z
17+
.string()
18+
.trim()
19+
.min(1, 'Subject is required')
20+
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
1621
message: z.string().min(1, 'Message is required'),
1722
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
1823
})

apps/sim/lib/marketing/demo-request.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { z } from 'zod'
22
import { quickValidateEmail } from '@/lib/messaging/email/validation'
33

4+
const NO_EMAIL_HEADER_CONTROL_CHARS_REGEX = /^[^\r\n]*$/
5+
46
export const DEMO_REQUEST_REGION_VALUES = [
57
'north_america',
68
'europe',
@@ -38,8 +40,18 @@ export const DEMO_REQUEST_USER_COUNT_OPTIONS = [
3840
] as const
3941

4042
export const demoRequestSchema = z.object({
41-
firstName: z.string().trim().min(1, 'First name is required').max(100),
42-
lastName: z.string().trim().min(1, 'Last name is required').max(100),
43+
firstName: z
44+
.string()
45+
.trim()
46+
.min(1, 'First name is required')
47+
.max(100)
48+
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
49+
lastName: z
50+
.string()
51+
.trim()
52+
.min(1, 'Last name is required')
53+
.max(100)
54+
.regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'),
4355
companyEmail: z
4456
.string()
4557
.trim()

apps/sim/lib/messaging/email/mailer.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,34 @@ describe('mailer', () => {
175175
expect(result.success).toBe(true)
176176
})
177177

178+
it('should sanitize CRLF characters in subjects before sending', async () => {
179+
const result = await sendEmail({
180+
to: 'test@example.com',
181+
subject: 'Hello\r\nBcc: attacker@evil.com',
182+
text: 'Plain text content',
183+
})
184+
185+
expect(result.success).toBe(true)
186+
expect(mockSend).toHaveBeenCalledWith(
187+
expect.objectContaining({
188+
subject: 'Hello Bcc: attacker@evil.com',
189+
})
190+
)
191+
})
192+
193+
it('should reject reply-to values containing header control characters', async () => {
194+
const result = await sendEmail({
195+
to: 'test@example.com',
196+
subject: 'Test Subject',
197+
text: 'Plain text content',
198+
replyTo: 'user@example.com\r\nBcc: attacker@evil.com',
199+
})
200+
201+
expect(result.success).toBe(false)
202+
expect(result.message).toBe('Failed to send email')
203+
expect(mockSend).not.toHaveBeenCalled()
204+
})
205+
178206
it('should handle multiple recipients as array', async () => {
179207
const recipients = ['user1@example.com', 'user2@example.com', 'user3@example.com']
180208
const result = await sendEmail({
@@ -222,6 +250,23 @@ describe('mailer', () => {
222250
expect(result.results.length).toBeGreaterThanOrEqual(0)
223251
})
224252

253+
it('should sanitize CRLF characters in batch email subjects', async () => {
254+
await sendBatchEmails({
255+
emails: [
256+
{
257+
...testEmailOptions,
258+
subject: 'Batch\r\nCc: attacker@evil.com',
259+
},
260+
],
261+
})
262+
263+
expect(mockBatchSend).toHaveBeenCalledWith([
264+
expect.objectContaining({
265+
subject: 'Batch Cc: attacker@evil.com',
266+
}),
267+
])
268+
})
269+
225270
it('should handle transactional emails without unsubscribe check', async () => {
226271
const batchEmails = [
227272
{ ...testEmailOptions, to: 'user1@example.com', emailType: 'transactional' as EmailType },

apps/sim/lib/messaging/email/mailer.ts

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/
77
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
88

99
const logger = createLogger('Mailer')
10+
const EMAIL_HEADER_CONTROL_CHARS_REGEX = /[\r\n]/
1011

1112
export type EmailType = 'transactional' | 'marketing' | 'updates' | 'notifications'
1213

@@ -57,6 +58,21 @@ interface ProcessedEmailData {
5758
replyTo?: string
5859
}
5960

61+
interface PreparedEmailHeaderData {
62+
to: string | string[]
63+
subject: string
64+
senderEmail: string
65+
replyTo?: string
66+
}
67+
68+
function hasEmailHeaderControlChars(value: string): boolean {
69+
return EMAIL_HEADER_CONTROL_CHARS_REGEX.test(value)
70+
}
71+
72+
function sanitizeEmailSubject(subject: string): string {
73+
return subject.replace(/[\r\n]+/g, ' ').trim()
74+
}
75+
6076
const resendApiKey = env.RESEND_API_KEY
6177
const azureConnectionString = env.AZURE_ACS_CONNECTION_STRING
6278

@@ -172,17 +188,14 @@ function addUnsubscribeData(
172188
async function processEmailData(options: EmailOptions): Promise<ProcessedEmailData> {
173189
const {
174190
to,
175-
subject,
176191
html,
177192
text,
178-
from,
179193
emailType = 'transactional',
180194
includeUnsubscribe = true,
181195
attachments,
182-
replyTo,
183196
} = options
184197

185-
const senderEmail = from || getFromEmailAddress()
198+
const preparedHeaders = prepareEmailHeaders(options)
186199

187200
let finalHtml = html
188201
let finalText = text
@@ -197,14 +210,43 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
197210
}
198211

199212
return {
200-
to,
201-
subject,
213+
to: preparedHeaders.to,
214+
subject: preparedHeaders.subject,
202215
html: finalHtml,
203216
text: finalText,
204-
senderEmail,
217+
senderEmail: preparedHeaders.senderEmail,
205218
headers,
206219
attachments,
207-
replyTo,
220+
replyTo: preparedHeaders.replyTo,
221+
}
222+
}
223+
224+
function prepareEmailHeaders(options: EmailOptions): PreparedEmailHeaderData {
225+
const senderEmail = options.from || getFromEmailAddress()
226+
const recipients = Array.isArray(options.to) ? options.to : [options.to]
227+
228+
if (recipients.some(hasEmailHeaderControlChars)) {
229+
throw new Error('Invalid recipient email header')
230+
}
231+
232+
if (hasEmailHeaderControlChars(senderEmail)) {
233+
throw new Error('Invalid from email header')
234+
}
235+
236+
if (options.replyTo && hasEmailHeaderControlChars(options.replyTo)) {
237+
throw new Error('Invalid reply-to email header')
238+
}
239+
240+
const subject = sanitizeEmailSubject(options.subject)
241+
if (subject.length === 0) {
242+
throw new Error('Email subject cannot be empty')
243+
}
244+
245+
return {
246+
to: options.to,
247+
subject,
248+
senderEmail,
249+
replyTo: options.replyTo,
208250
}
209251
}
210252

@@ -359,11 +401,11 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
359401
}
360402
}
361403

362-
const senderEmail = email.from || getFromEmailAddress()
404+
const preparedHeaders = prepareEmailHeaders(email)
363405
const emailData: any = {
364-
from: senderEmail,
365-
to: email.to,
366-
subject: email.subject,
406+
from: preparedHeaders.senderEmail,
407+
to: preparedHeaders.to,
408+
subject: preparedHeaders.subject,
367409
}
368410

369411
if (includeUnsubscribe && emailType !== 'transactional') {

0 commit comments

Comments
 (0)