Header injection
Email header injection is a class of vulnerability where a submitter’s input gets concatenated into an email’s headers, allowing them to inject additional headers (Bcc:, Cc:, From: overrides, etc.) or split the message entirely.
This is one of the oldest and most common bugs in contact-form software. Posthorn defends against it by construction, not by trying to sanitize input.
The attack
Section titled “The attack”A naive contact-form implementation might do something like:
// VULNERABLE — do not do thisheaders := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n", formName, formEmail, formSubject)If formName contains \r\nBcc: attacker@evil.com\r\n, the attacker has now added themselves as a Bcc recipient — every message goes to them silently. Worse, with \r\n\r\n they can terminate the headers entirely and inject a new message body.
How Posthorn prevents it
Section titled “How Posthorn prevents it”Three layers, all enforced in code:
1. No string concatenation into headers
Section titled “1. No string concatenation into headers”The Postmark transport submits emails as JSON to Postmark’s HTTP API, not as raw SMTP messages. Every header is a JSON field:
type postmarkRequest struct { From string `json:"From"` To string `json:"To"` ReplyTo string `json:"ReplyTo,omitempty"` Subject string `json:"Subject"` TextBody string `json:"TextBody"`}The JSON encoder escapes control characters in values automatically. A field value like Alice\r\nBcc: attacker@evil.com becomes the JSON string "Alice\r\nBcc: attacker@evil.com" — Postmark receives that as a single header value and rejects or sanitizes it server-side.
No fmt.Sprintf or string concatenation of submitter input into headers, anywhere in the transport.
2. Submitter input only feeds body templates, not header fields
Section titled “2. Submitter input only feeds body templates, not header fields”The header fields (From, To, Reply-To, Subject) come from:
From: configured per-endpoint (fromin the TOML). Operator-controlled. Submitter cannot influence.To: configured per-endpoint (toin the TOML). Operator-controlled. Submitter cannot influence.Reply-To: value of the configuredemail_fieldfrom the form. This is submitter-controlled — see below.Subject: rendered from thesubjecttemplate, which can include submitter input.
For Reply-To: the email field passes through the same JSON-encoded structured field; CRLF injection in the email field cannot break out. Additionally, Posthorn validates the email field’s format (Validation) before send, so a payload like alice@example.com\r\nBcc:... fails the email syntax check and gets 422’d before reaching the transport.
For Subject: the template rendering produces a Go string, which becomes a JSON field value. Same defense — JSON encoding escapes control characters.
3. Explicit test coverage
Section titled “3. Explicit test coverage”The test suite includes a table of known injection payloads:
{name: "crlf in name", payload: "Alice\r\nBcc: attacker@evil.com"},{name: "crlf in email", payload: "alice@a.com\r\nBcc: attacker@evil.com"},{name: "crlf in subject", payload: "Hello\r\nBcc: attacker@evil.com"},{name: "smuggled header", payload: "Alice\nX-Mailer: malicious"},{name: "double crlf", payload: "Hello\r\n\r\nNew body content"},{name: "lone lf", payload: "Hello\nBcc: x@y"},{name: "lone cr", payload: "Hello\rBcc: x@y"},For each payload, the test asserts:
- The outbound HTTP request to Postmark contains exactly the headers Posthorn intended (no
Bcc:, no smuggledX-Mailer:). - The payload appears in the message body (in the rendered template), not in the header.
These tests run on every build. A regression that re-introduced string-concatenated headers would fail CI immediately.
What you don’t need to do
Section titled “What you don’t need to do”You do not need to:
- Sanitize submitter input yourself before passing it to Posthorn
- Strip CRLF from form values in client JavaScript
- Run an additional Postfix milter or sanitizer
- Configure header filtering at the reverse proxy
Posthorn handles header safety as a code-level invariant. Submitter input is just data; it never becomes header structure.
What you should still do
Section titled “What you should still do”- Validate the
email_field— Posthorn does this by default withemail_field = "email". Don’t disable it. - Use Postmark’s verified-sender domain — Postmark rejects messages from
Fromaddresses not on a verified domain. This is an additional Postmark-side defense; keep it on. - Set up SPF, DKIM, DMARC on your sending domain — these don’t defend against header injection but defend against your domain being used for spoofing more broadly. See DNS.
The same guarantee across all transports
Section titled “The same guarantee across all transports”Every transport (Postmark, Resend, Mailgun, SES) uses the structured-data approach. The Postmark transport set the pattern: JSON-encoded request bodies, no Sprintf into header values, table-driven injection tests included. The others followed.
The outbound-SMTP transport is the one where headers are constructed as a wire-level SMTP message rather than a JSON payload. Its implementation uses net/mail and net/smtp to compose headers via the standard library’s structured APIs — never Sprintf of submitter input into raw header lines — plus a pre-write CRLF validation at Posthorn’s edge that rejects header-injection attempts before the SMTP session even opens. The same injection-payload test table runs against it.
The architecture explicitly bans transport implementations from constructing headers via string formatting. New transports that violate this don’t merge.
The SMTP ingress carries the invariant too: recipients in the outbound message come from the SMTP envelope (RCPT TO), never from the inbound Multipurpose Internet Mail Extensions (MIME) To/Cc/Bcc headers. A malicious internal client sending Bcc: victim@target.com in a DATA blob can’t add recipients — the header lands in the parsed map and is discarded. See SMTP ingress for the listener-side defense.