Internet Protocols · HTTP
Message Delimitation and the Mechanics of Request Smuggling
How Content-Length, Transfer-Encoding, and connection close define where a message ends, and how disagreements between parsers turn that boundary into an attack.
A request has no explicit length at the transport layer. The receiver learns where one request ends by reading the field section and then applying rules about Content-Length and Transfer-Encoding. When two receivers in a chain apply those rules differently, the bytes one of them treats as body are the bytes the other treats as the start of a new request. That is request smuggling, and it is a framing bug before it is a security bug.
Three ways to delimit a body
The first is Content-Length: an integer count of octets. The receiver reads exactly that many bytes and the message ends.
The second is chunked transfer coding, signalled by Transfer-Encoding: chunked. The body is a sequence of chunks, each introduced by a hexadecimal length, then CRLF, then that many bytes, then CRLF. A chunk of length zero terminates the body, followed by an optional trailer section and a final CRLF. The advantage is that the sender can begin transmitting before the total length is known, which matters for a response generated on the fly and for a request whose body is being streamed.
The third is connection close. A response with neither field, and a status that permits a body, is delimited by the server closing the connection. This is legal only for responses, because a request’s end cannot be signalled by closing the connection the client still wants to use.
For requests, the rules are strict and worth stating in order. If Transfer-Encoding is present and its final coding is chunked, the message is chunked-delimited and Content-Length must be ignored. If Transfer-Encoding is present and its final coding is not chunked, the server must respond 400 for a request and close, because it cannot determine the body’s end. If Transfer-Encoding is absent, Content-Length governs. If both are absent, the request has no body.
RFC 9112 goes further and says a server that receives both fields ought to treat it as an error and close the connection, because the message is contradictory. That “ought to” is the gap that smuggling exploits: a server that ignores Content-Length when Transfer-Encoding is present is conformant, and a server that uses Content-Length is not, but both exist.
The classic desync
Consider a front-end proxy and a back-end server sharing a connection, which is the normal topology for a reverse proxy with upstream keep-alive. The client sends a request whose field section contains both fields:
POST /search HTTP/1.1
Host: example.com
Content-Length: 6
Transfer-Encoding: chunked
0
G
The six bytes after the blank line are 0, CRLF, CRLF, G: a zero-length chunk, the chunked terminator, and one byte left over. The two parsers disagree about where the message ends.
A front end that prefers Content-Length reads six bytes, considers the message complete, and forwards it upstream with both fields intact. The back end prefers Transfer-Encoding, parses the zero chunk, ends the body there, and finds G sitting at the start of its buffer. Those bytes are now the beginning of the next request on that upstream connection, and the next client to reuse it has a request line prefixed with G; or, if the attacker chose the trailing byte to be P, the start of a POST the back end will attribute to that client, cookies included.
The other direction is the same disagreement with the roles swapped. A front end that prefers Transfer-Encoding sees the message end after the five bytes of 0, CRLF, CRLF. If it normalises the request — re-encoding the empty body and sending Content-Length: 0 — there is no desync, even against a back end that reads Content-Length. If it forwards those five bytes while leaving Content-Length: 6 in place, the back end waits for a sixth byte and takes it from the next request on the shared connection.
The general shape is the same for every variant: a length disagreement between two parsers, a shared connection, and attacker-controlled bytes positioned at the boundary. The specific disagreement can be Content-Length versus Transfer-Encoding, two different Content-Length values, an obfuscated Transfer-Encoding field name, a chunk extension that one parser accepts and another rejects, or a line terminator that one parser treats as a terminator and another treats as part of a value.
Why obfuscation works
Parsers normalise field names differently, and any difference is exploitable when it changes which of two fields the parser considers present.
A field name with a trailing space, Transfer-Encoding : chunked, is invalid because no whitespace is allowed before the colon. A strict parser rejects the message. A lenient one trims the name, sees a valid field, and applies chunked framing. If the front end rejects and the back end accepts, or the reverse, the two disagree about the body’s extent.
A duplicated field is another route. Transfer-Encoding: chunked appearing twice may be treated as a single chunked coding by one parser and as an invalid message by another. Two Content-Length fields with different values must cause a 400; a parser that takes the first and one that takes the last disagree by construction.
Chunk extensions are a third. A chunk header may carry extensions after a semicolon, and the specification says recipients must ignore them. A parser that fails to parse an extension and falls back to treating the line as a request line will split the message in the wrong place.
The underlying cause is always the same: leniency. Every tolerance a parser adds to accept malformed traffic is a rule that some other parser does not share, and the two rules together define a boundary neither sender intended.
What correct implementations do
The defensive rules are unglamorous and complete.
Reject any request with both Transfer-Encoding and Content-Length. Do not choose one. Close the connection. The message is ambiguous and there is no legitimate sender that produces it.
Reject a Transfer-Encoding value whose final coding is not chunked on a request. Reject multiple Content-Length values unless they are identical, and reject a Content-Length that is not a bare sequence of digits.
Parse the field section strictly: no whitespace before the colon, no obs-fold in requests, no bare LF as a terminator. RFC 9112 permits leniency in what a recipient accepts, but a proxy is not an ordinary recipient; it is a recipient whose output becomes another party’s input, so the tolerance it grants itself becomes the ambiguity it hands downstream.
Normalise on the way out. A proxy that forwards a request should emit exactly one length signal, in the form its upstream understands, and should never forward both fields. If it decodes a chunked body, it must recompute Content-Length and remove Transfer-Encoding; if it forwards chunked, it must remove Content-Length.
Reject requests that arrive with bytes left over after the declared body. If a parser finishes a message and finds the buffer non-empty, either those bytes are the next pipelined request, or they are an injection. Distinguishing them requires knowing whether pipelining was expected, and the safe default for an intermediary is to close.
// lengthOf returns the body length, or an error if the message is ambiguous.
func lengthOf(h header) (int64, error) {
te := h.values("Transfer-Encoding")
cl := h.values("Content-Length")
if len(te) > 0 && len(cl) > 0 {
return 0, errAmbiguous // never pick a winner
}
if len(te) > 0 {
if !lastCodingIsChunked(te) {
return 0, errUnsupportedCoding
}
return -1, nil // chunked: length is determined by parsing
}
switch len(cl) {
case 0:
return 0, nil
case 1:
return parseDecimal(cl[0])
default:
if allEqual(cl) {
return parseDecimal(cl[0])
}
return 0, errAmbiguous
}
}
Returning -1 for chunked rather than a number forces the caller to run the chunk parser, which is the only place the boundary can be established. A caller that treats -1 as “no body” has reintroduced the bug.
The connection-close case and responses
Responses have their own desync shape, less discussed but structurally identical. A response delimited by connection close cannot be cached and cannot be followed by another response on the same connection. An intermediary that receives such a response and forwards it to a client while reusing its upstream connection will splice the next response’s bytes into the first one’s body from the client’s perspective.
The rule that prevents this is that a response without Content-Length or Transfer-Encoding and with a body-permitting status must be forwarded with the connection closing behind it, or must be buffered to determine its length. Buffering costs memory proportional to the response, which is why streaming proxies prefer to re-encode as chunked. Both are correct; neither is free.
Where the responsibility sits
Smuggling is not fixed by any single implementation being strict. It is fixed by every parser in a chain agreeing, and the practical lever is that the front end and the back end must be configured to the same rules. A hardened proxy in front of a lenient application server is still exploitable if the proxy’s own parsing has any tolerance the server lacks.
The specification’s contribution is a set of rules that, if followed exactly, leave no ambiguity: one length signal, strict field syntax, rejection of contradictory messages, and connection close on any error that leaves the buffer’s state uncertain. The framing rules describe the grammar; smuggling is what happens when the grammar is enforced unevenly across a chain of intermediaries. The last observation is the one that generalises beyond HTTP: whenever a length is carried in the payload rather than by the transport, the receiver’s parser is part of the security boundary, and any two parsers that disagree create a channel between them.
References
- RFC 9112: HTTP/1.1 — IETF
- RFC 9110: HTTP Semantics — IETF
- The Tangled Web: A Guide to Securing Modern Web Applications — Michal Zalewski, No Starch Press, 2011