Security Engineering · Web Application Security
Header Splitting: When a Value Becomes a Field Boundary
How CR and LF terminate header fields on the wire, what modern servers do to reject them, and why the response body and the request line need separate treatment.
In HTTP/1.1 the message is not length-prefixed. The framing is positional: a start line, then header fields one per line, then an empty line, then the body whose length is declared by a header field or delimited by connection close. Line endings are the only thing separating a field from the field after it. A byte sequence that can inject a line ending into a field value therefore has the ability to create fields — and a byte sequence that can inject a blank line has the ability to end the header block entirely.
RFC 9112 defines the line terminator as CRLF, and specifies that a recipient may recognise a bare LF as a line terminator. That receiver leniency is a deliberate robustness measure, and it widens the injection surface: a value containing only \n can split a message on any parser that accepts bare LF.
What injection into a response buys
The canonical attack is a value reflected into a response header. A redirect endpoint that takes the destination from a query parameter and places it in a Location field:
HTTP/1.1 302 Found
Location: /next
If the destination is /next%0d%0aSet-Cookie:%20session=attacker, a server that decodes the parameter and writes it into the header without validation produces:
HTTP/1.1 302 Found
Location: /next
Set-Cookie: session=attacker
The browser client sees two header fields and honours the injected one. This is response splitting, and its most durable consequence is cookie injection: a new Cookie-adjacent value planted in the browser, which can be used for session fixation or for planting a value that a later gadget reads. A second blank line in the injected value ends the header block and lets the attacker write an arbitrary response body, which is a cross-site scripting primitive in a browser that sniffs the content type, and a cache-poisoning primitive in a shared cache that stores the combined response.
The Set-Cookie case has an extra wrinkle. The cookie grammar in RFC 6265 forbids control characters in the value, but the header’s own syntax allows a Set-Cookie header to be followed by attributes; a value that can insert \r\n can begin a second Set-Cookie, and two Set-Cookie fields with the same name are legal and both are processed. Nothing in the header block rejects that.
Where servers stand
The remediation is straightforward in principle and has been implemented unevenly. A server that constructs header values from strings must reject any value containing CR, LF, or NUL before writing. Several runtimes now enforce this in their HTTP layers rather than leaving it to application code: Node.js rejects header values containing invalid characters with a TypeError at the point the header is set, Python’s http.client rejects control characters in putheader, and Java’s servlet containers reject CR and LF in header values. Where the enforcement exists, an injection attempt becomes an exception rather than a malformed response, which is the correct failure direction.
The enforcement is not universal, and it is not always at the layer the developer assumes. A framework that writes headers through its own abstraction may validate, while a lower-level API on the same server may not. Proxies and reverse proxies in front of the application may each have their own parser with its own leniency, and the response the client ultimately parses is the one after all of them.
HTTP/2 and HTTP/3 remove the framing ambiguity at one level and reintroduce it at another. Frames carry headers as HPACK-encoded name-value pairs, so there is no line to terminate and a CR/LF inside a value cannot create a second field. The handling of an invalid byte in a header value is a protocol error that terminates the stream. But proxies translate between versions, and a proxy that accepts an HTTP/1.1 request with a bare LF and forwards it to an HTTP/2 backend must decide what to do with a value that the HTTP/2 side would reject. Divergence between the two parsers is the seed of request smuggling, which is covered from the request-side framing angle below.
Redirect targets and the encoding chain
Redirect parameters are the common place where the injection is reachable, and the reason is the encoding chain. A parameter arrives percent-encoded, the framework decodes it, and the decoded value is placed into a header. If the application encodes the value before placing it, the CR and LF become %0D and %0A and the framing is preserved — but then the URL is wrong, because the client will decode the percent escapes when it follows the redirect. The Location field is a URL, so encoding is not an escape hatch; the value must be a valid URL, and the way to guarantee that is to parse it, not to sanitise it.
Parsing solves both problems at once. A relative path is accepted by resolving it against the request’s base; an absolute URL is accepted only if its scheme and host are on an allowlist. The parsed result is then serialised by the same parser, and a serialised URL cannot contain a raw control character, because the URL parser percent-encodes them. The remaining question is which parts of the URL the attacker controls and whether that is acceptable — an open redirect is a lesser but real problem, since it lends the application’s domain to a phishing link.
The general form of the fix is the same as in SQL injection and HTML encoding: parse the value into the target grammar, validate the parsed structure, and re-serialise. String-level filtering of \r and \n treats the symptom; the next injection may use a character the filter missed and a parser that accepts it.
The request side is a different problem
Injection of CRLF into a request the application emits is a related but distinct failure. An application that builds an outbound request — a webhook, an internal service call, a proxy fetch — from user-supplied data and writes the request line or headers by concatenation can be made to send a request to a different host, or to append headers such as X-Forwarded-For that an internal service trusts. The injection point is the request line rather than a response header, and the consequence is usually forged internal traffic rather than a client-side execution.
The defence is the same shape: use an HTTP client library that takes a structured request rather than a byte string, so that the host, path, and headers are separate values and the library applies its own encoding. Where the URL must be constructed from parts, parse it and modify the parsed structure.
Disagreement between parsers
The request-side case that has the widest blast radius is not injection at all but ambiguity. RFC 9112 specifies how a message body is delimited: a Transfer-Encoding: chunked field takes precedence over Content-Length, and a message containing both is susceptible to smuggling because a proxy and an origin may resolve the conflict differently. A proxy that honours Content-Length and an origin that honours Transfer-Encoding will disagree about where the first request ends, and the bytes the proxy considers the body of request one become request two at the origin.
The CR and LF leniency feeds this directly. A proxy that rejects a bare LF in a chunk-size line and an origin that accepts it will disagree about framing; the same is true of whitespace before a header field name, of an obsolete line folding continuation, and of the number of spaces after the colon. The hardened configuration for every hop is to reject anything the specification does not unambiguously permit: bare CR, bare LF, whitespace before the colon, and messages carrying both Content-Length and Transfer-Encoding.
A cache in the path makes parser disagreement into cache poisoning. If the cache and the origin disagree about the length of the request, the cache may associate a response with a key derived from the first request while the origin generated the response for the second. The injectable field in that case is usually Host, which is why cache deployments increasingly key on the normalised Host and reject requests with a Host that does not match the connection’s destination.
The invariant
The invariant worth holding onto: a header value is a sequence of field-value characters, and nothing outside that set may appear in it. CR, LF, and NUL are never valid in a field value, and their presence is a signal that a value crossed a boundary it should not have crossed. Enforcing that invariant at the point where headers are written, for responses and requests alike, converts every one of these attacks from a silent protocol corruption into a rejected request.
The same discipline appears wherever a response is assembled from parts. A redirect target that must be a URL is a value for a different grammar, and the setting where the destination is genuinely user-chosen is the one described under server-side request forgery, where the URL is parsed and validated rather than concatenated.
References
- RFC 9112: HTTP/1.1 — IETF
- RFC 9110: HTTP Semantics — IETF
- RFC 9113: HTTP/2 — IETF
- RFC 6265: HTTP State Management Mechanism — IETF