Go's http.MaxBytesReader: Limiting Uploads Without Breaking Keep-Alive
An upload limit sounds like an integer and an io.LimitReader. Give the reader ten mebibytes, stop copying when it reaches zero, job done.
Except io.LimitReader reports EOF at the limit. It cannot distinguish a body containing exactly 10 MiB from a 40 GiB body whose first 10 MiB have been read. If the handler treats EOF as success, it can silently accept a truncated upload. There is also an unread request body sitting on the HTTP connection, which makes reuse rather awkward.
http.MaxBytesReader exists for precisely this server-side problem. It returns a typed error when the body exceeds the limit and cooperates with Go's HTTP server when the remaining bytes make connection reuse unsafe.
A complete upload handler
This example accepts a raw request body and writes it to a uniquely named file. The limit applies to bytes received in the body, not to a client-supplied filename or multipart field.
package main
import (
"errors"
"io"
"net/http"
"os"
)
const maxUploadBytes int64 = 10 << 20
func upload(w http.ResponseWriter, r *http.Request) {
f, err := os.CreateTemp("./uploads", "upload-*")
if err != nil {
http.Error(w, "cannot create upload", http.StatusInternalServerError)
return
}
name := f.Name()
committed := false
defer func() {
if !committed {
_ = f.Close()
_ = os.Remove(name)
}
}()
_, err = io.Copy(f, r.Body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, "upload exceeds 10 MiB", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "could not read upload", http.StatusBadRequest)
return
}
if err := f.Close(); err != nil {
http.Error(w, "could not store upload", http.StatusInternalServerError)
return
}
committed = true
w.WriteHeader(http.StatusCreated)
}
func main() {
mux := http.NewServeMux()
mux.Handle("POST /upload", http.MaxBytesHandler(
http.HandlerFunc(upload),
maxUploadBytes,
))
if err := http.ListenAndServe(":8080", mux); err != nil {
panic(err)
}
}MaxBytesHandler is the convenient middleware form. It replaces r.Body with a MaxBytesReader before calling the upload handler. Wrapping manually is equally valid:
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)The server closes an incoming request body after the handler returns, so a server handler does not need its own defer r.Body.Close(). The temporary file is different: it belongs to the application, and partial uploads must be closed and removed.
The extra byte is doing real work
Internally, the reader permits at most one read beyond the configured boundary. It asks the underlying body for up to the remaining allowance plus one byte. If that extra byte exists, the caller receives only the allowed bytes together with a *http.MaxBytesError. Subsequent reads return the same error.
Actually, this bit is interesting: a body whose length is exactly the limit is valid. The reader cannot know that it has ended until somebody reads again and obtains EOF. Functions such as io.Copy, JSON decoders and multipart parsers normally perform that final read. Code which reads exactly n bytes and then stops has not proved that the request fits within n bytes.
Use errors.As rather than comparing the error string. MaxBytesReader does not write a response by itself; choosing status 413 is still the handler's job. Other read errors might mean a disconnected client, malformed transfer encoding or a failed underlying connection, so they should not all be reported as oversized requests.
What happens to keep-alive?
For a request within the limit, the consumer reads to EOF and the HTTP/1.1 connection remains eligible for reuse. There is no reason to add Connection: close, drain the body again or fiddle with r.Close.
When the limit is exceeded, reuse is deliberately sacrificed for that connection. The remaining body bytes precede the next request on an HTTP/1.1 byte stream. Go could drain them, but an attacker could then make the server receive the enormous upload that the limit was supposed to prevent. The MaxBytesReader implementation therefore notifies the server response when it observes the extra byte. The server implementation marks the connection to close after the response instead of consuming the rest of the body.
So "without breaking keep-alive" needs a small qualification. Normal uploads retain connection reuse. An oversized upload loses its particular HTTP/1.1 connection, intentionally and safely. That is considerably better than either accepting truncated data or draining an attacker-controlled number of bytes merely to preserve one socket.
Do not manually copy an oversized body's remainder to io.Discard. That reverses the resource protection. Likewise, a Content-Length check is only an optimisation, never the actual limit: requests can use chunked transfer encoding, and input metadata is not a trustworthy enforcement boundary.
Put the limiter outside ResponseWriter wrappers
There is a subtle middleware ordering issue. To signal an excessive body, MaxBytesReader checks whether the supplied ResponseWriter supports a private hook implemented by Go's server. An application-defined logging or metrics wrapper can hide that concrete writer. The reader still returns *http.MaxBytesError, but it may be unable to tell the server to close the connection.
Place MaxBytesHandler outside middleware which wraps ResponseWriter:
handler := http.MaxBytesHandler(
accessLog(http.HandlerFunc(upload)),
maxUploadBytes,
)Here the limiting handler receives the original server writer, installs the bounded body, and only then invokes accessLog. Reversing those two wrappers may hide the hook. This is the practical meaning of the documentation's careful phrase "if possible".
Multipart limits are two different limits
For multipart/form-data, wrap the body before calling ParseMultipartForm:
const maxRequestBytes int64 = 12 << 20
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
if err := r.ParseMultipartForm(1 << 20); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "invalid multipart form", http.StatusBadRequest)
return
}
defer r.MultipartForm.RemoveAll()The argument to ParseMultipartForm is not a total upload cap. It controls how much of the file data is retained in memory before temporary files are used. MaxBytesReader limits the complete encoded request, including boundaries and ordinary form fields. Allow a little framing overhead if the product requirement is a 10 MiB file, then separately validate the selected file's size.
Also remember that a byte limit is not a time limit or a request-rate limit. A client can send ten mebibytes extremely slowly, or create thousands of permitted temporary files. Server read deadlines, concurrency controls, storage quotas and rate limiting solve those separate problems. One integer cannot be expected to do all the unpleasant jobs.