Why is checking r.ContentLength not a safe size cap on an untrusted HTTP request body in Go?
answer
- the client writes that number, not you
- what does a chunked request declare?
- -1 passes every greater-than comparison
- enforce where the bytes are actually read
- useful as a fast reject, never as the cap
basics
~20 sr.ContentLength only reports what the client declared. A chunked request declares nothing and arrives as -1, so a length check silently passes exactly the requests whose size is unknown. Only a cap on bytes actually read bounds the allocation.
solid answer
~40 s`http.Request.ContentLength` is derived from the client's `Content-Length` header, and for a request whose length was not declared — chunked transfer encoding — it is `-1`. So a guard like `if r.ContentLength > maxBody { reject }` skips the one case you care about most: the body with no declared size at all. It is also a check on a promise made before you read anything, whereas the cost you are trying to bound is the bytes you actually pull into memory. The correct shape is to enforce on the read path with `http.MaxBytesReader(w, r.Body, maxBody)` and treat the header as a cheap optional fast path: if `r.ContentLength > maxBody` you can reject with 413 immediately without reading anything, and if it is `-1` or under the cap you still read through the wrapper.
code
go · 7 linesconst maxBody = 1 << 20
if r.ContentLength > maxBody {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBody)go deeper
Remember that the length in the request is a number the client wrote. Know that some requests declare no length at all and that Go reports that as -1.
Explain the -1 case precisely and show the composition: an optional early reject on the declared length, and the real cap on the reader that the decoder consumes.
In review, be able to spot the header-only guard and say why it fails, and be able to justify keeping the early reject as a bandwidth optimisation rather than deleting it outright.
Decide policy: which layer is allowed to trust declared sizes at all — metrics, routing, the decision to stream to disk — and make the read-path cap non-optional in whatever shared middleware every service inherits.
## What r.ContentLength actually is On a server-side `*http.Request`, `ContentLength` is an `int64` populated from the incoming `Content-Length` header. Its documented values matter: - **A non-negative number** — the client declared a body of that many bytes. - **`-1`** — the length is unknown. This is what a request using chunked transfer encoding gives you: the body arrives as a sequence of chunks and the total is not known until the last one is read. - **`0`** — there is no body. So `ContentLength` is a *claim about* the body, made in the headers, available before a single body byte has been read. That is exactly why it looks like an attractive place to enforce a limit, and exactly why it does not work as one. ## The hole Write the natural check: ```go if r.ContentLength > maxBody { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } ``` A poster who wants to send you an unbounded body simply does not declare a length. `ContentLength` comes back as `-1`, `-1 > maxBody` is false, and the request sails straight through to the decoder that will read until the sender stops sending. The check is not merely incomplete: it is bypassed by choosing a request encoding, which costs an attacker nothing. A public webhook receiver with this guard has, in practice, no body cap. ## The second, subtler problem Even for a request that *does* declare a length, the header check happens in a different place from the cost. The memory your handler allocates comes from the bytes it reads, so the limit belongs on the read path. Enforcing on the header means the limit is only as good as the correspondence between what was declared and what is read — a correspondence you are asserting rather than enforcing. `http.MaxBytesReader` enforces it: it counts the bytes that actually pass through, and fails the read that crosses the line, no matter what any header said. ## What to do instead Cap the reader, and treat the header as an optimisation: ```go if r.ContentLength > maxBody { // fast path; -1 falls through http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } r.Body = http.MaxBytesReader(w, r.Body, maxBody) ``` The first check is worth having: when a client honestly declares 500 MB you can reject in microseconds without reading anything, which is real protection against wasted bandwidth and time. But it is a courtesy, not the defence. The wrapper is the defence, and it is the one that must never be optional. Because `-1` falls through the comparison, the two compose correctly: declared-and-huge is rejected early, undeclared or under-cap is read through the cap. ## Where this bites in review The misconception shows up in three recognisable forms. The first is the header-only guard above. The second is a handler that reads the body into a buffer sized from `ContentLength` and assumes the read cannot exceed it — sizing from an attacker-supplied number is its own problem, since `-1` is not a size and a large declared value is a free allocation request. The third is a middleware that logs or meters `ContentLength` and treats a `-1` as zero, which quietly under-reports exactly the traffic that is unbounded. ## The mental model Headers describe intent; readers observe reality. Any limit that protects memory has to sit on the path where memory is spent. `ContentLength` is a useful hint for fast rejection, for metrics, and for deciding whether to stream to disk rather than buffer — but a hint from an untrusted party is never the enforcement point.
- What value does r.ContentLength hold for a chunked request, and why does that matter here?It is `-1`, meaning the length is unknown. It matters because `-1 > maxBody` is false, so a size guard written only against the header lets every chunked request through unbounded — and choosing chunked encoding is free for the sender.
- Is the header check worth keeping at all once MaxBytesReader is in place?Yes, as an optimisation. An honestly declared 500 MB body can be rejected before reading a byte, saving time and bandwidth. Keep it as a fast path in front of the wrapper, and be explicit in review that it is not the enforcement point.
- Why is allocating a buffer sized from r.ContentLength risky?It lets the client choose your allocation size with a header alone, and it has no sensible meaning when the value is `-1`. If you want a pre-sized buffer, clamp the declared value to your own maximum first, and still read through a capped reader.
saying these in an interview costs you the question
- Treats the Content-Length header as an enforced limit
- Does not know ContentLength is -1 for chunked requests
- Allocates a buffer sized directly from the declared length
- Says a size check before reading is always cheaper and therefore better
- Assumes net/http rejects undeclared-length bodies by default