Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix missing resource limits in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Cap the body with r.Body = http.MaxBytesReader(w, r.Body, limit): it counts bytes as they are read and stops at the limit whatever the client declares, so a chunked or lying request is bounded too. Checking Content-Length is not enough. Also set ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout and MaxHeaderBytes on the http.Server.

Stack
  • Language Go
  • Runtime Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
  • Framework Go standard library 1.27.2 (net/http: MaxBytesReader, http.Server timeouts)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks3 cards
Download the project ZIP, 20 KB
On this page

Steps

  1. Find every handler that reads a request body (io.ReadAll(r.Body), json.NewDecoder(r.Body), multipart parsing) and has no size cap, and every http.Server built without timeouts.
  2. Wrap the body: r.Body = http.MaxBytesReader(w, r.Body, maxBytes) before reading it. The read fails with *http.MaxBytesError past the limit, which you map to 413; MaxBytesReader also makes the server close the connection.
  3. Do not rely on the Content-Length header: a chunked request carries none (r.ContentLength is -1) and a crafted one can lie. MaxBytesReader bounds the bytes actually read.
  4. Set the http.Server fields net/http leaves zero: ReadHeaderTimeout and ReadTimeout (a slow client cannot hold a connection), WriteTimeout, IdleTimeout, and MaxHeaderBytes for the header set.
  5. For decoders, also reject unexpected input: json.Decoder.DisallowUnknownFields and a bound on array and object sizes, so a small body cannot still expand into a large object graph.
  6. Add regression tests that send a body over the limit both with an honest Content-Length and chunked, and assert 413 both times.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.goGo
func (Vulnerable) ReadBody(w http.ResponseWriter, r *http.Request) ([]byte, error) {
	// No cap: read whatever arrives, however large.
	return io.ReadAll(r.Body)
}

With IMPL=vulnerable, 2 of 5 tests fail (2 exploit tests).

The fix: cap the body with MaxBytesReader

Fixedimpl/fixed.goGo
func (Fixed) ReadBody(w http.ResponseWriter, r *http.Request) ([]byte, error) {
	// Count bytes as they are read; the (MaxBodyBytes+1)th byte fails the read.
	r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
	body, err := io.ReadAll(r.Body)
	if err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			return nil, ErrTooLarge
		}
		return nil, err
	}
	return body, nil
}

With IMPL=fixed, 5 of 5 tests pass.

The fix: server-level timeouts and header cap

Fixedapp.goGo
// BuildServer returns an http.Server with the limits a public server needs. net/http sets none of
// these by default: without them a slow or idle client can hold a connection forever
// (Slowloris), and an oversized header set is buffered before any handler runs. MaxBytesReader in
// the handler bounds the body; these bound the headers and the time.
func BuildServer(addr string, handler http.Handler) *http.Server {
	return &http.Server{
		Addr:              addr,
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,  // time to send the request headers
		ReadTimeout:       15 * time.Second, // time to send headers and body
		WriteTimeout:      15 * time.Second, // time to write the response
		IdleTimeout:       60 * time.Second, // time a keep-alive connection may idle
		MaxHeaderBytes:    1 << 20,          // 1 MiB of request headers (the default is 1 MiB; set it explicitly)
	}
}

With IMPL=fixed, 5 of 5 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.goGo
func (Wrong) ReadBody(w http.ResponseWriter, r *http.Request) ([]byte, error) {
	// Trusting the declared length: a chunked upload declares none and slips past.
	if r.ContentLength > MaxBodyBytes {
		return nil, ErrTooLarge
	}
	return io.ReadAll(r.Body)
}

With IMPL=wrong, 1 of 5 tests fail (1 exploit test).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testapp_test.goGo
// Exploit: a body far larger than the limit. The secure outcome is 413 whether or not the request
// declares its length. A body over the cap must never be read in full.

func TestExploitOversizedBodyWithHonestContentLengthIsRejected(t *testing.T) {
	// 256 KiB, four times the cap, with a truthful Content-Length.
	if got := post(t, newServer(t), message(256<<10)); got != http.StatusRequestEntityTooLarge {
		t.Fatalf("an oversized body must be refused with 413, got %d (the whole body was read)", got)
	}
}

func TestExploitOversizedChunkedBodyIsRejected(t *testing.T) {
	// The same 256 KiB sent chunked, so there is no Content-Length to check.
	if got := postChunked(t, newServer(t), message(256<<10)); got != http.StatusRequestEntityTooLarge {
		t.Fatalf("an oversized chunked body must be refused with 413, got %d (the whole body was read)", got)
	}
}

Pitfalls

  • Checking r.ContentLength against the limit is the wrong fix here: a chunked upload declares no length (r.ContentLength is -1), so the check passes and io.ReadAll then reads an unbounded body.
  • net/http's default http.Server has no ReadTimeout, WriteTimeout or IdleTimeout, so a slow or idle client holds a connection indefinitely (Slowloris). Set them explicitly; httptest.NewServer does not set them either.
  • http.MaxBytesReader needs the ResponseWriter so it can signal the server to close the connection; io.LimitReader(r.Body, n) caps the bytes but silently truncates instead of erroring and does not close the connection.
  • A size cap on the body does not bound decoding cost. A 1 KiB JSON array of deeply nested objects or a zip bomb expands far beyond its wire size; cap the decoded structure too (DisallowUnknownFields, a depth or element limit, a decompression limit).
  • MaxHeaderBytes defaults to 1 MiB; set it explicitly so the value is reviewed, and remember it bounds headers, not the body.
  • Resource limits are one layer. A per-client rate limit and a concurrency bound (a semaphore or a worker pool) still matter, because many small requests can exhaust the server as surely as one large one.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs resource-limits/go-nethttp does this for you, in Docker if you do not have Go).
  2. Against your own endpoint, POST a body well over the limit with a correct Content-Length, then the same body chunked (curl --header 'Transfer-Encoding: chunked'); both must answer 413 without the whole body being read.
  3. Scanners do not help here: Semgrep's packs, gosec and CodeQL report nothing about an unbounded body read on any implementation.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix missing resource limits in Go and net/http
ToolRuleWhat it found here
Semgrepgo.lang.security.audit.net.formatted-host-port.formatted-host-port, p/golangNo findings on any implementation. The semgrep-rules repository has no rule that flags io.ReadAll(r.Body) without a limit, or that recognises http.MaxBytesReader as the control; the three readers look the same to every rule.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --metrics=off --config p/golang --config p/owasp-top-ten .
CodeQLgo/uncontrolled-allocation-sizeThere is no stable Go query for CWE-770 unbounded request-body reads. CodeQL models io.ReadAll but has no sink for "read without a size limit", so it does not distinguish the vulnerable reader from the fixed one, nor recognise that the wrong reader trusts r.ContentLength.Checked: github/codeql main at b5b8165; query list and Go models read, not executed
gosecG107, default rule setgosec 2.29.0No findings. gosec has no rule for an unbounded body read or for missing http.Server timeouts; its DoS-adjacent rules (such as G107 on request URLs) do not apply here.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./...

No Go scanner rule checked here flags an unbounded io.ReadAll(r.Body) or a server with no timeouts. The regression tests are the control: they prove an oversized body is refused whether or not it declares a Content-Length.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo standard library 1.27.2 (net/http: MaxBytesReader, http.Server timeouts)
gotest.tools/gotestsum (JUnit report, a go.mod tool)1.13.0
golang.org/x/text (raised for gotestsum)0.42.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the resource-limits/go-nethttpfolder.

Shell
go mod download
go build ./... tool
Shell
# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go mod download && go build ./... tool'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=fixed \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=vulnerable \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=wrong \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs resource-limits/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 20 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop (Engine 29.4.3), linux/arm64
Toolchain
  • go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
  • gotestsum 1.13.0
  • semgrep 1.180.0
  • gosec 2.29.0
  • codeql read, not executed
Matrix
fixed: 5 of 5 tests pass; vulnerable: 2 of 5 tests fail (2 exploit tests); wrong: 1 of 5 tests fail (1 exploit test)
Test results
fixed 5/5 pass; vulnerable 2/5 fail (2 exploit tests: the oversized body with an honest Content-Length and the oversized chunked body); wrong 1/5 fail (1 exploit test: the oversized chunked body, which carries no Content-Length to check), each test run in a container with --network=none after install
Dependency freshness
The card needs only the standard library. gotestsum 1.13.0 (2025-09-11) asks for golang.org/x/text 0.17.0, which GO-2026-5970 affects in a package gotestsum builds (x/text/unicode/norm), so go.mod raises it to 0.42.0 (2026-09-08). Go 1.27.2 (2026-10-08) is under seven days old but is a security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-770: Allocation of Resources Without Limits or Throttling
  2. CWE-400: Uncontrolled Resource Consumption
  3. OWASP API Security Top 10 2023: API4 Unrestricted Resource Consumption
  4. OWASP Top 10:2025 A06 Insecure Design
  5. Go standard library: net/http MaxBytesReader
  6. Go standard library: net/http Server (ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout, MaxHeaderBytes)
  7. Cloudflare: The complete guide to Go net/http timeouts
  8. Go standard library: net/http MaxBytesError

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.