Skip to content

Commit 37da453

Browse files
committed
refactor(smtp): harden custom header validation flow 🚧
- Add reserved custom header blocklist as class property - Add strict custom header formatter with CRLF safety checks - Route message custom headers through validated formatter
1 parent bd34ecf commit 37da453

1 file changed

Lines changed: 49 additions & 1 deletion

File tree

src/smtp/Message.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ import * as Utils from '@utils/index.ts'
77
* @description Formats headers, body, and multipart sections.
88
*/
99
export class SmtpMessage {
10+
/** Allowed custom header name pattern */
11+
private readonly customHeaderNamePattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
12+
/** Reserved custom headers to block */
13+
private readonly blockedCustomHeaderNames = new Set([
14+
'bcc',
15+
'cc',
16+
'content-disposition',
17+
'content-id',
18+
'content-transfer-encoding',
19+
'content-type',
20+
'date',
21+
'from',
22+
'message-id',
23+
'mime-version',
24+
'reply-to',
25+
'subject',
26+
'to'
27+
])
28+
1029
/**
1130
* Create message formatter.
1231
* @description Stores SMTP config for generated headers.
@@ -83,7 +102,7 @@ export class SmtpMessage {
83102
headers.push(`Reply-To: ${SMTP.SmtpAddress.formatAddressForHeader(replyToAddress)}`)
84103
if (message.headers) {
85104
for (const [key, value] of Object.entries(message.headers)) {
86-
headers.push(`${key}: ${value}`)
105+
headers.push(this.formatCustomHeader(key, value))
87106
}
88107
}
89108
return headers
@@ -178,6 +197,35 @@ export class SmtpMessage {
178197
return parts.join('\r\n')
179198
}
180199

200+
/**
201+
* Format custom header.
202+
* @description Validates custom header name and value safety.
203+
* @param customHeaderKey - Custom header name
204+
* @param customHeaderValue - Custom header value
205+
* @returns Safe custom header string
206+
* @throws {Error} When custom header key or value is invalid
207+
*/
208+
private formatCustomHeader(customHeaderKey: string, customHeaderValue: string): string {
209+
const trimmedHeaderKey = customHeaderKey.trim()
210+
const normalizedHeaderKey = trimmedHeaderKey.toLowerCase()
211+
if (trimmedHeaderKey.length === 0) {
212+
throw new Error('Custom header name cannot be empty')
213+
}
214+
if (this.blockedCustomHeaderNames.has(normalizedHeaderKey)) {
215+
throw new Error(`Custom header "${trimmedHeaderKey}" is reserved and cannot be overridden`)
216+
}
217+
if (!this.customHeaderNamePattern.test(trimmedHeaderKey)) {
218+
throw new Error(`Custom header "${trimmedHeaderKey}" contains invalid characters`)
219+
}
220+
if (trimmedHeaderKey.includes('\r') || trimmedHeaderKey.includes('\n')) {
221+
throw new Error(`Custom header "${trimmedHeaderKey}" contains line break characters`)
222+
}
223+
if (customHeaderValue.includes('\r') || customHeaderValue.includes('\n')) {
224+
throw new Error(`Custom header "${trimmedHeaderKey}" value contains line break characters`)
225+
}
226+
return `${trimmedHeaderKey}: ${customHeaderValue}`
227+
}
228+
181229
/**
182230
* Format embedded images section.
183231
* @description Builds related parts and inline image payloads.

0 commit comments

Comments
 (0)