If you run an application behind a reverse proxy, a CDN, or a load balancer — which is almost everyone — you have two independent HTTP parsers that must agree, byte for byte, on where one request ends and the next begins. When they disagree, the connection they share can be made to "desync," and one user can end up reading another user’s traffic. This is HTTP request smuggling (CWE-444), and it remains one of the highest-impact web bugs precisely because nothing in the victim’s own behaviour looks wrong.
This write-up is for defenders: what the bug is, how to tell whether your stack is exposed, and how to shut it down. To make it concrete it ships a small, local, authorized lab that binds to 127.0.0.1, uses no real targets and no real user data, and lets you reproduce the defect and then prove your fix holds.
What is HTTP request smuggling
HTTP/1.1 gives you two ways to say how long a request body is: the Content-Length header (a byte count) and Transfer-Encoding: chunked (a self-delimited stream of size-prefixed chunks). The spec says a message must not use both — and if one arrives that does, the receiver has to reject it or pick one consistently (RFC 9112 §6.3, formerly RFC 7230 §3.3.3). Plenty of proxies and app servers shipped before that rule was tightened, or implement it inconsistently, and that gap is the whole bug.
When a front-end and a back-end disagree about which header wins, or about how to parse chunked framing, they read a different number of bytes for the same TCP stream. The bytes one side thinks belong to request A but the other side reads as the start of request B are the "smuggled" request. Because most reverse proxies pool a few keep-alive connections to the back-end rather than opening one per client, those extra bytes get spliced onto whatever the next real visitor sends.
The three classic patterns, named front-end.back-end:
| Pattern | Front-end trusts | Back-end trusts | Why they disagree |
|---|---|---|---|
| CL.TE | Content-Length |
Transfer-Encoding |
Front-end forwards too many bytes; the tail is read by the back-end as its own request |
| TE.CL | Transfer-Encoding |
Content-Length |
Front-end forwards a chunked body; back-end reads only Content-Length bytes and treats the rest as new |
| TE.TE | both support chunked | — | One side is tricked into ignoring Transfer-Encoding via header obfuscation (xchunked, folded headers, odd whitespace), then behaves like CL.TE or TE.CL |
Why defenders should care: James Kettle’s "HTTP Desync Attacks" research (PortSwigger, 2019) and the 2025 follow-up "HTTP/1.1 Must Die" showed this class recovers other users’ session tokens, bypasses front-end access control and WAF rules, and poisons caches — all without touching the victim directly. Every variant starts the same way: get a shared connection to disagree about a boundary. If you understand that one idea, you can both spot it and prevent it.
A safe lab to reproduce it
The lab is three small Python processes wired together, driven entirely from a browser so you never hand-build raw sockets against anything but your own machine:
frontend_proxy.py(127.0.0.1:8080) — the "reverse proxy." It keeps one pooled, reused TCP connection to the back-end, exactly like a real nginx/HAProxy upstream pool. In the vulnerable setting it frames requests byContent-Lengtheven whenTransfer-Encodingis also present.backend_app.py(127.0.0.1:8081) — the "app server." It frames the spec-correct way:Transfer-Encodingwins when present. It serves/,/home(a boring account page) and/admin(a page that should never be reachable directly), and logs every request it parses.controller.py— a Flask control plane that serves the UI at http://127.0.0.1:8088 and spawns the other two as child processes.
The vulnerable framing logic is the entire bug, in about ten lines — and reading it is the fastest way to recognize the same mistake in a real proxy config:
# frontend_proxy.py (vulnerable setting)
has_cl = "content-length" in headers
has_te = "transfer-encoding" in headers
if MODE == "vulnerable" and has_cl:
# Bug: Content-Length is used even when Transfer-Encoding is
# ALSO present — the exact ambiguity CL.TE smuggling relies on.
body = cb.read_exact(int(headers["content-length"]))
elif has_te and "chunked" in headers["transfer-encoding"].lower():
body = read_chunked_raw(cb)
The back-end disagrees by being correct per the RFC — which is what makes the mismatch matter:
# backend_app.py
te = headers.get("transfer-encoding", "")
if "chunked" in te.lower():
body = parse_chunked_body(bb) # chunked wins here
elif "content-length" in headers:
body = bb.read_exact(int(headers["content-length"]))
Start it with Docker (docker compose up --build) or plain Python (run.sh / run.ps1), then open http://127.0.0.1:8088. The UI has three panels: Send Request (a raw-request builder with presets), Requests Log (what the back-end actually parsed, in order), and Victim Session (simulates a second, unrelated user visiting /home).

Seeing the desync in the lab
To defend against something you have to be able to recognize it, so the lab reproduces the defect against itself. The test case declares Content-Length: 48 and Transfer-Encoding: chunked; its body is a zero-length chunked message (0\r\n\r\n) followed by a second, complete request:
POST / HTTP/1.1
Host: lab.local
Content-Length: 48
Transfer-Encoding: chunked
Connection: close
0
GET /admin HTTP/1.1
Host: lab.local
The front-end trusts Content-Length: 48 and forwards all 48 body bytes — chunk terminator and trailing request together — onto the shared back-end connection. The back-end trusts Transfer-Encoding, consumes only 0\r\n\r\n as the body, and is left holding GET /admin … as a brand-new request that no client connection ever sent.
Option 1: from the UI
- Click Load: CL.TE test case, inspect the raw bytes, click Send Request. The response on the sender’s own connection looks completely normal — that invisibility is exactly why the bug is dangerous and hard to notice in production.
- Click Simulate victim: GET /home. The unrelated visitor’s clean request comes back with the admin page instead of their own, because the back-end had already queued that response for whoever asked next.

Option 2: from the terminal, against your own lab
printf 'POST / HTTP/1.1\r\nHost: lab.local\r\nContent-Length: 48\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n0\r\n\r\nGET /admin HTTP/1.1\r\nHost: lab.local\r\n\r\n' \
| nc 127.0.0.1 8080
The back-end’s log tells the whole story — the attacker-shaped POST /, the smuggled GET /admin that arrived with no client headers of its own, and the real victim’s GET /home whose response got pushed one slot down the queue:
# time method path headers
3 22:59:22 POST / content-length: 48 | transfer-encoding: chunked | ...
4 22:59:22 GET /admin host: lab.local
5 22:59:22 GET /home connection: close | host: lab.local
This is response-queue poisoning: the shared connection is now one response ahead of where the front-end thinks it is, so later visitors on that pooled connection get the previous person’s response until it resyncs. In a real deployment that is a cache-poisoning or session-hijacking primitive — which is why this matters even though no single user was "targeted."
Fixing it
Reject ambiguous framing — don’t "resolve" it
The correct fix is the one RFC 9112 already mandates: a request carrying both Content-Length and Transfer-Encoding is invalid and must be rejected, not quietly resolved by preferring a header.
# frontend_proxy.py (fixed setting)
if MODE == "fixed" and has_cl and has_te:
return "REJECT", path, headers, None # 400, never forwarded
Normalize on the proxy; never relay client framing verbatim
The front-end should be the single source of truth for framing and re-encode the request before forwarding it. A representative nginx guard:
server {
listen 127.0.0.1:443 ssl;
# Reject conflicting framing before anything is proxied.
if ($http_transfer_encoding ~* "chunked") { set $te_and_cl "te"; }
if ($http_content_length) { set $te_and_cl "${te_and_cl}cl"; }
if ($te_and_cl = "tecl") { return 400; }
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://backend_pool;
}
}
Prefer HTTP/2 (or strict HTTP/1.1) end-to-end
HTTP/2’s binary framing has an explicit per-frame length, so there is no CL-vs-TE ambiguity to abuse — but only if you don’t downgrade to ambiguous HTTP/1.1 on the internal hop and reintroduce it. Where an HTTP/1.1 hop is unavoidable, its parser must treat any CL+TE combination as a hard error and reject obfuscated Transfer-Encoding variants (extra spaces, casing tricks, duplicate headers) too.
Re-testing the fix
Switch the lab to the fixed setting and repeat the identical test:
curl -s -X POST http://127.0.0.1:8088/api/mode -H 'Content-Type: application/json' -d '{"mode":"fixed"}'
curl -s -X POST http://127.0.0.1:8088/api/reset
The same request now gets an immediate 400 Bad Request, the back-end log shows only requests that genuinely arrived on their own connection, and the victim keeps getting their own page.

Vulnerable vs. fixed
| Vulnerable (Content-Length wins) | Fixed (reject ambiguous framing) | |
|---|---|---|
| Ambiguous request | Forwarded as-is, 48 body bytes | Rejected with 400 Bad Request |
| Back-end requests parsed | 3 (POST /, smuggled GET /admin, victim GET /home) |
1 (GET /home only) |
Victim’s /home response |
Admin page — desynced | Correct Welcome home page |
| Root cause | Front-end and back-end disagree on CL vs. TE | Ambiguous requests never reach the back-end |
Detecting this class of bug
If you only remember one section, make it this one — detection is what turns a scary demo into something you can actually watch for:
- Access-log length mismatches. Compare the front-end’s logged request size against the back-end’s
Content-Length/chunk total for the same connection and window; a persistent small discrepancy is the signature of a desync, not noise. - Requests-per-connection mismatch across tiers. If the front-end logs one request on a connection but the back-end logs two or three for the same slice, that extra request appeared from nowhere — exactly what the lab’s Requests Log panel visualizes.
- Reject ambiguous framing at the WAF too, as defence in depth even when the proxy already does:
SecRule REQUEST_HEADERS:Content-Length "@rx ." \
"chain,id:100001,phase:1,deny,status:400,msg:'Ambiguous CL+TE framing'"
SecRule REQUEST_HEADERS:Transfer-Encoding "@rx ."
- Alert on malformed/obfuscated
Transfer-Encoding(chunked,Chunked,xchunked, duplicates) — real desync bugs usually hide behind a variant one parser mishandles. - Watch for responses with no matching request on a client connection, or clients receiving content for a path they never asked for — both surface as correlation gaps between the two tiers’ logs.
Key takeaways
- HTTP request smuggling comes from two parsers disagreeing about
Content-Lengthvs.Transfer-Encodingon a connection they both think they own exclusively. - CL.TE, TE.CL and TE.TE describe which header each tier trusts; all three share the same root ambiguity.
- The defect is invisible to the sender’s own connection — it surfaces as poisoned responses or bypassed checks for whoever shares the pooled back-end connection next, so it will not show up in ordinary testing.
- The reliable fix is rejecting ambiguous requests outright (RFC 9112 §6.3), not picking a "winning" header — and re-encoding on the proxy rather than relaying client framing verbatim.
- HTTP/2 end-to-end removes the ambiguity structurally, as long as you don’t downgrade to ambiguous HTTP/1.1 internally.
- Detection relies on correlating request/response counts and sizes across tiers; a single log rarely shows the bug alone.
Lab: download and run it
What’s inside:
http-smuggling/
├── controller.py # Flask UI + control plane (http://127.0.0.1:8088)
├── frontend_proxy.py # vulnerable/fixed front-end proxy (127.0.0.1:8080)
├── backend_app.py # back-end app server (127.0.0.1:8081)
├── static/index.html # dark-themed single-page UI
├── requirements.txt
├── run.sh / run.ps1 # plain-Python runner, no Docker needed
├── Dockerfile / docker-compose.yml
└── README.md
Run it:
unzip http-request-smuggling-lab.zip
cd http-smuggling
# Option A: Docker
docker compose up --build
# Option B: plain Python
chmod +x run.sh && ./run.sh
Open http://127.0.0.1:8088, load the CL.TE test case, send it, and simulate the victim visit to see the desync; toggle Switch to Fixed mode and Reset lab to confirm the fix holds. Stop with Ctrl+C (and docker compose down --rmi all if you used Docker). The only state it keeps is a local state/ folder of request logs with no secrets in it, safe to delete at any time.