Skip to content
HTTP Request Smuggling

HTTP Request Smuggling, Explained: When Two Servers Disagree on Where a Request Ends

Request smuggling hides one HTTP request inside another by exploiting a front end and back end that frame the same bytes differently. Here is how CL.TE, TE.CL, TE.TE, CL.0 and HTTP/2 downgrades work, how to test for them safely, and how to make every hop agree.

Part 1 of 1From the HTTP Request Smuggling series

At a glance

CWE-444
Interpreter
HTTP/1.1 message framing in front-end and back-end servers
Required condition
Two hops sharing a reused connection disagree on where a request body ends.
Potential impact
Front-end control bypass, capture of other users' requests, and response or cache poisoning.
Primary defense
HTTP/2 to the back end, or strict rejection of ambiguous framing on every hop.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

HTTP request smuggling, also called HTTP desync, exploits a disagreement about where one request ends and the next begins. Much production traffic crosses two HTTP parsers: a CDN, load balancer or reverse proxy at the front, and an application server behind it. The front end usually sends requests from many users down the same back-end connection. If the two tiers measure a request differently, the bytes one of them treats as leftover become the start of the next request, which may belong to someone else.

The flaw lives in message framing, not in any parameter, so code review rarely sees it. OWASP Top 10:2025 maps its weakness, CWE-444, to A06 Insecure Design.

The mental model: one byte stream, two length rules

HTTP/1.1 has two ways to state a body's length. Content-Length gives a byte count. Transfer-Encoding: chunked sends size-prefixed chunks ending with a zero-size chunk. RFC 9112 lets a server reject a request that carries both or frame it by Transfer-Encoding alone, and either way requires closing the connection afterward. The classic desync classes are pairs of servers that did not both follow that rule; CL.0 and the HTTP/2 classes break the same agreement in other ways.

Class Front end frames the body by Back end frames the body by
CL.TE Content-Length Transfer-Encoding
TE.CL Transfer-Encoding Content-Length
TE.TE An obfuscated Transfer-Encoding it accepts Content-Length, because it ignores that header (or the reverse)
CL.0 Content-Length Nothing: the route ignores the body
H2.CL / H2.TE HTTP/2 frame lengths A content-length or transfer-encoding header copied during downgrade

How it happens

Smuggling appears when independently chosen components are chained, each with its own parser and tolerance for malformed input.

Conflicting headers. One tier picks Content-Length, the other Transfer-Encoding: CL.TE or TE.CL.

Obfuscated Transfer-Encoding. When both tiers support chunking, the attacker makes the header slightly wrong:

Transfer-Encoding : chunked

Transfer-Encoding: xchunked

Transfer-Encoding: chunked
Transfer-Encoding: identity

If one parser ignores the odd header and the other honors it, TE.TE collapses into CL.TE or TE.CL. RFC 9112 requires a server to answer whitespace between a field name and its colon with 400 Bad Request; lenient parsers are the real problem.

Ignored bodies (CL.0). Some back ends ignore Content-Length on routes that never expect a body, such as static files or redirects. The front end forwards the body anyway, and the back end parses it as the next request.

HTTP/2 downgrades. HTTP/2 takes length from DATA frames, and RFC 9113 makes a transfer-encoding header, or a content-length that disagrees with the frames, a malformed message an intermediary must not forward. The risk returns when an edge rewrites HTTP/2 to HTTP/1.1 without those checks: a copied content-length (H2.CL), a copied transfer-encoding (H2.TE) or a line break carried inside a header value hands the client control of the back end's framing. The last case is CRLF injection on the request side.

A concrete test: timing a desync safely

An authorized tester proves the disagreement on their own connection without poisoning anyone else's. The safest method, published by James Kettle, uses time. For CL.TE:

POST / HTTP/1.1
Host: app.example
Content-Type: application/x-www-form-urlencoded
Transfer-Encoding: chunked
Content-Length: 4
Connection: close

1
A
0

A front end that honors Content-Length forwards four bytes: 1, CR, LF and A. A back end that honors Transfer-Encoding reads a one-byte chunk, then waits for a chunk size that never arrives, so the response hangs. If both tiers chunk, the body is a complete message; if both count, the back end reads four bytes. Either way the answer is immediate. Only the disagreement hangs.

TE.CL is the mirror image: Content-Length: 6 with a body of 0, CR, LF, CR, LF and X. A chunked front end forwards five bytes, and a back end counting to six waits. Test CL.TE first: on a CL.TE pair this probe forwards all six bytes, and the stray X can prefix the next request on the back-end connection.

A hang alone proves nothing; slow endpoints and WAF tarpits hang too. Require a well-formed control to the same origin, such as a POST with Content-Length: 0, to answer quickly, and repeat the ambiguous request. Only a reproducible, large gap counts.

CL.0 has no timing tell, so the test smuggles a harmless request and looks for its answer:

POST /search HTTP/1.1
Host: app.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 76
Connection: keep-alive

GET /ssec7f31c0de9a2b4e61 HTTP/1.1
Host: app.example
Connection: close

A back end that honors the length sees an odd form value. One that ignores it answers the POST, then parses the body as a GET, and a second status line, typically a 404 naming the random path, comes back on the connection. A body of 76 filler bytes must not produce it, and the result must repeat. Unlike the timing probe, this one can leave a stray response on a shared back-end connection, so run it against a test environment or a quiet window. The smuggled request asks for a path that does not exist and closes the connection, which keeps the likely collateral to a single stray 404.

The attacker does not need a bug in the application. They only need two servers to count the same bytes differently.

What an attacker gains

The attacker writes the start of a request the back end attributes to whoever is next on the connection:

  • Front-end control bypass. Path rules, authentication checks and WAF inspection run on the request the front end saw, so a smuggled GET /admin skips them; see access control bypass.
  • Capture of other users' requests. A prefix ending in an open body field absorbs the next user's headers and session cookie into a value the application stores.
  • Response queue poisoning. An extra response shifts the queue, and users receive responses meant for others.
  • Cache poisoning. A mismatched response can be cached under a popular URL; Host header cache poisoning covers that half of the chain.
  • Upgraded client-side bugs. A reflected XSS reaches the next visitor without a click.

The fix: one framing rule on every hop

The root cause is two parsers sharing one connection. Remove the ambiguity or remove the sharing.

Speak HTTP/2 to the back end. HTTP/2 frames carry their own lengths, so there is nothing to disagree on. nginx 1.29.4 and later can proxy over HTTP/2:

upstream app_backend {
    server 10.0.0.11:8443;
}

server {
    listen 443 ssl;
    http2 on;
    server_name app.example;
    ssl_certificate     /etc/nginx/tls/app.example.crt;
    ssl_certificate_key /etc/nginx/tls/app.example.key;

    location / {
        proxy_pass https://app_backend;
        proxy_http_version 2;
        proxy_ssl_verify on;
        proxy_ssl_name app-backend.internal;
        proxy_ssl_trusted_certificate /etc/nginx/tls/backend-ca.pem;
    }
}

In HAProxy, alpn h2 on the server line negotiates HTTP/2 to the back end, and the default strict parser rejects malformed HTTP/1 requests at the edge:

frontend web
    mode http
    bind :443 ssl crt /etc/haproxy/certs/app.example.pem alpn h2,http/1.1
    default_backend app

backend app
    mode http
    server app1 10.0.0.11:8443 ssl verify required ca-file /etc/haproxy/backend-ca.pem verifyhost app-backend.internal alpn h2

Where HTTP/1.1 stays, reject ambiguity. Every hop should refuse both length headers together, whitespace before a header colon and unknown transfer codings instead of guessing. nginx has rejected the first two since 1.21.1, Apache httpd applies HttpProtocolOptions Strict by default, and Node.js leaves insecureHTTPParser off. Patch every tier promptly; desync fixes ship as parser changes.

Know when connections are shared. nginx enables upstream keepalive by default since 1.29.7, so an upgrade can turn reuse on silently. For a legacy back end you cannot fix, disable it: keepalive 0; in the nginx upstream block, http-reuse never in HAProxy, or disablereuse=On on Apache's ProxyPass.

Make every route honor Content-Length. A handler that does not read a body must still consume or reject it, or close the connection.

Fixes that do not hold

  • Checking headers in application code: the server framed the request before the handler ran.
  • Blocking chunked at a WAF: obfuscation, HTTP/2 downgrades and CL.0 do not need the word.
  • HTTP/2 only at the edge: downgrading to HTTP/1.1 adds the H2.CL and H2.TE classes.
  • Patching one tier: the vulnerability is the disagreement, and every CDN adds another parser.
  • Lenient parsing for an old client: option accept-unsafe-violations-in-http-request (formerly accept-invalid-http-request) in HAProxy, HttpProtocolOptions Unsafe in Apache httpd and --insecure-http-parser in Node.js reopen the tolerance smuggling needs.

A developer review checklist

  1. Map every hop, from CDN to application server, and the protocol version each speaks.
  2. Prefer HTTP/2 to the back end; where an edge downgrades, confirm it checks content-length against the frames and rejects transfer-encoding and line breaks in header values.
  3. Verify each HTTP/1.1 parser rejects conflicting length headers, whitespace before a colon and unknown codings, with no lenient switch set.
  4. Confirm body-less routes such as static files and redirects still honor Content-Length.
  5. Know whether back-end connections are reused, and disable reuse to back ends you cannot patch.
  6. Re-test after proxy, CDN or server upgrades, and stop each test at the hang or the 404 marker.

References

Module: HTTP Request Smuggling. How it probes: Runs once per origin over a raw HTTP/1.1 socket: POST requests to the root path whose Content-Length and Transfer-Encoding: chunked headers conflict in CL.TE and TE.CL shapes, plus CL.TE variants with a space or tab between Transfer-Encoding and its colon, each sent with Connection: close; and a CL.0 probe that hides a complete GET for a random path in the body of a keep-alive POST to the scanned URL. How it confirms: A timing class is reported only when two ambiguous requests each go at least five seconds without an HTTP response while a well-formed Content-Length: 0 control to the same origin answers within two seconds and at least four seconds faster. CL.0 needs a second HTTP status line or the random path in the response stream on two sends, while an equal-length filler body shows neither; the timings or the attack response stay with the finding as evidence. A finding moves from Possible to Confirmed only after this check. Reported as CWE-444 · SARIF 2.1.0.
How SelfSec proves this one
Module
HTTP Request Smuggling
How it probes
Runs once per origin over a raw HTTP/1.1 socket: POST requests to the root path whose Content-Length and Transfer-Encoding: chunked headers conflict in CL.TE and TE.CL shapes, plus CL.TE variants with a space or tab between Transfer-Encoding and its colon, each sent with Connection: close; and a CL.0 probe that hides a complete GET for a random path in the body of a keep-alive POST to the scanned URL.
How it confirms
A timing class is reported only when two ambiguous requests each go at least five seconds without an HTTP response while a well-formed Content-Length: 0 control to the same origin answers within two seconds and at least four seconds faster. CL.0 needs a second HTTP status line or the random path in the response stream on two sends, while an equal-length filler body shows neither; the timings or the attack response stay with the finding as evidence.
Reported as CWE-444 · SARIF 2.1.0

A reflected response alone is not enough to promote a HTTP Request Smuggling finding. The classical engine has to confirm the behavior before it reaches the report. See the detection engine

SelfSec scanner

Find HTTP Request Smuggling in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list