Skip to content

Commit 9c8ff24

Browse files
authored
Add SMTP injection guards (#77)
* fix(smtp): block injection vectors in envelope addresses and header names Co-authored-by: null <> * fix(smtp): sanitize bare CR in header values and block DEL/non-ASCII in envelopes * fix(smtp): sanitize bare CR in header values and block DEL/non-ASCII in envelopes --------- Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
1 parent 0d2fa66 commit 9c8ff24

5 files changed

Lines changed: 192 additions & 5 deletions

File tree

.changeset/smtp-injection-guard.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opencoredev/email-sdk": patch
3+
---
4+
5+
Reject SMTP envelope addresses and header names that contain control characters, whitespace, or angle brackets before connecting. This closes an SMTP command/header injection vector where a crafted recipient address or header name could smuggle extra SMTP commands or headers into the session.

bun.lock

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

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
"dotenv": "^17.2.2",
2727
"zod": "^4.1.13"
2828
},
29+
"overrides": {
30+
"ws": "^8.20.1"
31+
},
2932
"devDependencies": {
3033
"@changesets/cli": "^2.31.0",
3134
"@email-sdk/config": "workspace:*",
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, test } from "bun:test";
2+
import net from "node:net";
3+
4+
import { EmailValidationError } from "./errors.js";
5+
import { smtp } from "./smtp.js";
6+
import type { EmailMessage } from "./types.js";
7+
8+
const baseMessage: EmailMessage = {
9+
from: "sender@example.com",
10+
to: "recipient@example.com",
11+
subject: "Hello",
12+
text: "Hi there",
13+
};
14+
15+
function send(message: EmailMessage) {
16+
// host points at an unroutable port; validation must reject before any connect.
17+
return smtp({ host: "127.0.0.1", port: 1 }).send(message, { attempt: 1 });
18+
}
19+
20+
// Runs a minimal in-process SMTP server, sends the message through the adapter,
21+
// and returns the raw DATA payload the client transmitted.
22+
async function captureSmtpData(message: EmailMessage) {
23+
let captured = "";
24+
let inData = false;
25+
26+
const server = net.createServer((socket) => {
27+
socket.setEncoding("utf8");
28+
socket.write("220 test.local\r\n");
29+
socket.on("data", (chunk: string) => {
30+
if (inData) {
31+
captured += chunk;
32+
33+
if (captured.endsWith("\r\n.\r\n")) {
34+
inData = false;
35+
socket.write("250 queued as test-id\r\n");
36+
}
37+
38+
return;
39+
}
40+
41+
const command = chunk.trim().toUpperCase();
42+
43+
if (command.startsWith("EHLO")) {
44+
socket.write("250 test.local\r\n");
45+
} else if (command.startsWith("MAIL") || command.startsWith("RCPT")) {
46+
socket.write("250 ok\r\n");
47+
} else if (command === "DATA") {
48+
inData = true;
49+
socket.write("354 go ahead\r\n");
50+
} else if (command === "QUIT") {
51+
socket.write("221 bye\r\n");
52+
socket.end();
53+
} else {
54+
socket.write("250 ok\r\n");
55+
}
56+
});
57+
});
58+
59+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
60+
const { port } = server.address() as net.AddressInfo;
61+
62+
try {
63+
await smtp({ host: "127.0.0.1", port }).send(message, { attempt: 1 });
64+
} finally {
65+
server.close();
66+
}
67+
68+
return captured;
69+
}
70+
71+
describe("smtp injection guards", () => {
72+
test("rejects CRLF injected into the envelope address", async () => {
73+
await expect(
74+
send({ ...baseMessage, to: "victim@example.com\r\nRCPT TO:evil@example.com" }),
75+
).rejects.toBeInstanceOf(EmailValidationError);
76+
});
77+
78+
test("rejects whitespace and angle brackets in the envelope address", async () => {
79+
await expect(
80+
send({ ...baseMessage, from: "attacker@example.com> evil" }),
81+
).rejects.toBeInstanceOf(EmailValidationError);
82+
});
83+
84+
test("rejects a CRLF injected header name", async () => {
85+
await expect(
86+
send({
87+
...baseMessage,
88+
headers: { "X-Trace\r\nBcc: hidden@evil.com": "1" },
89+
}),
90+
).rejects.toBeInstanceOf(EmailValidationError);
91+
});
92+
93+
test("rejects a colon in a header name", async () => {
94+
await expect(
95+
send({ ...baseMessage, headers: [{ name: "X-Bad: Injected", value: "1" }] }),
96+
).rejects.toBeInstanceOf(EmailValidationError);
97+
});
98+
99+
test("rejects DEL and non-ASCII characters in the envelope address", async () => {
100+
await expect(send({ ...baseMessage, to: "victim\x7f@example.com" })).rejects.toBeInstanceOf(
101+
EmailValidationError,
102+
);
103+
await expect(send({ ...baseMessage, to: "víctim@example.com" })).rejects.toBeInstanceOf(
104+
EmailValidationError,
105+
);
106+
});
107+
108+
test("bare CR in a header value is folded before transmission", async () => {
109+
const transmitted = await captureSmtpData({
110+
...baseMessage,
111+
headers: { "X-Custom": "legit\rBcc: evil@example.com" },
112+
});
113+
114+
// The lone \r is folded into a space, so the value stays on one header line
115+
// and no injected Bcc header reaches the wire.
116+
expect(transmitted).toContain("X-Custom: legit Bcc: evil@example.com");
117+
const injected = transmitted
118+
.split(/\r\n|[\r\n]/)
119+
.some((line) => line.toLowerCase().startsWith("bcc:"));
120+
expect(injected).toBe(false);
121+
});
122+
123+
test("accepts addresses with hyphens and plus signs", async () => {
124+
// A valid message should pass validation and fail later at the network layer,
125+
// never with a validation error.
126+
await expect(
127+
send({
128+
...baseMessage,
129+
from: "no-reply+tag@my-domain.example.com",
130+
to: "user.name+tag@sub-domain.example.com",
131+
}),
132+
).rejects.not.toBeInstanceOf(EmailValidationError);
133+
});
134+
});

packages/email-sdk/src/smtp.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { randomUUID } from "node:crypto";
22
import net from "node:net";
33
import tls from "node:tls";
44

5-
import { EmailProviderError } from "./errors.js";
5+
import { EmailProviderError, EmailValidationError } from "./errors.js";
66
import type { EmailAddress, EmailMessage, EmailProvider } from "./types.js";
77
import {
88
SUPPORTED_MESSAGE_FIELDS,
99
assertSupportedMessageFields,
1010
formatAddress,
1111
formatAddresses,
12+
headersToArray,
1213
headersToObject,
1314
} from "./utils.js";
1415

@@ -46,6 +47,7 @@ export function smtp(options: SmtpProviderOptions): EmailProvider<{ host: string
4647
},
4748
async send(message) {
4849
assertSupportedMessageFields(name, message, SUPPORTED_MESSAGE_FIELDS.smtp);
50+
assertSmtpMessage(message);
4951
const client = new SmtpClient(options, port);
5052

5153
try {
@@ -310,6 +312,46 @@ function buildMimeMessage(message: EmailMessage, defaults: SmtpProviderOptions["
310312
return `${headerText}\r\nContent-Type: ${contentType}; charset=utf-8\r\n\r\n${body}`;
311313
}
312314

315+
// Envelope addresses are interpolated into MAIL FROM/RCPT TO commands. RFC 5321
316+
// envelopes only allow printable US-ASCII, so reject whitespace (including
317+
// CR/LF), control characters (including DEL), angle brackets, and non-ASCII
318+
// characters before connecting.
319+
// oxlint-disable-next-line no-control-regex -- control characters are the point
320+
const SMTP_FORBIDDEN_ENVELOPE = /[\x00-\x20<>\x7f-\uffff]/;
321+
322+
// RFC 5322 header field names: printable US-ASCII (0x21-0x7e) excluding the
323+
// colon (0x3a). A name containing CR/LF would terminate the header and inject more.
324+
const SMTP_HEADER_NAME = /^[!-9;-~]+$/;
325+
326+
function assertSmtpMessage(message: EmailMessage) {
327+
const addresses = [
328+
formatAddress(message.from),
329+
...formatAddresses(message.to),
330+
...formatAddresses(message.cc),
331+
...formatAddresses(message.bcc),
332+
];
333+
334+
for (const address of addresses) {
335+
const envelope = parseEmailAddress(address);
336+
337+
if (envelope.length === 0 || SMTP_FORBIDDEN_ENVELOPE.test(envelope)) {
338+
throw new EmailValidationError(
339+
`SMTP envelope address ${JSON.stringify(envelope)} contains invalid characters.`,
340+
{ adapter: "smtp", address: envelope },
341+
);
342+
}
343+
}
344+
345+
for (const header of headersToArray(message.headers) ?? []) {
346+
if (!SMTP_HEADER_NAME.test(header.name)) {
347+
throw new EmailValidationError(
348+
`SMTP header name ${JSON.stringify(header.name)} contains invalid characters.`,
349+
{ adapter: "smtp", header: header.name },
350+
);
351+
}
352+
}
353+
}
354+
313355
function envelopeAddress(address: EmailAddress) {
314356
return parseEmailAddress(formatAddress(address));
315357
}
@@ -324,7 +366,7 @@ function escapeData(value: string) {
324366
}
325367

326368
function foldHeader(value: string) {
327-
return value.replace(/\r?\n/g, " ");
369+
return value.replace(/\r\n|[\r\n]/g, " ");
328370
}
329371

330372
function extractSmtpMessageId(response: string) {

0 commit comments

Comments
 (0)