Skip to content

fix: bound OAuth2 request bodies and report the rejection per RFC - #28175

Draft
BobbyHo wants to merge 3 commits into
coder-plat-463-httpapifrom
coder-plat-463-oauth2
Draft

fix: bound OAuth2 request bodies and report the rejection per RFC#28175
BobbyHo wants to merge 3 commits into
coder-plat-463-httpapifrom
coder-plat-463-oauth2

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Bounds the two OAuth2 paths from r.Body to a decoded value that do not go through httpapi.Read, and reports each rejection in the error shape its caller expects rather than the codersdk.Response that httpapi.Read writes.

Second of three PRs split out of #28048. Stacked on #28168, which adds the httpapi.ReadLimit primitive and RecordRequestBodyLimit that this PR builds on. Review that one first; the diff shown here is against its branch. The SCIM remediation and the lint rule follow in a third PR.

Refs PLAT-463. Part of the remediation for SEC-416 (CWE-770, CVSS 7.5).

Problem

httpapi.Read is not the only path from a request body to a decoded value.

POST /oauth2/tokens and POST /oauth2/revoke authenticate the client from the request body, so they carry no API key middleware and r.ParseForm ran before any authorization decision. These were not unbounded, since net/http caps an unwrapped urlencoded body at its own 10 MiB maxFormSize, but that is an asymmetric pre-auth ceiling 2.5x the one every other endpoint carries, on a prefix mounted outside apiRateLimiter.

POST /oauth2/register is also reachable pre-authentication. It decoded through httpapi.Read, which reports a codersdk.Response, making it the one protocol-inconsistent response in a handler that is otherwise RFC 7591 compliant. That inconsistency becomes visible once httpapi.Read starts answering 413.

Fix

The form endpoints get a MaxBytesReader at DefaultMaxRequestBodyBytes installed in extractOAuth2ProviderAppBase, ahead of every reader; net/http defers to one when it finds it, so this replaces maxFormSize. tokens.go and revoke.go translate the same error, because the middleware parses the form only when client_id is absent from the query string, and when it is present those handlers perform the first read.

/oauth2/register bounds and decodes locally in readOAuth2ClientRegistrationRequest. http.MaxBytesReader surfaces the limit through the decoder's error, so the error shape belongs to whoever decodes; that is why the decode moves into the handler rather than passing a limit to httpapi.ReadLimit. The limit itself is unchanged from what httpapi.Read applied.

RFC 6749 defines no error code for a transport rejection, and RFC 7591 section 3.2.2 defines invalid_client_metadata and friends for semantic validation rather than transport rejection, so invalid_request is the closest compliant framing in both.

Two fixes here that are not about body size

Called out separately so they are not skimmed past while checking the size bounds. Each is its own commit.

The middleware discarded its ParseForm error, so an oversized body reported the client_id it may well have carried as missing. A size failure is now reported as one; any other parse failure still falls through, since the client_id may arrive through HTTP Basic.

The registration 400 carried a fixed sentence, so an integrator saw one message whether a proxy had returned HTML or redirect_uris carried a string where an array belongs. It now carries the decoder's own text, which names the offending field and the expected type, describes the caller's own bytes so it discloses nothing, and is what httpapi.Read has exposed on every other endpoint for as long as it has existed. TestOAuth2SpecificErrorScenarios/InvalidJSONStructure was an empty subtest claiming coverage happened implicitly through typed request structs, which by construction cannot produce a decode failure; it now posts raw bodies and asserts the shape.

Behavior change

POST /oauth2/tokens and POST /oauth2/revoke now answer 413 with an RFC 6749 invalid_request once a form body passes 4 MiB. Previously such a body ran to net/http's 10 MiB cap and was reported as a 400 naming client_id as missing.

POST /oauth2/register now answers 413 as an RFC 7591 error rather than a codersdk.Response. Its 400 for a malformed body now carries the JSON decoder's text in error_description; the status and the RFC 7591 error code are unchanged.

POST /oauth2/tokens and POST /oauth2/revoke authenticate the client from the
request body, so they carry no API key middleware and r.ParseForm ran before
any authorization decision. net/http caps an unwrapped urlencoded body at its
own 10 MiB maxFormSize, 2.5x the ceiling every other endpoint carries, and
/oauth2 is mounted outside apiRateLimiter.

The middleware now installs a MaxBytesReader at DefaultMaxRequestBodyBytes
ahead of every reader, which net/http defers to when it finds one. tokens.go
and revoke.go translate the same error, since they perform the first read when
client_id arrived in the query string and the middleware had no reason to
parse.

These endpoints answer under RFC 6749 rather than through httpapi.Read, so
the rejection is written as an invalid_request. RFC 6749 defines no error code
for a transport rejection, so that is the closest compliant framing.

The middleware also discarded its ParseForm error, so an oversized body
reported the client_id it may well have carried as missing. That is fixed
here: a size failure is now reported as one, and any other parse failure
still falls through, since the client_id may arrive through HTTP Basic.
POST /oauth2/register is reachable pre-authentication and decoded through
httpapi.Read, which reports a codersdk.Response. That was the one
protocol-inconsistent response in a handler that is otherwise RFC 7591
compliant, and it becomes visible once httpapi.Read starts answering 413.

readOAuth2ClientRegistrationRequest bounds and decodes locally so the
rejection is an RFC 7591 error. http.MaxBytesReader surfaces the limit
through the decoder's error, so the error shape belongs to whoever decodes;
that is why the decode moves into the handler rather than passing a limit to
httpapi.ReadLimit. RFC 7591 section 3.2.2 defines invalid_client_metadata and
friends for semantic validation rather than transport rejection, so
invalid_request is the closest compliant framing.

The limit is DefaultMaxRequestBodyBytes, unchanged from what httpapi.Read
would have applied. Only the error shape and the recorded limit differ.
readOAuth2ClientRegistrationRequest reported every malformed body as a bare
"Request body must be valid JSON", so a client integrator saw one message
whether a proxy had returned HTML or redirect_uris carried a string where an
array belongs. The decoder's own text names the offending field and the type
it expected, it describes the caller's own bytes so it discloses nothing, and
httpapi.Read has exposed it on every other endpoint for as long as it has
existed. This handler routes every other error through err.Error() too.

That 400 also had no test. It is a shape the previous commit introduced on
purpose: a malformed body used to produce a codersdk.Response and now
produces an RFC 7591 error, which is the whole reason the decode is local to
the handler rather than httpapi.Read. A future refactor routing it back
through httpapi.Read would have regressed the protocol shape silently.
TestOAuth2SpecificErrorScenarios/InvalidJSONStructure was an empty subtest
claiming coverage happened implicitly through typed request structs, which by
construction cannot produce a decode failure. It now posts raw bodies, an
unterminated object and a mistyped field, and asserts the status, the
invalid_request code, and that the decoder's text survives into
error_description.

The doc comment for writeOAuth2RegistrationError had been left above
readOAuth2ClientRegistrationRequest when that function was inserted, so godoc
attributed it to the wrong function and writeOAuth2RegistrationError had none
of its own.
@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