fix(coderd): set Cache-Control: no-store on OAuth2 responses - #28143
Conversation
No response from the /oauth2 route tree set Cache-Control at all, so an
intermediary cache or customer-operated reverse proxy was free to apply a
heuristic freshness lifetime to a response carrying a live credential. RFC
6749 §5.1 and OAuth 2.1 §3.2.3 both make an affirmative no-store directive
a MUST for the authorization server.
Add httpmw.NoStore, mounted once on the /oauth2 tree, setting
Cache-Control: no-store and Pragma: no-cache on every response from it.
RFC 6749 §5.1 makes both headers a MUST in a single sentence; OAuth 2.1
drops Pragma because RFC 9111 §5.4 deprecates it as a request-only field,
so sending both is conformant under either reading.
The scope is the whole tree rather than only POST /oauth2/tokens. With a
middleware the mount point is one line either way, and the wider scope
also covers DCR registration (returns client_secret and
registration_access_token), client configuration read and update, and the
authorize endpoint's 302 whose Location query carries the authorization
code. A route added to the tree later inherits the headers with no action
from its author. Three write paths in the tree never call httpapi.Write,
so a fix centralized there would have missed POST /oauth2/revoke, DELETE
/oauth2/clients/{client_id}, and writeOAuth2RegistrationError.
The two /.well-known/* metadata endpoints are deliberately excluded. They
sit on their own route trees, their content is public discovery metadata,
and RFC 9728 §5 asks for the opposite treatment. A negative assertion
pins the exclusion so a later move to a higher router fails CI.
Note for PLAT-498: DELETE /oauth2/tokens now carries no-store and is
wrapped in apiKeyMiddleware, which runs after this middleware. A
Cache-Control write there must not replace no-store with a weaker
directive such as private.
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 12 findings (5 P2, 1 Nit, 6 Note), COMMENT. Review Finding inventoryFinding inventoryFindings
Round logRound 1Netero: no findings. Panel: 16 trigger-matched (bisky, chopper, ging-go, gon, hisoka, kite, komugi, kurapika, leorio, mafu-san, mafuuu, pariston, razor, robin, ryosuke, zoro) + 1 wildcard (meruem). 1 P2 (converged: scope gap), 4 P2 (Gon comment quality), 6 Notes, 2 Nits. Bisky, Chopper (no-finding path), Ging-Go, Kite, Komugi, Kurapika, Mafu-san (no-finding path), Meruem, Pariston reported no findings; several appended trust-signal or praise notes for the commit body, docstring quality, negative-assertion pattern, and test-truthfulness. Reviewed against 49cbd7d..98efb88. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Small, focused, high-signal PR. The middleware form is the right instrument here: three write paths in the /oauth2 tree bypass httpapi.Write, so a helper-hook fix there would have missed them, and the tree-mount inherits future routes without an author remembering. The negative Cacheable/.well-known/* assertions are unusually mature. They fail if a future refactor hoists NoStore onto a router that reaches them. TestNoStoreAfterExperimentGate documents its substitution against RequireExperiment because the production wrapper's buildinfo.IsDev() short-circuits in test binaries, and the docstring names why.
Severity: 5 P2, 6 Notes, 2 Nits.
The main open question is CRF-1: the PR body's own scope defense ("every response from this tree may carry a credential") applies verbatim to POST /api/v2/oauth2-provider/apps/{app}/secrets, which returns ClientSecretFull and gets no cache directive from any middleware on its tree. Either mount NoStore on the /oauth2-provider route or state the deliberate exclusion in the PR body's scope argument. Ryosuke also proposes framing the mount rule around "credentials returned" rather than URL prefix so a future admin route on a new tree inherits the invariant automatically; that is a structural proposal, not a request.
The other four P2s are Gon's comment-quality set: the mount-site inline comment, the NoStore godoc third paragraph, an assert struct-field comment, and the HandlerOverwrites trailing clause each restate what the surrounding syntax already teaches. The Notes track advisory-contract fragility (Hisoka, Mafuuu, Ryosuke, Razor), what TestNoStoreAfterExperimentGate actually pins vs. what its docstring claims (Zoro, Razor), a sibling class outside both OAuth2 trees (Mafu-san flags /login, /otp/change-password, /api/v2/users/{user}/keys/*), and Robin's observation that chi's imported middleware.NoCache already exists but is a spec-wrong superset the doc could name.
CRF-13 is meta: the PR title fix: ... is missing the (coderd) scope the commit subject already carries. AGENTS.md pins the format; a squash-merge takes the PR title, so release-notes lose the scope silently otherwise.
A verbatim quote from Leorio, on the commit body: "This is the commit body I want every fix in this codebase to read like."
coderd/coderd.go:2111
P2 [CRF-1] The PR body's scope defense fires here too: POST /api/v2/oauth2-provider/apps/{app}/secrets returns plaintext ClientSecretFull via httpapi.Write, and nothing on the /api/v2/oauth2-provider tree sets a cache directive. (Ryosuke P2, Razor P3, Chopper Note, Mafu-san Note)
Ryosuke: "The threat model is the same as POST /oauth2/register (RFC 7591 registration returns client_secret); the only difference is that this admin endpoint sits behind apiKeyMiddleware. Session-authenticated does not mean cache-safe: a corporate reverse proxy configured by the operator, an intermediate cache that ignores request auth, or a browser's private cache are exactly what motivated the PR in the first place."
Razor: "RFC 6749 §5.1's MUST is written as 'any response containing tokens, credentials, or other sensitive information,' not 'any response on the OAuth2 endpoints.' The PR body extends its scope on exactly that reasoning ('Every response from this tree may carry a credential, so none of them may be retained by an intermediary cache'), then leaves the sibling that fits the same predicate uncovered."
Resolve by adding r.Use(httpmw.NoStore) inside the r.Route("/oauth2-provider", ...) block and a RegisterAppSecret-shaped test that asserts no-store on the response, or by stating the deliberate exclusion in the PR body so a future reader finds the paper trail. Ryosuke also proposes reframing the mount rule around credentials returned rather than URL prefix; that is a structural proposal for a follow-up, not part of this fix.
🤖
🤖 This review was automatically generated with Coder Agents.
POST /api/v2/oauth2-provider/apps/{app}/secrets returns
OAuth2ProviderAppSecretFull.ClientSecretFull in plaintext, and nothing on
that tree set a cache directive. It meets RFC 6749 5.1's predicate as
squarely as anything under /oauth2, so mount httpmw.NoStore there too
rather than leaving the rule a description of one URL prefix. Mounted
after apiKeyMiddleware, so a 401 that carries no credential gets no
headers, matching the property the /oauth2 tree already has with the
experiment gate.
Also address review comments on the original change. Drop the two
single-use header-name constants in favor of the package's existing
string literals at the call site. Delete the comments that restate the
syntax beside them, in the mount chain, the NoStore godoc, and the test
table. Note why chi's middleware.NoCache is not used: it strips
ETag-family headers from the request, and it sends directives neither
specification asks for while OAuth 2.1 narrows the requirement.
Narrow two overclaiming docstrings. TestNoStoreAfterExperimentGate
builds its chain in the test body, so it does not detect a reordering in
coderd.go, and the docstring no longer says it pins one. The
.well-known assertions prove the exclusion of this middleware, not that
the metadata is cacheable, since those endpoints advertise no freshness
lifetime at all.
770a146 to
97cea43
Compare
The shape annotations on the table entries and subtests ran two to three sentences where a phrase carried the same fact. Trim them, and trim the two test docstrings, keeping the clause that says why each shape is under test: which handlers bypass httpapi.Write, what http.Redirect does to the header map, why the unmatched-path case does not assert a status. Case names are unchanged. Renaming them to absorb the comments was considered and declined, since a name cannot carry the mapping from a generic handler shape to the real endpoint it models.
NotContains "no-store" caught this middleware being hoisted onto a router that reaches the discovery endpoints, but not the wider class of blanket middleware that would contradict RFC 9728 5 the same way. Chi's NoCache happens to include no-store and so was already caught; a middleware stamping private or max-age=0 on authenticated API responses was not. Assert the property instead: no directive that stops a shared cache from storing the response. An explicit freshness lifetime added to these endpoints later, which RFC 9728 5 encourages, still passes, which is why this is not require.Empty on the whole header.
geokat
left a comment
There was a problem hiding this comment.
A couple of nits, but LGTM 👍
The test asserted that RequireExperiment returns 403 and that a short-circuiting outer middleware stops the inner one from running. The first belongs to experiments.go and the second is net/http handler chaining, so nothing in it could fail for a reason involving NoStore. Reordering the production chain, or replacing NoStore in it with any no-op, left the test green. No coverage is lost, because there was none to lose. The experiment gate that runs in production is RequireExperimentWithDevBypass, which short-circuits on buildinfo.IsDev(), so the disabled path is not reachable from a test binary at any level. The design fact the test was standing in for, that mounting after the gate is deliberate and harmless, moves to a comment at the mount site.
The godoc said the test suite pins that no handler writes its own Cache-Control. That holds for /oauth2, whose nine routes are all asserted, but /api/v2/oauth2-provider has ten routes and two of them are covered. The fact itself is unchanged, since the only Cache-Control writers in coderd are the SSE helper, exp_chats, and the site handler, none of them reachable from either tree.
No response from the
/oauth2route tree setCache-Controlat all, so an intermediary cache or customer-operated reverse proxy was free to apply a heuristic freshness lifetime to a response carrying a live credential. RFC 6749 §5.1 and OAuth 2.1 §3.2.3 both make an affirmativeno-storedirective a MUST for the authorization server.Adds
httpmw.NoStore, mounted on the/oauth2and/api/v2/oauth2-providertrees, settingCache-Control: no-storeandPragma: no-cacheon every response from them. OAuth 2.1 dropsPragmabecause RFC 9111 §5.4 deprecates it as a request-only field, so sending both is conformant under either reading. Not operator-configurable, since both specs say MUST.Scope
POST /oauth2/tokens. The mount is one line either way, and the wider scope also covers DCR registration, client configuration read and update, the authorize 302 whoseLocationquery carries the code, andPOST /oauth2-provider/apps/{app}/secrets, which returns a plaintext client secret. A route added later inherits the headers, which matters for PLAT-449.httpapi.Write. Three write paths never call it:POST /oauth2/revokeandDELETE /oauth2/clients/{client_id}write a bare status, andwriteOAuth2RegistrationErrorencodes its own JSON./.well-known/*deliberately excluded. Public discovery metadata, and RFC 9728 §5 asks for the opposite treatment. Assertions pin the exclusion so a later hoist onto a higher router fails CI./users/login,/users/otp/change-password, and/users/{user}/keys/*have the same gap, but PLAT-448 is scoped to OAuth2 and reaching into session auth changes the risk profile.Every credential-returning route here is a
POST, and RFC 9111 §3 bars heuristic caching ofPOSTresponses, so this is defense-in-depth against a non-conformant intermediary rather than a live caching bug. Both specs say MUST regardless of what caches would actually do.Note for PLAT-498
DELETE /oauth2/tokensnow carriesno-storeand is wrapped inapiKeyMiddleware, which is mounted inside the/oauth2tree and therefore runs after this middleware. It is the one route where both can writeCache-Control, and PLAT-498's write must not replaceno-storewith something weaker such asprivate.POST /oauth2/tokenscannot overlap, since it deliberately has noapiKeyMiddleware.Two assumptions testing corrected
GET /oauth2/does-not-existreturns 200, not 404. Chi runs the subrouter's middleware chain for unmatched paths, so both headers are present, but the request falls through to the root router's SPA handler. The test asserts the headers and deliberately not the status.RequireExperimentWithDevBypassshort-circuits onbuildinfo.IsDev(). A unit test covers the consequence against theRequireExperimentit delegates to.No schema,
codersdk, or serpent option changes, somake genproduces no diff. Rollback is a revert.Refs PLAT-448