Blog / Go

  • go
  • net-http
  • security
  • maxbytesreader
  • denial-of-service
  • middleware

Why Your Go Web Server Needs a Body Limit Before a Router

Go's http.Server will happily let a handler read a request body of any size. Headers are capped (MaxHeaderBytes, 1 MiB by default), but the body is not. If your handler calls io.ReadAll(r.Body) or json.NewDecoder(r.Body).Decode(&v) on a hostile client, you read as much as they care to send.

Routers are a distraction here. A router decides which handler runs; it has no opinion on how many bytes that handler consumes. The limit belongs in front of everything, so a forgotten route cannot be the unlimited one.

The one-line version

The standard library has had this since Go 1.18: http.MaxBytesHandler. It wraps a handler and replaces r.Body with a reader that errors once the cap is passed.

package main

import (
	"log"
	"net/http"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/notes", createNote)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           http.MaxBytesHandler(mux, 1<<20), // 1 MiB for everything
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

Every route registered on mux now sees a body capped at 1 MiB. Add a route next month and it is covered without anyone remembering to.

What the handler sees when the limit trips

The cap does not reject the request by itself. It makes Read return an error, and your handler decides what to tell the client. Since Go 1.19 that error is a *http.MaxBytesError, so you can tell "too big" from "malformed":

func createNote(w http.ResponseWriter, r *http.Request) {
	var note struct {
		Text string `json:"text"`
	}
	if err := json.NewDecoder(r.Body).Decode(&note); err != nil {
		var tooBig *http.MaxBytesError
		if errors.As(err, &tooBig) {
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, "bad JSON", http.StatusBadRequest)
		return
	}
	// ...
}

Skip the errors.As and an oversized body shows up as a generic 400 or 500, which makes your logs lie about what happened. Return 413 so the failure is distinguishable from a client bug.

Quick detour: why the limit also closes the connection

Hang on, why does it matter that this is a reader and not a header check? Because a client can send chunked bodies with no Content-Length, or lie about it. A check on the header alone would be useless.

The limited reader counts actual bytes. When it trips it also tells the server not to reuse the connection, since the unread remainder of the body would otherwise be parsed as the next request. That is the right call, and it means an oversized upload costs the attacker a fresh TCP connection each time.

Defaults that are bigger than you think

Some standard library paths read bodies for you, and their defaults are generous:

  • r.ParseForm() on a urlencoded body caps at 10 MB if you have not already applied a limit. Fine if you set one; surprising if you assumed it was tiny.
  • r.ParseMultipartForm(maxMemory) keeps up to maxMemory bytes in RAM and spills the rest to temporary files on disk. maxMemory is not a total cap. Without a body limit, it is a way to fill /tmp.
  • r.FormValue calls these implicitly, so even handlers that never mention forms can trigger them.

With the global 1 MiB wrapper in place, all three are bounded by that figure, because they read through r.Body. That is the real argument for doing it at the top: the limit applies to code paths you did not write.

The catch: you cannot raise a limit from inside

A file upload route legitimately needs more than 1 MiB. Wrapping r.Body again inside the handler with a bigger number does nothing useful, because the outer reader still trips first. Limits only ever get tighter as you go inwards.

So split by path before applying the default. A small top-level dispatcher does it:

upload := http.MaxBytesHandler(http.HandlerFunc(uploadHandler), 32<<20)
api := http.MaxBytesHandler(mux, 1<<20)

root := http.NewServeMux()
root.Handle("POST /upload", upload)
root.Handle("/", api)

Here root is the only thing the server sees. The specific pattern wins over /, so uploads get 32 MiB and everything else gets 1 MiB. Anything new that you forget to classify lands in the small bucket, which is the safe failure.

Decompression undoes your limit

If you write middleware that accepts Content-Encoding: gzip requests, your cap now measures compressed bytes. A few kilobytes of gzip can expand to gigabytes, so the limit has to sit on the decompressed stream.

func gunzip(next http.Handler, max int64) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Content-Encoding") == "gzip" {
			zr, err := gzip.NewReader(r.Body)
			if err != nil {
				http.Error(w, "bad gzip", http.StatusBadRequest)
				return
			}
			defer zr.Close()
			r.Body = http.MaxBytesReader(w, zr, max)
			r.Header.Del("Content-Encoding")
		}
		next.ServeHTTP(w, r)
	})
}

Keep the outer cap too, so the compressed side is bounded as well. Go's server does not decompress request bodies on its own, so this only matters if you added the middleware yourself. If you did not, there is nothing to worry about.

Picking the number

Choose it from the largest legitimate body you expect, not from what feels roomy. A JSON API for notes rarely needs more than 64 KiB; 1 MiB is already generous. Measure your real traffic if you have it, set the cap a little above the maximum, and treat every 413 in the logs as either an attack or a number to revisit.

Body limits are not the whole story. Without ReadTimeout and friends, a client can still send a legal-sized body at one byte a minute. The size cap and the timeouts cover different halves of the same problem, and you want both.