Skip to content

fix: bound request body size on JSON API endpoints - #28168

Open
BobbyHo wants to merge 5 commits into
mainfrom
coder-plat-463-httpapi
Open

fix: bound request body size on JSON API endpoints#28168
BobbyHo wants to merge 5 commits into
mainfrom
coder-plat-463-httpapi

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

httpapi.Read decoded request bodies with no size limit, so a single request could allocate memory without bound. This adds a 4 MiB default ceiling, leaves the endpoints that legitimately need more explicitly exempted, and counts the rejections so a limit set too tight is visible.

This is the first of three PRs split out of #28048, covering the endpoints that answer in codersdk.Response shape. The OAuth2 decode paths (RFC 6749, RFC 7591) and the SCIM ones (RFC 7644) answer in their own error shapes and follow in separate PRs, along with the lint rule that pins the invariant.

Closes PLAT-463. Remediates SEC-416 (CWE-770, CVSS 7.5) and SEC-392.

Problem

httpapi.Read calls json.NewDecoder(r.Body).Decode(value) with no ceiling, and no middleware in the chain bounds body size. The exposure is pre-authentication: login, OTP, and first-user creation all read a body before any authorization decision is reached. The existing rate limiter bounds request rate, which is orthogonal to the memory a single admitted request may consume.

Fix

Read is split into Read and ReadLimit. ReadLimit wraps r.Body in an http.MaxBytesReader and keeps the existing decode and validate logic; Read delegates to it with a new DefaultMaxRequestBodyBytes of 4 MiB, which covers the 124 remaining non-test callers at a single site.

http.MaxBytesReader composes as tightest-wins, so the handlers that pre-wrapped their own bodies pass their limit to ReadLimit rather than wrapping, and each keeps its previous ceiling byte for byte. That matters most for the bulk secrets import at 8 * MaxSecretsFileBytes: an unconditional wrap inside Read would have silently halved it to the default. TestImportUserSecretsBodyLargerThanDefaultLimit is the regression guard for that specific failure, and TestMaxBytesReaderNesting pins the composition behavior the whole requirement rests on.

Every rejection site calls httpapi.RecordRequestBodyLimit, which names the limit that tripped on the request's existing log line and marks the request so coderd_api_requests_too_large_total{reason="request_body"} counts body rejections apart from the 413s coderd answers for other causes, such as agent log storage overflow. A limit set too tight for a legitimate payload therefore surfaces without waiting for a user report.

The limit is a constant rather than a deployment option: an operator raising it to unblock something would reopen the vulnerability as configuration, where a security scan will not find it. A legitimate 413 is answered with a targeted ReadLimit on that endpoint.

Behavior change

POST /api/v2/files now answers 413 rather than 400 when a request body exceeds HTTPFileMaxBytes. It installed that bound already but reported the rejection as a read failure, which leaked the stdlib http: request body too large string through Detail and kept the largest limit in the tree off the metric. The separate 413 for an oversized expanded archive is unchanged.

The task log snapshot endpoint now answers 413 rather than 400 when its 64 KiB cap is exceeded. Routing it through ReadLimit also changes its decode-failure message from "Failed to decode request payload." to "Request body must be valid JSON.", which is what every other endpoint answers. Its tests are updated to match both.

coderd_api_requests_too_large_total gains a reason label. The metric name and its existing labels are unchanged, so a query that ignores labels keeps working, but anything matching an exact label set will need reason added.

Reading this

The commits are ordered to be read in sequence. Commits 1 and 2 are the security fix; commits 3 to 5 are the observability consequences, and commit 3 is the one that touches dashboards.

httpapi.Read decoded r.Body with no ceiling, so one request could allocate
memory without limit. The exposure is pre-authentication: login, OTP,
first-user creation, OAuth2 dynamic registration, and SCIM provisioning all
decode a body before any authorization decision is reached. Rate limiting
bounds request rate, not the memory a single admitted request may consume.

Split Read into Read and ReadLimit. ReadLimit wraps r.Body in an
http.MaxBytesReader and holds the existing decode and validate logic; Read
delegates to it with a new DefaultMaxRequestBodyBytes of 4 MiB. That single
wrap site covers the 124 remaining non-test callers at once.

http.MaxBytesReader composes as tightest-wins, so an unconditional wrap
inside Read would have overridden the handlers that pre-wrapped their own
bodies. That is silent where the caller's limit is larger: the bulk secrets
import at 8x MaxSecretsFileBytes would have halved to the default. Those
handlers pass their limits to ReadLimit instead, leaving the effective limit
at each byte for byte unchanged. TestMaxBytesReaderNesting pins the
composition behavior the requirement rests on.

The limit is a constant rather than a deployment option. An operator raising
it to unblock something would reopen the vulnerability as configuration,
where a security scan will not find it. A legitimate 413 is answered with a
targeted ReadLimit on that endpoint.
A 413 for an oversized body named no limit, so an operator could not tell
which of the ceilings in the tree produced it, and a limit set too tight for
a legitimate payload was indistinguishable from a client that hung up.

RecordRequestBodyLimit puts the limit on the request's existing log line,
rather than a line of its own: a caller can produce 413s at will, so a
dedicated line is attacker-controlled log volume. It also marks the request
through a tracker carried on the context, which the Prometheus middleware
reads to tell a body size rejection from the other reasons coderd answers
413. The tracker is installed in a later commit; a call without one is a
no-op, which is what lets sites adopt this before the middleware exists.

Every site that answers 413 because a request body exceeded a limit calls
this, and a site answering 413 for any other reason must not. The sites keep
their own error shapes, which is why what they share is this call rather
than a response writer.
Nothing counted body size rejections apart from the other reasons coderd
answers 413, so a deliberate exhaustion attempt and a limit set too tight
for a legitimate payload were both invisible.

coderd_api_requests_too_large_total carries method, path, and a reason label
fed by the tracker this middleware installs and the recording sites mark.
reason="request_body" is the alertable series; everything else, such as the
agent log storage overflow at workspaceagents.go, lands under
reason="other". Series exist only for routes that have actually rejected a
body, which is what makes this readable at a glance where filtering
requests_processed_total by code is not.

Counting is keyed on the response status rather than the point of
rejection, which is what reaches the endpoints that bound their own bodies
to keep their own error shapes.

The metric name and its existing labels are unchanged, so a query that
ignores labels keeps working, but anything matching an exact label set will
need reason added.
POST /api/v2/files installed a 100 MiB bound and then reported the rejection
as a 400 read failure, leaking the stdlib "http: request body too large"
string through Detail. It is the largest limit in the tree, so the metric
under-counted precisely where a legitimate payload is most likely to be
refused.

An oversized body is a size failure and is now reported as one, with the
limit recorded. The separate 413 for an oversized expanded archive is about
the expanded bytes, which are not reached until this read succeeds, and is
unchanged.

The swagger annotation listed only the success responses, so the published
reference did not mention the status the endpoint could already return.
postWorkspaceAgentTaskLogSnapshot reimplemented ReadLimit: its own
MaxBytesReader wrap, its own decode, its own 400 for an oversized body. It
calls ReadLimit instead, which removes the duplication and records the limit
it was missing. Validate is a no-op on the payload type, which carries no
validate tags, so validation behavior is unchanged.

Behavior change: the endpoint answers 413 rather than 400 once its existing
64 KiB cap is exceeded, and its decode failure message becomes "Request body
must be valid JSON.", which is what every other endpoint answers. Its test
is updated to match both.
@linear-code

linear-code Bot commented Aug 14, 2026

Copy link
Copy Markdown

PLAT-463

@github-actions

Copy link
Copy Markdown

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant