Skip to content

Commit fc1784c

Browse files
committed
fix: preserve safe SMTP headers
1 parent a5996e1 commit fc1784c

7 files changed

Lines changed: 256 additions & 44 deletions

File tree

apps/smtp-server/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "",
55
"main": "index.js",
66
"scripts": {
7-
"test": "echo \"Error: no test specified\" && exit 1",
7+
"test": "vitest run",
88
"build": "tsup",
99
"start": "node dist/server.js"
1010
},
@@ -23,6 +23,7 @@
2323
"@types/node": "^22.15.2",
2424
"@types/nodemailer": "^8.0.0",
2525
"tsup": "^8.4.0",
26-
"typescript": "^5.8.3"
26+
"typescript": "^5.8.3",
27+
"vitest": "^3.2.4"
2728
}
2829
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { HeaderLines } from "mailparser";
2+
3+
// These headers are represented by first-class API fields, rebuilt when
4+
// Nodemailer creates the outbound MIME message, or added by the receiving MTA.
5+
const NON_FORWARDABLE_HEADERS = new Set([
6+
"authentication-results",
7+
"bcc",
8+
"cc",
9+
"content-disposition",
10+
"content-id",
11+
"content-length",
12+
"content-md5",
13+
"content-transfer-encoding",
14+
"content-type",
15+
"date",
16+
"delivered-to",
17+
"dkim-signature",
18+
"domainkey-signature",
19+
"envelope-to",
20+
"errors-to",
21+
"from",
22+
"message-id",
23+
"mime-version",
24+
"received",
25+
"received-spf",
26+
"reply-to",
27+
"return-path",
28+
"sender",
29+
"subject",
30+
"to",
31+
"x-envelope-to",
32+
"x-google-dkim-signature",
33+
"x-original-to",
34+
"x-received",
35+
]);
36+
37+
const NON_FORWARDABLE_PREFIXES = [
38+
"arc-",
39+
"resent-",
40+
"x-ses-",
41+
"x-unsend-",
42+
"x-usesend-",
43+
];
44+
45+
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
46+
47+
function shouldForwardHeader(name: string): boolean {
48+
return (
49+
!NON_FORWARDABLE_HEADERS.has(name) &&
50+
!NON_FORWARDABLE_PREFIXES.some((prefix) => name.startsWith(prefix))
51+
);
52+
}
53+
54+
/**
55+
* Extracts end-to-end headers that remain meaningful after useSend rebuilds
56+
* the MIME message. Repeated headers use the last value because the public API
57+
* currently accepts a string record rather than an ordered header list.
58+
*/
59+
export function extractForwardedHeaders(
60+
headerLines: HeaderLines | undefined,
61+
): Record<string, string> | undefined {
62+
const headers = new Map<string, { name: string; value: string }>();
63+
64+
for (const { key, line } of headerLines ?? []) {
65+
const normalizedName = key.toLowerCase();
66+
if (!shouldForwardHeader(normalizedName)) {
67+
continue;
68+
}
69+
70+
const colonIndex = line.indexOf(":");
71+
if (colonIndex === -1) {
72+
continue;
73+
}
74+
75+
const name = line.slice(0, colonIndex).trim();
76+
if (
77+
!HEADER_NAME_PATTERN.test(name) ||
78+
name.toLowerCase() !== normalizedName
79+
) {
80+
continue;
81+
}
82+
83+
// headerLines contains the original folded representation. Unfold valid
84+
// continuation lines before passing values through the JSON API.
85+
const value = line
86+
.slice(colonIndex + 1)
87+
.replace(/\r?\n[ \t]+/g, " ")
88+
.trim();
89+
90+
if (!value || /[\r\n]/.test(value)) {
91+
continue;
92+
}
93+
94+
headers.set(normalizedName, { name, value });
95+
}
96+
97+
if (headers.size === 0) {
98+
return undefined;
99+
}
100+
101+
return Object.fromEntries(
102+
[...headers.values()].map(({ name, value }) => [name, value]),
103+
);
104+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it } from "vitest";
2+
import { simpleParser } from "mailparser";
3+
import { extractForwardedHeaders } from "./email-headers";
4+
5+
describe("extractForwardedHeaders", () => {
6+
it("forwards end-to-end and custom headers", async () => {
7+
const parsed = await simpleParser(
8+
[
9+
"From: sender@example.com",
10+
"To: recipient@example.com",
11+
"Subject: Header forwarding",
12+
"List-Unsubscribe: <mailto:unsubscribe@example.com>,",
13+
" <https://example.com/unsubscribe/recipient-token>",
14+
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
15+
"List-Help: <https://example.com/help>",
16+
"In-Reply-To: <previous@example.com>",
17+
"References: <first@example.com> <previous@example.com>",
18+
"Precedence: bulk",
19+
"Auto-Submitted: auto-generated",
20+
"Feedback-ID: campaign:customer:usesend",
21+
"X-Custom-Trace: trace-123",
22+
"",
23+
"Hello",
24+
].join("\r\n"),
25+
);
26+
27+
expect(extractForwardedHeaders(parsed.headerLines)).toEqual({
28+
"List-Unsubscribe":
29+
"<mailto:unsubscribe@example.com>, <https://example.com/unsubscribe/recipient-token>",
30+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
31+
"List-Help": "<https://example.com/help>",
32+
"In-Reply-To": "<previous@example.com>",
33+
References: "<first@example.com> <previous@example.com>",
34+
Precedence: "bulk",
35+
"Auto-Submitted": "auto-generated",
36+
"Feedback-ID": "campaign:customer:usesend",
37+
"X-Custom-Trace": "trace-123",
38+
});
39+
});
40+
41+
it("does not forward headers that are rebuilt or transport-controlled", async () => {
42+
const parsed = await simpleParser(
43+
[
44+
"Return-Path: <bounce@example.com>",
45+
"Received: from untrusted.example.com",
46+
"Authentication-Results: mx.example.com; dkim=pass",
47+
"ARC-Seal: i=1; a=rsa-sha256; d=example.com; b=stale",
48+
"DKIM-Signature: v=1; d=example.com; b=stale",
49+
"X-SES-CONFIGURATION-SET: untrusted",
50+
"X-Usesend-Email-ID: spoofed",
51+
"From: sender@example.com",
52+
"To: recipient@example.com",
53+
"Cc: copy@example.com",
54+
"Bcc: hidden@example.com",
55+
"Subject: Header forwarding",
56+
"Message-ID: <old@example.com>",
57+
"MIME-Version: 1.0",
58+
"Content-Type: text/plain; charset=utf-8",
59+
"X-Safe: forwarded",
60+
"",
61+
"Hello",
62+
].join("\r\n"),
63+
);
64+
65+
expect(extractForwardedHeaders(parsed.headerLines)).toEqual({
66+
"X-Safe": "forwarded",
67+
});
68+
});
69+
70+
it("returns undefined when there is nothing safe to forward", async () => {
71+
const parsed = await simpleParser(
72+
[
73+
"From: sender@example.com",
74+
"To: recipient@example.com",
75+
"Subject: Header forwarding",
76+
"",
77+
"Hello",
78+
].join("\r\n"),
79+
);
80+
81+
expect(extractForwardedHeaders(parsed.headerLines)).toBeUndefined();
82+
});
83+
});

apps/smtp-server/src/server.ts

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Readable } from "stream";
33
import dotenv from "dotenv";
44
import { simpleParser } from "mailparser";
55
import { readFileSync, watch, FSWatcher } from "fs";
6+
import { extractForwardedHeaders } from "./email-headers";
67

78
dotenv.config();
89

@@ -16,39 +17,6 @@ const SSL_KEY_PATH =
1617
const SSL_CERT_PATH =
1718
process.env.USESEND_API_CERT_PATH ?? process.env.UNSEND_API_CERT_PATH;
1819

19-
// Forwarded headers that mailparser's normalized Map can't be trusted for.
20-
// Read from headerLines (raw) instead.
21-
const FORWARDED_HEADERS = ["list-unsubscribe", "list-unsubscribe-post"];
22-
23-
function canonicalHeaderName(name: string): string {
24-
return name
25-
.split("-")
26-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
27-
.join("-");
28-
}
29-
30-
function extractForwardedHeaders(
31-
headerLines: readonly { key: string; line: string }[] | undefined,
32-
): Record<string, string> | undefined {
33-
const result: Record<string, string> = {};
34-
35-
for (const { key, line } of headerLines || []) {
36-
if (!FORWARDED_HEADERS.includes(key)) {
37-
continue;
38-
}
39-
const colonIndex = line.indexOf(":");
40-
if (colonIndex === -1) {
41-
continue;
42-
}
43-
const value = line.slice(colonIndex + 1).trim();
44-
if (value.length > 0) {
45-
result[canonicalHeaderName(key)] = value;
46-
}
47-
}
48-
49-
return Object.keys(result).length > 0 ? result : undefined;
50-
}
51-
5220
async function sendEmailToUseSend(emailData: any, apiKey: string) {
5321
try {
5422
const apiEndpoint = "/api/v1/emails";
@@ -134,6 +102,12 @@ const serverOptions: SMTPServerOptions = {
134102
text: parsed.text,
135103
html: parsed.html,
136104
replyTo: parsed.replyTo?.text,
105+
cc: Array.isArray(parsed.cc)
106+
? parsed.cc.map((addr) => addr.text).join(", ")
107+
: parsed.cc?.text,
108+
bcc: Array.isArray(parsed.bcc)
109+
? parsed.bcc.map((addr) => addr.text).join(", ")
110+
: parsed.bcc?.text,
137111
headers: forwardedHeaders,
138112
attachments:
139113
parsed.attachments.length > 0

apps/web/src/server/utils/email-headers.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,44 @@
11
import { nanoid } from "../nanoid";
22

3-
const RESERVED_EMAIL_HEADERS = new Set(
4-
["x-usesend-email-id"].map((header) => header.toLowerCase())
5-
);
3+
const RESERVED_EMAIL_HEADERS = new Set([
4+
"authentication-results",
5+
"bcc",
6+
"cc",
7+
"content-disposition",
8+
"content-id",
9+
"content-length",
10+
"content-md5",
11+
"content-transfer-encoding",
12+
"content-type",
13+
"date",
14+
"delivered-to",
15+
"dkim-signature",
16+
"domainkey-signature",
17+
"envelope-to",
18+
"errors-to",
19+
"from",
20+
"message-id",
21+
"mime-version",
22+
"received",
23+
"received-spf",
24+
"reply-to",
25+
"return-path",
26+
"sender",
27+
"subject",
28+
"to",
29+
"x-envelope-to",
30+
"x-google-dkim-signature",
31+
"x-original-to",
32+
"x-received",
33+
]);
34+
35+
const RESERVED_EMAIL_HEADER_PREFIXES = [
36+
"arc-",
37+
"resent-",
38+
"x-ses-",
39+
"x-unsend-",
40+
"x-usesend-",
41+
];
642

743
const HEADER_INJECTION_PATTERN = /[\r\n]/;
844

@@ -13,14 +49,21 @@ const HEADER_INJECTION_PATTERN = /[\r\n]/;
1349
*/
1450
export function sanitizeHeader(
1551
rawName: unknown,
16-
rawValue: unknown
52+
rawValue: unknown,
1753
): { name: string; value: string } | undefined {
1854
if (typeof rawName !== "string" || typeof rawValue !== "string") {
1955
return undefined;
2056
}
2157

2258
const name = rawName.trim();
23-
if (!name || RESERVED_EMAIL_HEADERS.has(name.toLowerCase())) {
59+
const normalizedName = name.toLowerCase();
60+
if (
61+
!name ||
62+
RESERVED_EMAIL_HEADERS.has(normalizedName) ||
63+
RESERVED_EMAIL_HEADER_PREFIXES.some((prefix) =>
64+
normalizedName.startsWith(prefix),
65+
)
66+
) {
2467
return undefined;
2568
}
2669

@@ -35,7 +78,7 @@ export function sanitizeHeader(
3578
}
3679

3780
export function sanitizeCustomHeaders(
38-
headers?: Record<string, string | null | undefined>
81+
headers?: Record<string, string | null | undefined>,
3982
): Record<string, string> | undefined {
4083
if (!headers) {
4184
return undefined;
@@ -44,7 +87,7 @@ export function sanitizeCustomHeaders(
4487
const sanitizedEntries = Object.entries(headers)
4588
.map(([name, value]) => sanitizeHeader(name, value))
4689
.filter((entry): entry is { name: string; value: string } =>
47-
Boolean(entry)
90+
Boolean(entry),
4891
);
4992

5093
if (sanitizedEntries.length === 0) {
@@ -56,7 +99,7 @@ export function sanitizeCustomHeaders(
5699
acc[name] = value;
57100
return acc;
58101
},
59-
{} as Record<string, string>
102+
{} as Record<string, string>,
60103
);
61104
}
62105

@@ -75,7 +118,7 @@ export function buildHeaders({
75118
}) {
76119
const sanitizedHeaders = sanitizeCustomHeaders(headers);
77120
const sanitizedHeaderNames = new Set(
78-
Object.keys(sanitizedHeaders ?? {}).map((name) => name.toLowerCase())
121+
Object.keys(sanitizedHeaders ?? {}).map((name) => name.toLowerCase()),
79122
);
80123

81124
const defaultHeaders: Record<string, string> = {};

apps/web/src/server/utils/email-headers.unit.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
describe("email header sanitization", () => {
99
it("removes reserved and invalid headers", () => {
1010
expect(sanitizeHeader("x-usesend-email-id", "123")).toBeUndefined();
11+
expect(sanitizeHeader("Content-Type", "text/html")).toBeUndefined();
12+
expect(sanitizeHeader("DKIM-Signature", "v=1; stale")).toBeUndefined();
13+
expect(sanitizeHeader("ARC-Seal", "i=1; stale")).toBeUndefined();
14+
expect(sanitizeHeader("X-SES-CONFIGURATION-SET", "other")).toBeUndefined();
1115
expect(sanitizeHeader("X-Test", "ok\r\nInjected: true")).toBeUndefined();
1216
expect(sanitizeHeader(123, "ok")).toBeUndefined();
1317
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)