Skip to content
How systems talk

2Station 2 of 6: TCP + HTTP

Chapter 02 · 30 minutes

One message, two meanings

When two systems disagree about where a message ends, a request the front proxy never saw can reach the API server behind it.

From the lab folder:
$ uv run python -m wire run 2
$ uv run python -m wire check 2

Goal#

Fix an HTTP/1.1 message parser that tolerates conflicting Content-Length headers, allowing request smuggling through a proxy that reads the same bytes differently.

The idea#

What HTTP/1.1 framing is

HTTP/1.1 reuses TCP connections: one socket carries many requests and responses, one after another. The protocol must say where each message ends and the next begins. This is framing. Without clear framing, a client or server cannot tell which bytes belong to which message.

Two headers control framing:

  • Content-Length: N says the body is exactly N bytes long.
  • Transfer-Encoding: chunked says the body is split into chunks, each prefixed by its size in hexadecimal, ending with a zero-length chunk.

A message uses one or the other, never both. Once you know the framing, you read that many bytes as the body, then start reading the next message.

Why ambiguous framing is dangerous

If two systems disagree about where a message ends, they no longer agree where the next message starts. An attacker can hide a second request inside the first. The front proxy sees one request and lets it through. The backend sees two requests and runs both.

This is HTTP request smuggling. It bypasses authentication, authorization, rate limiting, and WAF rules because the second request never passed through the proxy's checks. It has been used to:

  • Delete accounts by smuggling POST /admin/delete past a proxy that blocks /admin.
  • Steal credentials by poisoning a cache with a response that captures the next user's cookies.
  • Escalate privileges by attaching a smuggled request to another user's session.

How framing works

A valid HTTP/1.1 message on one connection looks like this:

code
POST /search HTTP/1.1
Host: api.example.test
Content-Length: 4

q=hi
GET /status HTTP/1.1
Host: api.example.test

The parser reads the head (everything up to \r\n\r\n), extracts Content-Length: 4, reads 4 bytes of body (q=hi), then starts reading the next request at the line GET /status.

If a message has two Content-Length headers with different values, or both Content-Length and Transfer-Encoding, the framing is ambiguous. Different parsers might pick different values, and they will split the bytes differently.

Attacker sendsProxy reads (first CL)Backend reads (last CL)
Content-Length: 67<br>Content-Length: 4One request, 67-byte bodyTwo requests: 4-byte body, then a second request at byte 5

The proxy sees one POST /search with a 67-byte body that swallows the hidden request. The backend sees POST /search with a 4-byte body, then POST /admin/users/7/delete as a separate request. The proxy never checked the second request.

What people get wrong

Tolerating duplicate headers. Legacy proxies sometimes take the first Content-Length and ignore the rest. Modern servers take the last. This desync creates smuggling opportunities. The fix is to reject any message with multiple Content-Length headers, even if the values match. Guessing is never safe.

Mixing Content-Length and Transfer-Encoding. HTTP/1.1 forbids a message with both. Some proxies ignore Transfer-Encoding and use Content-Length. Others do the opposite. The fix is to reject any message that has both. RFC 9112 section 6.3 says such a message is malformed and must be answered with 400 Bad Request, and the connection must be closed.

Accepting invalid lengths. A Content-Length with a value like "abc" or "-1" is malformed. Some parsers treat it as zero. Others crash. The fix is to validate that the value is a non-negative decimal integer, and reject anything else.

How production systems handle framing

HTTP/2 and HTTP/3. Both use binary framing with length-prefixed messages. There is no Content-Length header and no ambiguity. A frame header says the type and length, and the receiver reads exactly that many bytes. Request smuggling does not exist in HTTP/2 or HTTP/3. If your infrastructure supports them, use them.

Strict parsers. Modern web frameworks like Django, Flask, Rails, Spring, and ASP.NET Core reject messages with duplicate Content-Length or both Content-Length and Transfer-Encoding. Node.js http module (used by Express) rejects duplicate Content-Length as of version 12. Older servers and some proxies do not.

Proxy and backend alignment. If you run a proxy or load balancer in front of an application server, they must use the same HTTP parser or the same strict rules. AWS Application Load Balancer, nginx, HAProxy, and Envoy all have modes that reject ambiguous framing. Enable them. Test your stack with known smuggling payloads (PortSwigger and James Kettle's research provides test cases).

RFC 9112. The latest HTTP/1.1 specification (June 2022) clarifies that a message with duplicate Content-Length headers with different values, or both Content-Length and Transfer-Encoding, is malformed and must be rejected. Older proxies and servers that predate this RFC may still tolerate these cases. Update them.

The case#

Run the case to see the desync:

shell
uv run python -m wire run 2
output
case 02: One message, two meanings
one connection, as the proxy reads it:
  POST /search  body: 67 bytes
the same bytes, as the API server reads them:
  POST /search  body: 4 bytes
  POST /admin/users/7/delete  body: 0 bytes
The proxy checked one request and let it through. The API server ran two.

The proxy read the first Content-Length: 67 and saw one request. The API server read the last Content-Length: 4, saw two requests, and ran the admin deletion that the proxy never checked.

Reasoning towards the fix#

Open lab/cases/case02_framing.py. The body_length function extracts every Content-Length header and returns the last one. The split function uses that length to decide where the body ends and the next request starts.

The proxy_view function does the opposite: it takes the first Content-Length. When the same bytes pass through both, they split differently.

The SMUGGLED constant is the attack payload. It has two Content-Length headers: 67 and 4. The proxy sees 67 bytes of body, which swallows the second request. The API sees 4 bytes, which stops at q=hi, and the rest becomes a new request.

To fix it, reject ambiguous framing. In body_length, check whether the list of Content-Length values has more than one unique value. If it does, raise BadRequest("duplicate Content-Length"). Do the same if the message has both Content-Length and Transfer-Encoding.

The proxy_view function is a simulation. In the real world, the proxy would forward the bytes to the backend, and the backend would reject them. The test checks that your parser raises BadRequest when given the smuggled payload.

Check your fix#

Once you have made the changes, check them:

shell
uv run python -m wire check 2

As shipped, the check output is:

output
  FAIL  two different Content-Length headers are refused
  FAIL  Content-Length together with Transfer-Encoding is refused
  FAIL  a Content-Length that is not a number is refused
  PASS  two ordinary requests on one connection still split correctly
not yet. Try: uv run python -m wire hint 2

When fixed, all lines show PASS. The parser rejects the smuggled payload and any message with conflicting Content-Length headers, but still accepts clean pipelined requests.

Try it on your own machine#

Run your own tests with a local server and a raw TCP client. Send a request with two Content-Length headers and watch what your server does. Most modern frameworks reject it. Legacy proxies sometimes do not.

python
import socket
sock = socket.create_connection(("localhost", 8000))
sock.sendall(b"POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\nContent-Length: 5\r\n\r\nabcdefghij")
print(sock.recv(1024))

If the server accepts it, you have a problem. If it answers 400, you are safe.

In production#

Request smuggling is rare but devastating. It bypasses authentication, authorization, and rate limiting by hiding requests inside other requests. Modern frameworks mostly handle this correctly, but proxies, load balancers, and WAFs sometimes do not.

To prevent it:

  • Reject any request with duplicate Content-Length headers, regardless of whether the values match.
  • Reject any request with both Content-Length and Transfer-Encoding.
  • If you parse Transfer-Encoding, handle it strictly. Ignore unknown codings, and reject chunked anywhere except the last position.
  • Close the connection after rejecting a malformed request. Do not try to recover.
  • Keep proxies and backends in sync. They must use the same HTTP parser, or at least the same strict rules for ambiguous framing.
  • Use HTTP/2 or HTTP/3 where possible. They do not have this problem.

If you run a proxy or WAF, test it against known smuggling payloads. PortSwigger and James Kettle's research provide good test cases. If your proxy and backend disagree about where a message ends, you have a bypass.

Key points#

  • HTTP/1.1 reuses connections, so framing must be unambiguous.
  • If two systems disagree about where a message ends, a request can hide in the body of another.
  • Reject any message with duplicate Content-Length headers or both Content-Length and Transfer-Encoding. Do not guess.
  • Close the connection after rejecting a malformed request.
  • HTTP/2 and HTTP/3 do not have this problem. Use them where possible.