fix: bound OAuth2 request bodies and report the rejection per RFC - #28175
fix: bound OAuth2 request bodies and report the rejection per RFC#28175BobbyHo wants to merge 11 commits into
Conversation
Docs previewCheck 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. |
03467be to
8cfc235
Compare
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.
8cfc235 to
5913b57
Compare
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 8 findings (3 P3, 1 Nit, 4 Note), COMMENT. Review Finding inventoryFinding inventoryFindings
Contested and acknowledged(none) Round logRound 1Panel. 3 P3, 1 Nit, 4 Notes new. Reviewed against 72a6c8a..74996ec. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
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.
There was a problem hiding this comment.
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):
-
readOAuth2ClientRegistrationRequestreports the constanthttpapi.DefaultMaxRequestBodyBytesin both the 413 body and theRecordRequestBodyLimitcall, discarding theLimiton the*http.MaxBytesErrorit just caught. The three sibling call sites this PR adds (httpmw/oauth2.go,tokens.go,revoke.go) all read.Limitfrom the error, andhttpapi.ReadLimitdocuments 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. -
POST /oauth2/registerandPUT /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, sodocs/reference/api/enterprise.mddescribes 413 for two of the four affected endpoints. A codegen client will treat 413 as undocumented there. The RFC 7591 shape differs fromcodersdk.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.
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.
|
Pushed
CRF-5, CRF-6, and CRF-8 are still open and get replies rather than code changes. |
# Conflicts: # coderd/oauth2.go # coderd/oauth2provider/registration_test.go # docs/reference/api/enterprise.md
Summary
Bounds the two OAuth2 paths from
r.Bodyto a decoded value that do not go throughhttpapi.Read, and reports each rejection in the error shape its caller expects rather than thecodersdk.Responsethathttpapi.Readwrites.Second of three PRs split out of #28048. #28168, which added the
httpapi.ReadLimitprimitive andRecordRequestBodyLimitthat this PR builds on, has merged, so this is based onmainand 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.Readis not the only path from a request body to a decoded value.POST /oauth2/tokensandPOST /oauth2/revokeauthenticate the client from the request body, so they carry no API key middleware andr.ParseFormran before any authorization decision. These were not unbounded, sincenet/httpcaps an unwrapped urlencoded body at its own 10 MiBmaxFormSize, but that is an asymmetric pre-auth ceiling 2.5x the one every other endpoint carries, on a prefix mounted outsideapiRateLimiter.POST /oauth2/registeris also reachable pre-authentication. It decoded throughhttpapi.Read, which reports acodersdk.Response, making it the one protocol-inconsistent response in a handler that is otherwise RFC 7591 compliant. That inconsistency became visible whenhttpapi.Readstarted answering 413 in #28168.Fix
The form endpoints get a
MaxBytesReaderatDefaultMaxRequestBodyBytesinstalled inextractOAuth2ProviderAppBase, ahead of every reader;net/httpdefers to one when it finds it, so this replacesmaxFormSize.tokens.goandrevoke.gotranslate the same error, because the middleware parses the form only whenclient_idis absent from the query string, and when it is present those handlers perform the first read./oauth2/registerbounds and decodes locally inreadOAuth2ClientRegistrationRequest.http.MaxBytesReadersurfaces 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 tohttpapi.ReadLimit. The limit itself is unchanged from whathttpapi.Readapplied.RFC 6749 defines no error code for a transport rejection, and RFC 7591 section 3.2.2 defines
invalid_client_metadataand friends for semantic validation rather than transport rejection, soinvalid_requestis 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
ParseFormerror, so an oversized body reported theclient_idit may well have carried as missing. A size failure is now reported as one; any other parse failure still falls through, since theclient_idmay 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_uriscarried 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 whathttpapi.Readhas exposed on every other endpoint for as long as it has existed.TestOAuth2SpecificErrorScenarios/InvalidJSONStructurewas 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/tokensandPOST /oauth2/revokenow answer 413 with an RFC 6749invalid_requestonce a form body passes 4 MiB. Previously such a body ran tonet/http's 10 MiB cap and was reported as a 400 namingclient_idas missing.POST /oauth2/registernow answers 413 as an RFC 7591 error rather than acodersdk.Response. Its 400 for a malformed body now carries the JSON decoder's text inerror_description; the status and the RFC 7591 error code are unchanged.