01 //Introduction
Email header injection, sometimes called SMTP header injection or mail injection, is a server-side vulnerability that lets an attacker smuggle additional SMTP headers, or an entire second message, into mail that the application sends on their behalf. The carrier is almost always a contact form, a password-reset endpoint, an invitation flow, a support ticket auto-reply, or any feature that takes user input and turns it into the From:, Reply-To:, Subject:, or recipient fields of an outbound email. The attack is over twenty years old and still ships in fresh CVEs every quarter, because the vulnerable pattern, string-concatenating user input into a header value, is the most natural way to write the code.
The bug class sits in a quiet seam of the web stack. The application logic looks fine, the mail library looks fine, the SMTP server is RFC-compliant. The vulnerability lives in the moment a string crosses from the application's value space into the mailer's header space, the same kind of seam where HTTP response splitting, CSV injection, and log injection live. Once a carriage return + line feed (\r\n) survives unsanitised into that header string, the receiving parser treats everything after it as a new header line, and the attacker has free use of Bcc:, MIME boundaries, and message bodies. CWE-93 (Improper Neutralisation of CRLF Sequences) is the canonical reference.
Mail libraries got safer; glue code did not. Most modern mailers (Nodemailer, Python email.message.EmailMessage, JavaMail's InternetAddress, SwiftMailer 6+) reject embedded CRLF in header values. The reason this bug still ships is that developers build the value before handing it to the library: a From: "${name}" <noreply@app> template string, an InternetAddress(personal + " <" + addr + ">") call, or a PHP mail($to, $subj, $body, "From: $email\r\n") argument. The library never sees a structured value; it sees a finished header line that already contains the attacker's injection.
OWASP catalogues the issue in its Mail Command Injection page and in the Injection Prevention Cheat Sheet. The defensive recipe is identical across languages: validate addresses with a real parser (not a regex), reject CRLF outright before the value reaches any header, and prefer structured mailer APIs (setFrom(address, name)) over building header strings by hand. This module walks through the attack surface, the canonical payloads, and the per-language fix that closes the bug class properly.
A contact form takes `name`, `email`, and `message` and sends mail with PHP`s mail($to, $subject, $message, "From: $email")`. An attacker submits email = `evil@x.tld\r\nBcc: victim@bank.com\r\nSubject: Free crypto`. What does the receiving MTA see?
02 //The Attack Flow
Every email header injection follows the same four-stage flow: the attacker supplies a value into a field that the application uses to compose a header, the application concatenates that value into a header string with insufficient sanitisation, the mailer hands the string to an MTA (sendmail, postfix, an SMTP relay, an API like SES or Postmark), and the MTA fans the resulting message out to the recipients listed in the headers, including any that the attacker smuggled in via CRLF. The application is a relay; the bug is in the moment the value crosses from "string the app owns" to "header line the protocol owns".
Bcc: inside a contact-form name or email fieldFrom: / Reply-To: / Subject:The vulnerability is not that the mailer library is buggy. Modern libraries (Nodemailer, Python email.message, JavaMail, SwiftMailer) refuse newlines in header values. The bug is in the glue code that builds the header value before handing it to the library, the developer concatenates user input into a string and passes the string as From: or Subject:. The library sees a single value with a newline in it and, on older versions or permissive paths, faithfully writes both lines to the wire.
The shape of a realistic exploit depends on what the application's mailer is willing to send. Modern Nodemailer, SwiftMailer 6, and JavaMail with mail.mime.allowutf8=true all reject literal CRLF in header values, but they do not protect against the much more common path where the developer interpolates user input into a single header string and passes the finished line to a lower-level API. That includes PHP's mail(), Python's legacy smtplib.SMTP.sendmail with a hand-built MIME envelope, Go's net/smtp.SendMail with a concatenated msg byte slice, and any direct DATA command against an SMTP socket.
Common Sink Surfaces in Real Applications
| Where mail is composed | Typical injectable fields |
|---|---|
| Contact / "get in touch" form | name, email, subject, message body |
| Password-reset / magic-link mailer | submitted email (rendered into Subject or From display name) |
| Invite a friend / referral flow | inviter name, invitee email, optional message |
| Support ticket auto-reply | subject prefix, customer name, ticket reference |
| Order / receipt mailer | shipping name, billing name, custom note field |
| Calendar / meeting invites | organiser display name, location text, description |
| Comment / mention notifications | mentioner display name, comment excerpt in Subject |
| Localised templates | translator-controlled Subject prefixes from i18n bundles |
| Webhook → email bridges | arbitrary attacker-controlled payload fields |
Contact forms ask the user for an email address, then immediately use that address as the From: or Reply-To: header of a mail to the support inbox. That is the most direct attacker-controlled-to-header path in any product. Almost every email header injection CVE you will read started life as a contact form, including the long tail of WordPress, Joomla, and Drupal plugin advisories (CVE-2014-9081, CVE-2016-10033 / PHPMailer, CVE-2018-19518 / Zend, and many more).
1// VULNERABLE, the application constructs the From header as a single
2// string and passes it to Nodemailer. Nodemailer's structured
3// API would have rejected newlines, but we never used it.
4import nodemailer from 'nodemailer';
5
6const transport = nodemailer.createTransport({ /* SES / SMTP config */ });
7
8export async function sendContactMessage(req, res) {
9 const { name, email, subject, message } = req.body;
10
11 await transport.sendMail({
12 // BAD, attacker-controlled name and email concatenated into one header string.
13 from: `"${name}" <${email}>`,
14 to: 'support@app.tld',
15 subject: subject, // BAD, subject reaches header unchecked
16 text: message,
17 });
18
19 res.json({ ok: true });
20}
21
22// An attacker submitting name = "Alice\r\nBcc: victim@bank.com" produces
23// a From line whose newline becomes a header separator. The smuggled Bcc
24// is delivered as part of the same envelope.The team patches the From field to reject newlines. A pentester reports the Subject line is still injectable. Why is that a separate fix, and what does it tell you about the original review?
03 //Payload Anatomy & Trigger Bytes
A header value becomes injectable the moment its raw bytes contain a carriage return (\r, 0x0D) or line feed (\n, 0x0A). RFC 5322 defines a header line as fields separated by CRLF, so any CRLF inside a value ends the current header and starts a new one. URL-encoded forms of these bytes (%0d, %0a) decode before they reach the header in most web stacks, which is why query-string-driven mailers (mailto handlers, magic-link triggers) are especially exposed. Double-encoded variants (%250d) catch any layer that decodes twice.
From:,Reply-To:,Sender:To:,Cc:,Bcc:(especially comma-joined lists)Subject:(often forgotten, also CRLF-injectable)X-*custom headers (tracking IDs, locale, tenant)Message-ID:,References:,In-Reply-To:- MIME
Content-Typeboundary parameters
- Display name in
"Name" <addr>syntax - Template variables interpolated into a static
From:string ?subject=query params that drive amailto:backend- File names in attachment
Content-Dispositionheaders - Localised
Subject:prefixes loaded from a translation file an editor can control
The full trigger set is { '\r', '\n', '%0d', '%0a', '%0D', '%0A' }plus their double-encoded variants. If any user-controlled string reaches a header value and these bytes are not rejected before the mailer sees them, you have an injection sink. Subject lines and display names are by far the most common forgotten path.
Payload Catalogue, What an Attacker Actually Writes
| Payload (in a name or email field) | Effect | Mitigation that defeats it |
|---|---|---|
| attacker@x.tld\r\nBcc: victim@bank.com | Adds a hidden Bcc; the application becomes a phishing relay | Reject any header value containing \r or \n |
| Alice\r\nSubject: You won a prize | Replaces the Subject (some MTAs use the last seen header) | Validate Subject with the same header-value validator |
Alice\r\nContent-Type: text/html; charset=utf-8\r\n\r\n... | Smuggles a whole HTML body, breaks out of the original MIME envelope | Use a structured mailer that builds MIME parts; never concatenate the envelope by hand |
| a@x.tld%0d%0aBcc:%20victim@bank.com | URL-encoded variant for query-string driven mailers | Decode once, then validate; never decode in the header layer itself |
| a@x.tld%250d%250aBcc:... | Double-encoded; defeats a single naive decode | Validate post-decode; reject any value containing \r/\n after every decode pass |
| "Alice\r\n attacker"@x.tld | CRLF inside a quoted local-part of an address | Parse addresses with a real RFC 5322 parser, not a regex; reject control chars even inside quotes |
| Alice , victim@bank.com | Comma-injection into a recipient list joined by commas | Validate each address separately, then join; do not let user input contain a comma |
| a@x.tld\nX-Original-Sender: legit@app.tld | Smuggles a custom header that downstream filters trust | Reject \n as well as \r; SMTP allows bare-LF in many implementations |
1# Classic Bcc smuggling, the original 2002 attack, still works today.
2attacker@x.tld\r\nBcc: victim@bank.com\r\nSubject: Refund pending
3
4# MIME envelope break, replaces the entire message body.
5Alice\r\nContent-Type: text/html\r\n\r\n<a href="https://evil.tld">Click</a>
6
7# URL-encoded form for query-string mailers (?to=, ?subject=).
8to=user@app.tld%0d%0aBcc:victim@bank.com&subject=UpdateA common half-fix is to reject \r\n but allow a bare \n. The RFC says headers are CRLF-terminated, but sendmail, postfix, and exim all accept bare LF as a line terminator in practice (the "lone LF" extension dates to the 1980s). Any validator must reject both \r and \n independently, not the literal two-byte sequence.
A reviewer suggests rejecting the string '\\r\\n' in header values. A teammate replies 'that misses bare LF, bare CR, NUL bytes, and the encoded forms'. Who is right and what is the minimal correct rule?
04 //Mitigation Patterns
There are three correct mitigations, in rough order of preference. Use a structured mailer API that builds headers from typed values (so user input never reaches header-line syntax); validate every header value with a single helper that rejects control bytes; and parse addresses with a real RFC 5322 parser before they ever reach a recipient list. Each one is a few lines of code; the discipline is to apply them at every sink, not just the contact form.
Mitigation Options at a Glance
| Strategy | How | When to use |
|---|---|---|
| Structured mailer API | Use library calls like setFrom(address, name), addBcc(address), setSubject(text) instead of building header strings | Always. This is the primary control; modern libraries reject CRLF in structured calls. |
| Header-value validator | Reject any value containing \r, \n, or \0 before passing it to the mailer | For the unavoidable cases where you must still touch raw header strings (legacy code, custom X-* headers). |
| RFC 5322 address parser | Parse addresses with a real parser (email-validator, Python email.utils.parseaddr, JavaMail InternetAddress) | Wherever user input becomes a recipient or appears in From/Reply-To. |
| Separate display name from address | Never let users supply the full "Name | Contact forms, invite flows, anywhere a personalised From is needed. |
| Drop user input from headers entirely | Use a fixed From: noreply@app.tld; put the user's email in Reply-To via a structured call, or only inside the body | When the header value is not actually needed for delivery; the safest default for transactional mail. |
1// lib/mail-safe.ts, one canonical helper used at every mail sink.
2//
3// Strategy: reject any control byte that could terminate a header line.
4// The structured mailer API does most of the work; this validator is the
5// belt-and-braces layer for legacy code and custom X-* headers.
6
7const CONTROL_BYTES = /[\r\n\0]/;
8
9/** Throws if the value cannot safely become a header value. */
10export function assertHeaderSafe(value: string, field: string): void {
11 if (typeof value !== 'string') {
12 throw new Error(`${field}: must be a string`);
13 }
14 if (CONTROL_BYTES.test(value)) {
15 throw new Error(`${field}: contains forbidden control characters`);
16 }
17 if (value.length > 998) {
18 // RFC 5322 line length cap, defensive against folding-based smuggling.
19 throw new Error(`${field}: exceeds 998 characters`);
20 }
21}
22
23/** Returns a validated, ready-to-use header value. */
24export function headerSafe(value: unknown, field: string): string {
25 const s = value == null ? '' : String(value);
26 assertHeaderSafe(s, field);
27 return s;
28}
29
30// Use the helper at EVERY mail sink. Do not pass raw values through.
31import nodemailer from 'nodemailer';
32
33await transport.sendMail({
34 from: { name: headerSafe(name, 'name'), address: 'noreply@app.tld' },
35 replyTo: headerSafe(email, 'email'),
36 to: 'support@app.tld',
37 subject: headerSafe(subject, 'subject'),
38 text: message, // body is fine; CRLF is legal here
39});Every "email regex" you have ever seen is wrong. RFC 5322 is more permissive than developers expect (quoted local-parts, IP-literal domains, comments) and less permissive in the places that matter (control bytes inside quotes). Use email-validator or validator.isEmail in JS, email.utils.parseaddr + email_validator in Python, InternetAddress(addr, true) in Java. Then reject anything whose parsed form contains a comma, semicolon, or control byte. Regex defenses are routinely bypassed by quoted-string payloads.
1# lib/mail_safe.py, the structured API is the primary control;
2# the validator is the second layer for any remaining raw-header path.
3import re
4from email.message import EmailMessage
5from email_validator import validate_email, EmailNotValidError
6
7_CONTROL = re.compile(r"[\r\n\x00]")
8
9def assert_header_safe(value: str, field: str) -> None:
10 if not isinstance(value, str):
11 raise ValueError(f"{field}: must be a string")
12 if _CONTROL.search(value):
13 raise ValueError(f"{field}: contains forbidden control characters")
14 if len(value) > 998:
15 raise ValueError(f"{field}: exceeds 998 characters")
16
17def safe_address(addr: str) -> str:
18 # Raises on invalid syntax, IDN domains are normalised.
19 return validate_email(addr, check_deliverability=False).normalized
20
21def build_contact_message(name: str, email: str, subject: str, body: str) -> EmailMessage:
22 assert_header_safe(name, "name")
23 assert_header_safe(subject, "subject")
24 email = safe_address(email)
25
26 msg = EmailMessage()
27 # Structured calls; the library refuses to embed CRLF.
28 msg["From"] = ("noreply@app.tld",)
29 msg["Reply-To"] = email
30 msg["To"] = "support@app.tld"
31 msg["Subject"] = subject
32 msg.set_content(body)
33 # The display name reaches headers via a structured 2-tuple,
34 # not by concatenating "Name <addr>" by hand.
35 msg.replace_header("Reply-To", f"{name} <{email}>")
36 return msg1<?php
2// Use PHPMailer's structured API. Never call mail() with concatenated
3// headers; do not pass attacker-controlled strings to the $headers arg.
4use PHPMailer\PHPMailer\PHPMailer;
5use PHPMailer\PHPMailer\Exception;
6
7function assert_header_safe(string $value, string $field): void {
8 if (preg_match('/[\r\n\x00]/', $value) === 1) {
9 throw new InvalidArgumentException("$field contains control bytes");
10 }
11 if (strlen($value) > 998) {
12 throw new InvalidArgumentException("$field exceeds 998 characters");
13 }
14}
15
16function send_contact(string $name, string $email, string $subject, string $body): void {
17 assert_header_safe($name, 'name');
18 assert_header_safe($subject, 'subject');
19 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
20 throw new InvalidArgumentException('email is not a valid address');
21 }
22
23 $mail = new PHPMailer(true);
24 $mail->setFrom('noreply@app.tld'); // fixed From, not user-controlled
25 $mail->addReplyTo($email, $name); // structured, library refuses CRLF
26 $mail->addAddress('support@app.tld');
27 $mail->Subject = $subject; // structured, library validates
28 $mail->Body = $body;
29 $mail->send();
30}
31// Reminder: CVE-2016-10033 (PHPMailer) was a header-injection-class bug
32// in the From parameter. Keep the library patched and avoid the legacy
33// mail() function entirely.1import jakarta.mail.*;
2import jakarta.mail.internet.*;
3
4public final class MailSafe {
5 private static final java.util.regex.Pattern CONTROL =
6 java.util.regex.Pattern.compile("[\\r\\n\\x00]");
7
8 public static String headerSafe(String value, String field) {
9 if (value == null) return "";
10 if (CONTROL.matcher(value).find()) {
11 throw new IllegalArgumentException(field + " contains control bytes");
12 }
13 if (value.length() > 998) {
14 throw new IllegalArgumentException(field + " exceeds 998 characters");
15 }
16 return value;
17 }
18
19 public static void sendContact(Session session, String name,
20 String email, String subject, String body)
21 throws MessagingException {
22 // Strict parsing: throws on syntactically invalid addresses,
23 // including ones with embedded CRLF inside a quoted local-part.
24 InternetAddress reply = new InternetAddress(email, true);
25 reply.setPersonal(headerSafe(name, "name"));
26
27 MimeMessage msg = new MimeMessage(session);
28 msg.setFrom(new InternetAddress("noreply@app.tld"));
29 msg.setReplyTo(new Address[] { reply });
30 msg.setRecipient(Message.RecipientType.TO,
31 new InternetAddress("support@app.tld"));
32 msg.setSubject(headerSafe(subject, "subject"));
33 msg.setText(body);
34 Transport.send(msg);
35 }
36}The pattern that actually works in practice is the same as for CSV escaping, prepared statements, and HTML sanitisation: one validator in a shared library, plus a code-review or lint rule that flags any mailer call not fed through it. The validator is fifteen lines of code. The discipline is what closes the bug class, every new transactional mail must go through the helper, and code review enforces that.
A developer says: 'our mailer library already rejects CRLF, so we do not need a validator'. Why is that not enough?
05 //Conclusion
Email header injection is a textbook case of trust transferred across a syntactic seam. The application is correct; the mail library is correct; the MTA is correct. The vulnerability lives in the moment a string the application treated as a value becomes a string the protocol treats as a header line. Fix it the same way every adjacent bug class is fixed: a single helper, applied at every sink, paired with a structured mailer API and an outbound posture (SPF, DKIM, DMARC) that limits the damage when something does slip through.
Reject \r, \n, and \0 in every header value, no exceptions · Parse addresses with a real RFC 5322 parser, never a regex · Use the mailer's structured API for From, To, Cc, Bcc, Reply-To, Subject, and custom headers · Centralise validation in one helper used at every mail sink · Generate MIME boundaries from a CSPRNG, never from user input · Decode RFC 2047 encoded-words before validating · Default to a fixed application From: noreply@your-domain · Add a Semgrep / CodeQL rule that flags raw header strings or concatenated From / Subject · Enforce SPF, DKIM, and strict-alignment DMARC on every sending domain.
When you see a new mailer call in a diff, the first question is not "does it send the right mail", it is "what user input reaches a header value, and how?" If the answer involves string concatenation or template interpolation, every byte in that value is potentially a new header line. The fix is one helper and one structured API away. Pair this module with the HTTP Header Injection & CRLF Injection, CSV / Formula Injection, and Secure Logging Practices guides for the adjacent classes where the bug is "an output sink reinterprets your data as something more dangerous."