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:

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).

Lab UI in the vulnerable setting, CL.TE test case loaded, clean log

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

  1. 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.
  2. 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.

In the vulnerable setting the back-end logs a hidden GET /admin, and the victim's /home request is desynced into the admin response

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.

Fixed setting: the same request is rejected with 400, and the victim's /home response is no longer desynced

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:

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 ."

Key takeaways

Lab: download and run it

Download the lab (zip)

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.

References