Why Your Go Service Trusts Content-Length Until a Proxy Disagrees
A Go handler that rejects big uploads by checking r.ContentLength does not reject big uploads. It rejects uploads that say they are big. A client that sends the body chunked gets through, because the field then holds -1, and -1 is less than any limit you wrote.
That is the small version of the problem. The bigger version appears when a proxy sits in front of your service and the two of them disagree about where a request ends.
The check that never fires
This looks sensible and is wrong:
func limitBody(next http.Handler) http.Handler {
const max = 1 << 20 // 1 MiB
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > max {
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
return
}
next.ServeHTTP(w, r)
})
}
With Transfer-Encoding: chunked there is no declared length, so ContentLength is -1. The comparison is false, the handler runs, and the client can stream as much as it likes.
The same trap exists one step later. make([]byte, r.ContentLength) panics on -1, and on a huge declared value it tries to allocate whatever the client asked for, before a single body byte has arrived.
Enforce the limit on the bytes you actually read
The declared length is a hint. The only limit that holds is one counted on the bytes read, and the standard library has it:
func limitBody(next http.Handler) http.Handler {
const max = 1 << 20
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, max)
next.ServeHTTP(w, r)
})
}
Reads past the limit return an error of type *http.MaxBytesError, and the server closes the connection after the response. A lying Content-Length no longer matters, and neither does a missing one. If you want an early, friendly 413 for honest clients, keep the header check as well, but only as an optimisation.
Now add a proxy
Here is where it gets properly interesting. HTTP/1.1 has two ways to frame a body: Content-Length and Transfer-Encoding: chunked. RFC 9112 says that if both are present, the sender is probably broken or malicious, and a recipient must not simply pick one and carry on without care. In particular, Transfer-Encoding wins over Content-Length, and the message should be treated as an error or the connection closed afterwards.
Not every implementation behaves that way. Suppose a request arrives like this:
POST /upload HTTP/1.1
Host: example.org
Content-Length: 4
Transfer-Encoding: chunked
1
A
0
GET /admin HTTP/1.1
Host: example.org
A front end that honours Content-Length: 4 sees one request: the body is the first four bytes, and the rest is... the start of the next one. A back end that honours chunked sees the body end at the 0 line, then a second request, GET /admin. Same bytes, two different request streams.
Quick detour: why this works at all
Hang on, why does the second request get past the proxy's rules? Because the proxy never saw it as a request. It forwarded what it believed was one body, over a reused back-end connection. Its access control, authentication and logging only ran for the first request.
The back end, which trusts the proxy, parses a second request out of the same stream. It arrives on a connection the proxy considers its own, so it carries the proxy's trust. This is request smuggling, and the root cause is not a bug in any single parser. It is two parsers disagreeing.
What Go does and does not protect you from
Go's HTTP/1 server is fairly strict. From memory of the net/http source, it rejects a request with conflicting Content-Length values, rejects unknown transfer codings with a 501, and ignores Content-Length when the body is chunked. Check the version you run rather than trusting my recollection; the point is that the server side is not usually the weak party.
What Go cannot do is fix the proxy. If your edge reads the framing differently, your strict back end is still in a connection with something that disagrees with it. Practical defences:
- Use HTTP/2 or HTTP/3 between proxy and back end where you can, since those protocols frame bodies themselves.
- Keep the proxy and its parser current, and prefer one that rejects ambiguous framing outright rather than normalising it.
- Do not reuse back-end connections across different clients if you cannot trust the proxy's parsing.
- Never base authorisation on the assumption that a request reached you "through" the proxy's checks.
Refuse the ambiguous request yourself
Cheap insurance in the handler chain: if both headers arrive, say no. Go may already have dropped Content-Length by the time you see it, so check the transfer encoding too, and close the connection so nothing left on the wire gets parsed.
func refuseAmbiguous(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(r.TransferEncoding) > 0 && r.Header.Get("Content-Length") != "" {
w.Header().Set("Connection", "close")
http.Error(w, "ambiguous framing", http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
}
This is a belt-and-braces measure, and it is simplified: depending on your Go version the header may be stripped before it reaches you, in which case the check never fires and harms nothing. Test it with a raw TCP client such as nc, since net/http's own client will not send both headers for you.
The rule underneath all of this: a length a client declares is input like any other. Count the bytes, cap the bytes, and treat disagreement between layers as an error, not a puzzle to resolve quietly.