Thanks to visit codestin.com
Credit goes to github.com

Skip to content

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

Open
BobbyHo wants to merge 11 commits into
mainfrom
coder-plat-463-oauth2
Open

fix: bound OAuth2 request bodies and report the rejection per RFC#28175
BobbyHo wants to merge 11 commits into
mainfrom
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. #28168, which added the httpapi.ReadLimit primitive and RecordRequestBodyLimit that this PR builds on, has merged, so this is based on main and the diff shown here is the whole change. The SCIM remediation and the lint rule follow in #28181, stacked on this one.

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 became visible when httpapi.Read started answering 413 in #28168.

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.

@linear-code

linear-code Bot commented Aug 14, 2026

Copy link
Copy Markdown

PLAT-463

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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.

@BobbyHo
BobbyHo force-pushed the coder-plat-463-oauth2 branch 3 times, most recently from 03467be to 8cfc235 Compare August 18, 2026 19:10
Base automatically changed from coder-plat-463-httpapi to main August 18, 2026 19:54
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.
@BobbyHo
BobbyHo force-pushed the coder-plat-463-oauth2 branch from 8cfc235 to 5913b57 Compare August 18, 2026 19:57
@BobbyHo

BobbyHo commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-08-18 20:02 UTC by @BobbyHo

Review history
  • R1 (2026-08-18): 17 reviewers, 1 Nit, 4 Note, 3 P3, COMMENT. Review

deep-review v0.9.0 | Round 1 | 72a6c8a..74996ec

Last posted: Round 1, 8 findings (3 P3, 1 Nit, 4 Note), COMMENT. Review

Finding inventory

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open coderd/oauth2.go:213 Missing @Failure 413 on POST /oauth2/register R1 Netero, Chopper, Hisoka, Kite, Leorio, Mafuuu, Meruem, Pariston, Razor, Ryosuke Yes
CRF-2 P3 Open coderd/oauth2.go:326 Missing @Failure 413 on PUT /oauth2/clients/{client_id} R1 Netero, Hisoka, Kite, Pariston, Ryosuke Yes
CRF-3 P3 Open coderd/oauth2provider/registration.go:551 readOAuth2ClientRegistrationRequest discards mbe.Limit and reports the constant R1 Chopper, Gon, Kite, Knov, Mafu-san, Meruem, Netero, Pariston, Razor, Ryosuke Yes
CRF-4 Nit Open coderd/httpmw/oauth2.go:441 Body-bound comment names only tokens/revoke though wrap applies to every route on extractOAuth2ProviderAppBase R1 Leorio, Mafu-san, Razor, Ryosuke Yes
CRF-5 Note Open coderd/httpmw/oauth2.go:407 codersdkErrorWriter.writeRequestTooLarge is unreachable via any current route and duplicates ReadLimit's 413 body R1 Chopper, Gon, Kite, Mafu-san, Netero, Pariston, Razor, Robin, Ryosuke Yes
CRF-6 Note Open coderd/httpmw/oauth2.go:378 Structural alternative: move body bound to a dedicated middleware, drop the writeRequestTooLarge interface method R1 Ryosuke Yes
CRF-7 Note Open coderd/oauth2provider/registration.go:559 Doubled json: prefix in the decoder-text 400 body R1 Leorio Yes
CRF-8 Note Open coderd/oauth2_error_compliance_test.go:412 InvalidJSONStructure asserts on encoding/json's decoder text R1 Kite Yes

Contested and acknowledged

(none)

Round log

Round 1

Panel. 3 P3, 1 Nit, 4 Notes new. Reviewed against 72a6c8a..74996ec.
Netero + Bisky, Chopper, Ging-go, Gon, Hisoka, Kite, Knov, Komugi, Kurapika, Leorio, Mafu-san, Mafuuu, Meruem, Pariston, Razor (wildcard), Robin, Ryosuke.
No P0/P1/P2 findings. Ging-go, Komugi, Kurapika, Bisky returned "No findings" (Bisky explicitly praised test authenticity of the resurrected InvalidJSONStructure subtest).

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

BobbyHo and others added 2 commits August 18, 2026 13:02
The token exchange and revocation endpoints declared 413 without a
description, so the API reference rendered the swagger default while
every other 413 in the reference names the limit that applies. Dynamic
client registration answers 413 as an RFC 7591 error but declared no
413 at all, leaving that response undocumented.

All three read at most httpapi.DefaultMaxRequestBodyBytes.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, focused stack. The middleware wrap lands at the right seam (the two pre-auth form endpoints), the strategy pattern for the error shape is reused rather than re-invented, and the registration decode owns its own reader so the RFC 7591 shape reaches the wire on both 413 and 400. Three side fixes travel with the PR and are called out honestly in the description: the discarded ParseForm error that misreported oversize as "missing client_id", the resurrected InvalidJSONStructure subtest (previously an empty function claiming coverage "through typed request structs"), and the decoder-text 400 shape. Each is its own commit. That commit decomposition made this diff much easier to reason about.

Bisky: "the platonic example of costume jewelry: an empty subtest that claimed coverage happened 'implicitly through the other tests since we're using typed requests that ensure proper JSON structure.' A typed request cannot express invalid JSON."

Counts: 3 P3, 1 Nit, 4 Notes. Zero P0/P1/P2.

The P3s cluster on two threads, both surfaced by many reviewers (Chopper, Gon, Kite, Knov, Mafu-san, Meruem, Netero, Pariston, Razor, Ryosuke):

  1. readOAuth2ClientRegistrationRequest reports the constant httpapi.DefaultMaxRequestBodyBytes in both the 413 body and the RecordRequestBodyLimit call, discarding the Limit on the *http.MaxBytesError it just caught. The three sibling call sites this PR adds (httpmw/oauth2.go, tokens.go, revoke.go) all read .Limit from the error, and httpapi.ReadLimit documents the invariant explicitly ("Report the limit the error carries, not the one this call installed. Nested readers compose as tightest-wins and the error carries the winner"). Today the numbers agree because register has no outer wrap; the moment one is added upstream, the 413 body and the metric both go stale silently. Fix is a two-line change.

  2. POST /oauth2/register and PUT /oauth2/clients/{client_id} gained a 413 response but the swagger annotations were not updated. The two form endpoints in this same PR got @Failure 413 {object} codersdk.OAuth2Error. Registration and RFC 7592 update did not, so docs/reference/api/enterprise.md describes 413 for two of the four affected endpoints. A codegen client will treat 413 as undocumented there. The RFC 7591 shape differs from codersdk.OAuth2Error, so the annotation may need its own schema (or an inline object), which is a knob only you can pick.

The Nit and Notes are localized. One deserves highlighting because it is a structural alternative rather than a defect: Ryosuke suggests moving the body bound out of extractOAuth2ProviderAppBase and into a dedicated middleware mounted on the OAuth2 subtree. That would drop the fourth method on errorWriter, drop codersdkErrorWriter.writeRequestTooLarge (unreachable through any current route), and drop the errors.AsType branch inside the app-lookup middleware. It also erases the constant-vs-mbe.Limit inconsistency by giving the bound one owner across the tree. Preserved at Note; take it or leave it.

Deep review by Coder Agents.


coderd/oauth2.go:213

P3 [CRF-1] POST /oauth2/register now answers 413 as an RFC 7591 error but the swagger docblock still lists only @Success 201. The same PR added @Failure 413 {object} codersdk.OAuth2Error at coderd/oauth2.go:155 (tokens) and coderd/oauth2.go:180 (revoke); this endpoint and the RFC 7592 update below were the reason the local decode exists yet are the two places the annotation was omitted. docs/reference/api/enterprise.md inherits the gap. (Kite P3, Leorio P3, Ryosuke P3, Netero P3, Meruem Nit, Pariston Note, Razor Nit, Hisoka Nit, Chopper Nit, Mafuuu Nit)

Hisoka: "the two JSON endpoints at oauth2.go:206-213 and oauth2.go:318-326 now also answer 413 through readOAuth2ClientRegistrationRequest, and neither annotation was updated, so the generated enterprise.md still tells an integrator these endpoints can only return 201 / 200."

Add // @Failure 413 {object} codersdk.OAuth2Error above the // @Router line (the RFC 7591 body's error / error_description fields match the OAuth2Error schema on the wire, so the sibling schema is accurate enough) and run make gen. If you prefer a distinct schema for RFC 7591 errors, define one and reference it here instead of reusing codersdk.OAuth2Error.

🤖

coderd/oauth2.go:326

P3 [CRF-2] PUT /oauth2/clients/{client_id} shares readOAuth2ClientRegistrationRequest with the register handler at coderd/oauth2provider/registration.go:276, so it also now returns 413 with the RFC 7591 error shape. Its docblock lists only @Success 200. The PR description does not mention this endpoint, which reads as the sibling being overlooked rather than deferred. (Kite P3, Ryosuke P3, Netero P3, Pariston Note, Hisoka Nit)

Same fix as CRF-1: add // @Failure 413 {object} codersdk.OAuth2Error above the // @Router line and regenerate.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/oauth2provider/registration.go Outdated
Comment thread coderd/httpmw/oauth2.go Outdated
Comment thread coderd/httpmw/oauth2.go
Comment thread coderd/httpmw/oauth2.go
Comment thread coderd/oauth2provider/registration.go
Comment thread coderd/oauth2_error_compliance_test.go
Updating a client shares readOAuth2ClientRegistrationRequest with dynamic
client registration, so it answers 413 with the same RFC 7591 error body,
but declared only 200. The API reference described 413 for registration
and the two form endpoints and left this one undocumented.
readOAuth2ClientRegistrationRequest caught *http.MaxBytesError and
discarded it, reporting the limit it had installed rather than the one
the error carried. Nested readers compose as tightest-wins, so an outer
bound tighter than this one would be reported as a looser limit that
rejected nothing. httpapi.ReadLimit and the tokens and revoke handlers
already report mbe.Limit.
The comment above the MaxBytesReader justified it with tokens and revoke
alone, but the middleware also mounts on /oauth2/authorize and
/api/v2/oauth2-provider/apps/{app}, so the bound applies there too.
The 400 description interpolated the decoder's error after a colon, and
encoding/json's own text begins with "json: ", so a type mismatch
rendered as "Request body must be valid JSON: json: cannot unmarshal
...". A period reads as the two sentences it is.

BobbyHo commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 88dd7661fe..98c52b1202. CRF-1 and CRF-2 arrived in the review body rather than as threads, so noting them here:

  • CRF-1 was already fixed before the review was submitted. dea874ad15 added @Failure 413 {object} codersdk.OAuth2Error to POST /oauth2/register; the review was cut against 74996ec7b8, one commit behind.
  • CRF-2 is fixed in 88dd7661fe, same annotation on PUT /oauth2/clients/{client_id} plus the regenerated swagger.json, docs.go, and enterprise.md. codersdk.OAuth2Error is accurate for the RFC 7591 body: writeOAuth2RegistrationError emits error and error_description, and error_uri is omitempty and never set, so no distinct schema is needed.
  • CRF-3 fixed in 883c2d1122, CRF-4 in fbe67866d6, CRF-7 in 98c52b1202. Replies on those threads.

CRF-5, CRF-6, and CRF-8 are still open and get replies rather than code changes.

@BobbyHo
BobbyHo marked this pull request as ready for review August 18, 2026 22:09
@BobbyHo
BobbyHo requested a review from geokat August 19, 2026 15:09
@github-actions github-actions Bot added the stale This issue is like stale bread. label Sep 9, 2026
@github-actions github-actions Bot closed this Sep 13, 2026
@BobbyHo BobbyHo reopened this Sep 14, 2026
@BobbyHo BobbyHo removed the stale This issue is like stale bread. label Sep 14, 2026
# Conflicts:
#	coderd/oauth2.go
#	coderd/oauth2provider/registration_test.go
#	docs/reference/api/enterprise.md
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