Part XIX — HTTP in depth
Part XI covered HTTP's shape (versions, framing, multiplexing). This part covers the semantics you actually operate: framing hazards, caching, cookies, CORS, streaming, auth, and proxies.
58. Message framing: where does a request end?
58.1 The two ways to delimit a body
Content-Length: 1234 exact byte count; simple; needs the length up front
Transfer-Encoding: chunked a series of <hex-size>CRLF<data>CRLF blocks,
terminated by 0CRLF CRLF ; for streamed/unknown length
HTTP/1.1 rules (RFC 9112): if both are present, Transfer-Encoding wins and
Content-Length must be ignored/rejected. A message with neither, on a
response, is delimited by connection close (HTTP/1.0 style) — which is why
Connection: close responses can't be pipelined.
58.2 Request smuggling — the framing attack
If a front proxy and a back-end disagree about where a request ends, an attacker can hide a second request inside the first.
Attacker sends ONE TCP stream containing:
POST / HTTP/1.1
Host: x
Content-Length: 6
Transfer-Encoding: chunked
0
GET /admin HTTP/1.1
X: y
Front proxy honours Transfer-Encoding -> sees the request end at "0\r\n\r\n"
and forwards the remainder as a NEW request
Back end honours Content-Length: 6 -> consumes 6 bytes, leaves "GET /admin"
prefixed onto the NEXT victim's request
Variants are named by which side does what: CL.TE, TE.CL, TE.TE
(obfuscated Transfer-Encoding headers so one side ignores it).
Defences
- Normalise at the edge: reject any request containing both headers; reject
unknown/obfuscated
Transfer-Encodingvalues. - Use HTTP/2 end to end (its framing is explicit and length-prefixed) — but beware H2.CL / H2.TE downgrade smuggling when the proxy translates H2 to HTTP/1.1 downstream; the proxy must regenerate framing, never trust the client-supplied length.
- Disable connection reuse to the back end if you can afford it (removes the victim's request from the picture).
- Reject requests with duplicate
Content-Length, or withContent-Lengthon aGETwhere your app doesn't expect one.
58.3 Other framing edge cases
| Case | Behaviour |
|---|---|
HEAD response |
Has Content-Length but no body. Proxies must not wait for one. |
204 No Content, 304 Not Modified |
Never have a body, regardless of headers. |
1xx interim (100 Continue, 103 Early Hints) |
Followed by a second status line. Clients must handle multiple responses to one request. |
Expect: 100-continue |
Client asks permission before sending a large body; server replies 100 or an error. Saves uploading a body that will be rejected. |
| Trailers | Headers sent after a chunked body (Trailer: announces them). Used by gRPC for status. |
58.4 Real-world example
A CDN in front of a Node.js origin: the CDN parsed Transfer-Encoding with a
trailing space (Transfer-Encoding : chunked) as invalid and therefore absent,
while the origin's parser trimmed the space and honoured it. Classic TE.TE. One
crafted request poisoned the next user's response. The fix was a WAF rule
rejecting any header name with whitespace before the colon, plus upgrading both
parsers to RFC 9112-strict behaviour.
Try it yourself
# see chunked framing on the wire
curl -sv --raw http://httpbin.org/stream/3 2>&1 | head -30
# force HTTP/1.0 (no chunked; body ends at connection close)
curl -sv --http1.0 http://example.com 2>&1 | grep -i 'connection\|content-length'
# 100-continue in action
curl -sv -H 'Expect: 100-continue' -d @bigfile http://httpbin.org/post 2>&1 | grep '< HTTP'