HTTP request smuggling and proxy desync
Why two HTTP parsers in a chain disagree on message length, the CL.TE, TE.CL, TE.TE, H2.CL and client-side desync classes, and the defences that work.
Key points
- Smuggling is a framing disagreement rather than a memory bug, because two hops read the same octets and disagree about where request one ends.
- RFC 9112 says
Transfer-EncodingoverridesContent-Length, that such a message "ought to be handled as an error", and that a server answering one MUST close the connection afterwards. - Every classic variant needs upstream connection reuse; request tunnelling and client-side desync exist precisely because they do not.
- The only defence that removes the class rather than mitigating it is HTTP/2 end to end, because length-prefixed framing cannot be ambiguous.
HTTP request smuggling happens when two HTTP implementations in one chain read the same stream of octets and disagree about where the first request ends. The front end forwards what it believes is one request; the back end sees one and a fragment. The leftover octets stay in the back end's socket buffer and become the prefix of whoever's request arrives next on that connection. Nothing is injected and no memory is corrupted: the whole attack is a difference of opinion about message framing, which is why it keeps recurring in mature software.
The prerequisite for the classic variants is a reused upstream connection. A proxy that opens a fresh connection per client request has no shared socket to poison. That single fact explains both why the class became severe once upstream keep-alive became universal, and why the newer variants were designed to work without it.
The classes at a glance#
The naming convention is what the front end trusts dot what the back end trusts.
| Class | Front end frames by | Back end frames by | Consequence |
|---|---|---|---|
| CL.TE | Content-Length | Transfer-Encoding: chunked | Octets after the terminating 0 chunk are left over at the back end |
| TE.CL | Transfer-Encoding: chunked | Content-Length | Octets beyond Content-Length are left over at the back end |
| TE.TE | Transfer-Encoding, obfuscated so one hop misses it | The hop that recognises it | Degenerates to CL.TE or TE.CL depending which hop was fooled |
| CL.CL | The first Content-Length | A second, differing Content-Length | Leftover octets equal to the difference |
| CL.0 | Content-Length | Ignores the body entirely | Whole body read as a new request; needs no second parser bug, only a bodyless handler |
| H2.CL | HTTP/2 DATA frame lengths | Content-Length copied verbatim into the downgraded HTTP/1.1 request | Front end is exact, back end is lied to |
| H2.TE | HTTP/2 DATA frame lengths | An injected transfer-encoding: chunked that survived the downgrade | Same, through chunked framing |
Why the specification makes Transfer-Encoding win#
RFC 9112 section 6.3: "If a message is received with both a Transfer-Encoding and a Content-Length header field, the Transfer-Encoding overrides the Content-Length." The reasoning is mechanical. Content-Length is an assertion written before the body exists and can never be checked against anything; chunked framing carries its own terminator and is verified as the body is read. When two claims conflict, the verifiable one must win.
The same paragraph adds that such a message "might indicate an attempt to perform request smuggling ... and ought to be handled as an error", and that an intermediary choosing to forward it "MUST first remove the received Content-Length field and process the Transfer-Encoding ... prior to forwarding the message downstream". Section 6.1 is stricter for servers: reject it or honour Transfer-Encoding alone, but "Regardless, the server MUST close the connection after responding to such a request to avoid the potential attacks."
That closing requirement is the part implementations skip, and it is load-bearing. Answering 400 while keeping the connection open leaves the attacker's unread octets in the buffer, ready to be parsed as the next request. Reject and close, not reject.
Two further rules are violated by the obfuscation variants. Section 5.1: "No whitespace is allowed between the field name and colon ... A server MUST reject, with a response status code of 400 (Bad Request), any received request message that contains whitespace between a header field name and colon." Section 6.3: if Transfer-Encoding appears in a request and chunked is not the final coding, "the message body length cannot be determined reliably; the server MUST respond with the 400 (Bad Request) status code and then close the connection."
Worked example: how a CL.TE desync unfolds, hop by hop#
The point of walking through this is to recognise the shape in your own logs and to know which hop to fix. Framing tests like it belong only on systems you own or have written authorisation to test.
A front end honouring Content-Length sits in front of a back end honouring Transfer-Encoding, over a pooled upstream connection.
POST /search HTTP/1.1
Host: shop.example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 37
Transfer-Encoding: chunked
0
GET /admin HTTP/1.1
X-Ignore: XThe body starts after the blank line. Counting octets with CRLF endings:
| Octets | Content | Running total |
|---|---|---|
| 3 | 0 CRLF | 3 |
| 2 | CRLF, ending the chunked body | 5 |
| 21 | GET /admin HTTP/1.1 CRLF | 26 |
| 11 | X-Ignore: X, no trailing CRLF | 37 |
Content-Length: 37 is exactly correct, so the request looks well formed.
Hop 1, the front end. Reads 37 body octets, considers the message complete, forwards all of it verbatim on a pooled connection. Because it never processed Transfer-Encoding, it does not strip it, violating the RFC 9112 requirement quoted above.
Hop 2, the back end. Honours Transfer-Encoding and ignores Content-Length. Chunk size 0 ends the body at offset 5. The remaining 32 octets are still buffered, so they become the start of the next request on this connection: a request line and one incomplete header, waiting for more input.
The victim. Another user's request arrives on the same pooled connection and is appended:
GET /admin HTTP/1.1
X-Ignore: XGET / HTTP/1.1
Host: shop.example.com
Cookie: session=<victim session>The back end executes GET /admin carrying the victim's cookies and any headers the front end added, such as an authentication assertion. Note what was bypassed: the front end's access rule on /admin was never consulted, because the front end never saw a request for /admin.
TE.CL inverts the trust. With Content-Length: 4 and a chunked body beginning 5c CRLF, the front end reads the chunked body to its zero chunk and forwards everything, while the back end consumes only four octets and treats the rest as a new request starting GPOST. A 405 for a method that is a real method with one extra letter is the highest-signal smuggling indicator available in origin logs.
TE.TE: making one hop not see the header#
The goal is a Transfer-Encoding field that one hop treats as present and the other as absent or unparseable.
| Obfuscation | Why a hop misses it | What the specification requires |
|---|---|---|
Transfer-Encoding : chunked | Lenient trimming of whitespace before the colon | 400, per RFC 9112 section 5.1 |
Transfer-Encoding:\tchunked | Tab kept as part of the value | Tab is valid OWS and must be stripped before comparison |
Transfer-Encoding: xchunked | Substring match instead of exact token compare | Unknown transfer coding, reject |
Transfer-Encoding: chunked, identity | Only the first or only the last coding is inspected | Chunked is not final, so 400 and close |
Two Transfer-Encoding field lines | One hop takes the first, one the last | Combined as a list, making chunked non-final |
| Bare LF before the field | See the callout above | Recognising bare LF is a MAY, hence divergent |
Do not enumerate these in a filter. Make the edge accept Transfer-Encoding only when it is exactly the single token chunked after OWS stripping, reject anything carrying both framing fields, and close the connection in both cases.
CL.CL and duplicate Content-Length#
Two Content-Length fields with different values are unambiguously invalid: RFC 9110 section 8.6 and RFC 9112 section 6.3 require rejection, and permit collapsing duplicates only when every value is identical. The failure is a hop that silently picks the first, the last, or the larger, and forwards both fields intact. A related variant uses a value one parser accepts and another does not: a leading +, leading zeros, embedded whitespace, or a magnitude that overflows a specific implementation's length type. CVE-2021-40346 in HAProxy was of that shape, an integer overflow in the HTX representation, fixed in 2.0.25, 2.2.17, 2.3.14 and 2.4.4.
HTTP/2 downgrade smuggling: H2.CL and H2.TE#
HTTP/2 cannot be smuggled on its own. Each DATA frame declares its own length in a fixed-size binary header and the message ends on the END_STREAM flag, so two conforming parsers cannot disagree about boundaries. The vulnerability appears when a front end speaks HTTP/2 to the client and HTTP/1.1 upstream, which is the dominant deployment shape. Downgrading means synthesising an HTTP/1.1 request from an HTTP/2 one, and every field copied across without validation becomes a framing claim in a protocol where framing claims are trusted.
H2.CL. In HTTP/2 content-length is descriptive; RFC 9113 section 8.1.1 requires a message whose content-length differs from the sum of its DATA frame payload lengths to be treated as malformed. A front end that skips that check, frames the body correctly from the frames, and then writes the attacker's content-length into the downgraded request has produced a message whose stated length differs from its real length. The back end trusts the stated one.
H2.TE. RFC 9113 section 8.2.2 makes Transfer-Encoding a connection-specific field that must not appear in HTTP/2, and requires any message containing one to be treated as malformed. A front end that forwards it into the HTTP/1.1 request hands the back end chunked framing that the front end itself did not apply.
Request splitting through CR/LF in field values. RFC 9113 sections 8.2.1 and 8.3.1 forbid CR, LF and NUL anywhere in field names, field values and pseudo-header values. HTTP/2's binary header encoding has no line-terminator concept, so those octets are inert data right up until the downgrade turns them into real line terminators. One unvalidated header value can therefore carry an entire second request, request line and body included. This is the highest-impact downgrade variant, because the attacker controls the smuggled request completely instead of fitting it around a chunk boundary.
The decision rule follows: if you terminate HTTP/2 at the edge and speak HTTP/1.1 upstream, validation at the downgrade point is the only thing standing between the two protocols. The alternative, HTTP/2 on the upstream leg, is covered in HTTP/2 and HTTP/3 through proxies.
Request tunnelling and client-side desync#
Both exist to defeat the answer "we do not reuse upstream connections".
Request tunnelling accepts that the poisoned socket only ever serves the attacker. A second request is smuggled onto the attacker's own connection and both responses are read. Front end policy is still bypassed: path-based access rules, WAF inspection and header rewriting never see the second request. It is also the standard way to read the internal headers the front end injects, by making the smuggled request echo them. Lower impact than classic smuggling, much weaker prerequisites, so it applies to far more deployments.
Client-side desync removes the proxy entirely. The victim's browser sends a request whose body the server never reads: a POST to a static path, or a handler that responds before consuming the body. The unread octets remain in the pooled connection, so the browser's next request to that origin is appended to the attacker's prefix and the smuggled request executes with the victim's cookies. The desync is between the browser's connection reuse and the server's willingness to leave a body unread. The fix belongs to the application server: consume or explicitly reject every request body, and close the connection whenever a response is sent without reading one.
Defences, ranked#
| Rank | Defence | What it removes | Cost and caveats |
|---|---|---|---|
| 1 | HTTP/2 or HTTP/3 end to end | The whole class; length-prefixed framing cannot be ambiguous | Every hop must support it; upstream HTTP/2 to origins is still less common than downgrade |
| 2 | Reject ambiguous messages at the edge and close | CL.TE, TE.CL, TE.TE, CL.CL | Must be reject-and-close; must cover obfuscated forms, not only canonical ones |
| 3 | Validate at the HTTP/2 to HTTP/1.1 downgrade point | H2.CL, H2.TE, HTTP/2 request splitting | Some implementations check content-length but not CR/LF in field values |
| 4 | Normalise rather than forward | Divergence from bare LF, whitespace, duplicate fields | Conflicts with byte-transparent proxying and with upstreams that sign the raw request |
| 5 | Same implementation and version on both hops | Divergence by construction | Impossible with a CDN in front; useless when the bug is in that one implementation |
| 6 | Disable upstream connection reuse | Cross-user smuggling only | Real throughput cost, below; does nothing for tunnelling or client-side desync |
| 7 | Keep both hops patched | Known implementation bugs | Necessary, never sufficient; new variants precede patches by definition |
Ranks 2 and 3 are what nearly every deployment should actually implement. Rank 1 is the strategic answer. Rank 6 deserves precision about its cost.
On nginx before 1.29.7, upstream keep-alive is off unless an upstream block declares keepalive, and doing it correctly also needs proxy_http_version 1.1; and proxy_set_header Connection "";. From 1.29.7 nginx caches upstream connections by default (keepalive 32 local), so an untouched configuration reuses them. Plenty of deployments believe they are reusing upstream connections and are not, and the reverse, so check the running version rather than the config file alone.
What to verify in your own stack#
Testing request smuggling against systems you do not own or have written authorisation to test is not acceptable.
| Behaviour | How to verify | Expected result |
|---|---|---|
| Both framing fields present | Send Content-Length and Transfer-Encoding: chunked together; watch the response and the socket | 400 with the connection closed, or forwarded with Content-Length removed |
| Whitespace before colon | Send Transfer-Encoding : chunked | 400, per RFC 9112 section 5.1 |
| Non-final chunked coding | Send Transfer-Encoding: chunked, identity | 400 and close |
Duplicate Content-Length | Send two fields with different values | 400, never a silent collapse to one |
| Bare LF terminators | Send a request using only LF between field lines | Both hops agree; the edge normalises or rejects |
| HTTP/2 length mismatch | Send content-length disagreeing with DATA frame totals | Stream reset with PROTOCOL_ERROR, never forwarded |
| CR or LF in an HTTP/2 field value | Send a value containing \r\n | Malformed, never downgraded |
| Upstream reuse in effect | Check keepalive in nginx, http-reuse in HAProxy, max_requests_per_connection in Envoy | Know the answer rather than assuming it |
| Timeout alignment | Compare edge and origin idle timeouts with the timeout ladder checker | Origin idle timeout shorter, so the origin closes first |
These need raw octets. printf piped into openssl s_client -quiet -connect host:443 reproduces them faithfully; curl will not, because it rewrites conflicting length fields before they reach the wire.
Failure modes and their symptoms#
400 Bad Request bursts with no matching application log entry. The edge or origin is rejecting malformed framing. Correlate on connection identifiers, not request identifiers, because the offending octets may belong to a request the application never saw.
405 Method Not Allowed for a near-miss method. GPOST, HPOST or POSTGET in origin logs is the TE.CL signature. Alert on it explicitly.
A user receiving another user's response. The classic poisoned-connection symptom, usually first reported as a caching bug. If the edge caches, a smuggled response can be stored under the victim's key, turning a transient desync into persistent cache poisoning.
An origin request whose Host matches no front end route. The smuggled request carried its own Host. Log $host at the origin as well as the edge and alert on unexpected values, as in the reverse proxy security checklist.
Known implementation bugs. CVE-2019-20372 affected nginx before 1.17.7 in certain error_page configurations behind a load balancer. CVE-2023-25690 affected Apache HTTP Server 2.4.0 to 2.4.55 where RewriteRule or ProxyPassMatch substituted user-controlled data into a proxy target. Both show that the front end's rewriting logic is part of the parser, which is worth weighing when choosing a reverse proxy implementation.
Frequently asked questions#
What is HTTP request smuggling in simple terms?#
It is an attack in which two servers in a chain disagree about where one HTTP request ends and the next begins. The attacker sends a request the front end reads as one message and the back end reads as one and a fragment, leaving octets in the connection buffer that get prepended to the next user's request on that same connection.
Why does Transfer-Encoding take precedence over Content-Length?#
Because chunked framing carries its own terminator and is verified as the body is read, while Content-Length is an unverifiable assertion made before the body exists. RFC 9112 section 6.3 states that Transfer-Encoding overrides Content-Length, that such a message ought to be treated as an error, and that any intermediary forwarding it must first remove the Content-Length field.
Is HTTP/2 vulnerable to request smuggling?#
Not by itself. HTTP/2 frames are length-prefixed, so conforming parsers cannot disagree about message boundaries. The vulnerability appears at HTTP/2 to HTTP/1.1 downgrade, where a front end copies content-length, transfer-encoding or CR/LF-bearing header values into a generated HTTP/1.1 request without validating them.
Does disabling upstream keep-alive stop request smuggling?#
It stops the classic cross-user variants because there is no shared connection to poison, but it does not stop request tunnelling or client-side desync. It also costs an extra TCP and TLS handshake per request and imposes a connection-rate ceiling set by the ephemeral port range and Linux's fixed 60 second TIME_WAIT.
What is client-side desync?#
Client-side desync is a smuggling variant with no proxy involved. The victim's browser sends a request whose body the server never reads, so the unread octets stay in the pooled connection and prefix the browser's next request to that origin. The fix is in the application server: consume or reject every request body, and close the connection if a response is sent without reading one.
Does a WAF protect against request smuggling?#
Only incidentally. A WAF inspecting the request as the front end parsed it never sees the smuggled request, which is the point of the attack. A WAF deployed as an extra hop also adds another parser and therefore another chance of divergence. Strict framing enforcement at the outermost hop is the control; the WAF is not.
Should both hops run the same HTTP implementation?#
It reduces divergence but is rarely achievable and never sufficient. A CDN in front of your own edge guarantees two implementations, and identical software still fails when the bug is in that software, as the nginx, HAProxy and Apache advisories show. Strict rejection of ambiguous framing at the outermost hop is the defence that does not depend on the pairing.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- RFC 9112 HTTP/1.1, section 6 Message Body
- RFC 9112 HTTP/1.1, section 11.2 Request Smuggling
- RFC 9112 HTTP/1.1, section 2.2 Message Parsing
- RFC 9113 HTTP/2, section 8.2 HTTP Fields
- RFC 9110 HTTP Semantics, section 8.6 Content-Length
- CVE-2019-20372 nginx error_page request smuggling
- CVE-2021-40346 HAProxy HTX integer overflow
- Apache HTTP Server 2.4 vulnerabilities (CVE-2023-25690)
- nginx ngx_http_upstream_module keepalive
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.