What it is
RFC 9112, the current HTTP/1.1 specification, defines request smuggling as “a technique that exploits differences in protocol parsing among various recipients to hide additional requests (which might otherwise be blocked or disabled by policy) within an apparently harmless request.” Typical deployments put a front-end (CDN, load balancer or reverse proxy) in front of an application server. If the two disagree about where one request ends and the next begins, an attacker can make the backend see a second request that the front-end never inspected.
That second request can bypass front-end access controls, poison other users’ responses or capture requests from other users that share the same backend connection.
The framing ambiguity
HTTP/1.1 has two ways to say how long a body is: Content-Length and Transfer-Encoding: chunked. RFC 9112 section 6.3 says that if a message arrives with both, “the Transfer-Encoding overrides the Content-Length,” that such a message “might indicate an attempt to perform request smuggling” and “ought to be handled as an error.” An intermediary that forwards it must first remove the Content-Length field.
Smuggling happens when one hop follows that rule and another does not. The classic variants are named after which server uses which header:
- CL.TE: the front-end uses
Content-Length, the backend usesTransfer-Encoding. - TE.CL: the reverse.
- TE.TE: both support chunked encoding, but one can be induced to ignore it through an obfuscated header such as a malformed
Transfer-Encodingvalue.
A simplified CL.TE probe (front-end trusts Content-Length, backend trusts chunked):
POST / HTTP/1.1
Host: example.com
Content-Length: 13
Transfer-Encoding: chunked
0
SMUGGLED
The front-end forwards 13 bytes as one request. The backend reads the chunked body, sees the terminating 0 chunk, and treats SMUGGLED as the start of the next request on the connection. Test only systems you are authorised to test, since a desync can affect other users.
HTTP/2 downgrading adds more variants: when a front-end accepts HTTP/2 and rewrites to HTTP/1.1 for the backend, a length header or special characters carried in the HTTP/2 request may be mishandled during translation.
Vulnerable patterns in code
Most applications do not parse raw HTTP themselves, but custom proxies, gateways and test tooling do. A hand-rolled proxy that picks one header is a risk:
// Anti-pattern: custom proxy chooses a framing header itself
function bodyLength(headers) {
if (headers['content-length']) return parseInt(headers['content-length'], 10);
if (headers['transfer-encoding'] === 'chunked') return null; // read chunks
}
This ignores the case where both headers are present, and accepts a non-numeric or duplicate Content-Length. Node’s built-in HTTP parser rejects many malformed messages, and the --insecure-http-parser flag (and the insecureHTTPParser option) relaxes that strictness; do not enable it in production.
A defensive check for a custom Go proxy or gateway:
func rejectAmbiguousFraming(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(r.Header.Values("Content-Length")) > 1 ||
(r.Header.Get("Content-Length") != "" && len(r.TransferEncoding) > 0) {
http.Error(w, "ambiguous message framing", http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
}
On stock net/http this check will not fire. From our reading of the Go source on the master branch (not tested per Go version), the server removes Content-Length when Transfer-Encoding: chunked is present, rejects differing duplicate Content-Length values, and collapses identical ones, so the handler never sees the ambiguity. Use the same rule in custom parsers, raw-socket proxies and gateways that do not go through the standard server. Apply the rule at the outermost hop, because a backend cannot catch what an upstream hop already misparsed.
The Python equivalent for an ASGI middleware:
async def reject_ambiguous_framing(scope, receive, send, app):
if scope["type"] == "http":
h = [(k.lower(), v) for k, v in scope["headers"]]
cl = [v for k, v in h if k == b"content-length"]
te = [v for k, v in h if k == b"transfer-encoding"]
if len(cl) > 1 or (cl and te):
await send({"type": "http.response.start", "status": 400, "headers": []})
await send({"type": "http.response.body", "body": b"ambiguous framing"})
return
await app(scope, receive, send)
ASGI servers parse framing before your code runs, and behaviour differs: from our reading of the h11 source, it rejects conflicting Content-Length values but does not reject Content-Length together with Transfer-Encoding, so this check can fire under h11-based servers. We did not check other parsers. Treat it as a backstop and not a substitute for configuring the server and proxy.
Fixes
- Use HTTP/2 or HTTP/3 end to end, including between proxy and backend. Binary framing removes the ambiguity. Avoid downgrading to HTTP/1.1 upstream.
- Keep the stack patched and consistent. Use current versions of the proxy and the application server, and prefer combinations known to enforce RFC 9112 strictly.
- Reject ambiguous requests at the edge: both headers present, duplicate
Content-Length, obfuscatedTransfer-Encoding. Configure the proxy to normalise or drop them. - Disable upstream connection reuse if you cannot guarantee parser parity. This reduces impact because a smuggled prefix cannot poison a shared connection, at a performance cost.
- Do not enable lenient parsers such as Node’s
--insecure-http-parser. - Test with tooling such as Burp Suite’s HTTP Request Smuggler extension (from the author’s knowledge, not checked for this article), on staging first.
Detection
Look for 400 responses spiking on one backend, requests in access logs with unexpected methods or paths that never reached your WAF, and responses delivered to the wrong user. Compare front-end and backend request counts; a mismatch can indicate desync.