Skip to content

fix(coderd): set Cache-Control: no-store on OAuth2 responses - #28143

Merged
BobbyHo merged 7 commits into
mainfrom
coder-plat-448-oauth-cache-control
Aug 14, 2026
Merged

fix(coderd): set Cache-Control: no-store on OAuth2 responses#28143
BobbyHo merged 7 commits into
mainfrom
coder-plat-448-oauth-cache-control

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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.

Adds httpmw.NoStore, mounted on the /oauth2 and /api/v2/oauth2-provider trees, setting Cache-Control: no-store and Pragma: no-cache on every response from them. 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. Not operator-configurable, since both specs say MUST.

Scope

  • Both trees, not just 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 whose Location query carries the code, and POST /oauth2-provider/apps/{app}/secrets, which returns a plaintext client secret. A route added later inherits the headers, which matters for PLAT-449.
  • A middleware, not a hook in httpapi.Write. Three write paths never call it: POST /oauth2/revoke and DELETE /oauth2/clients/{client_id} write a bare status, and writeOAuth2RegistrationError encodes 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.
  • Session-credential routes left alone. /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 of POST responses, 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/tokens now carries no-store and is wrapped in apiKeyMiddleware, which is mounted inside the /oauth2 tree and therefore runs after this middleware. It is the one route where both can write Cache-Control, and PLAT-498's write must not replace no-store with something weaker such as private. POST /oauth2/tokens cannot overlap, since it deliberately has no apiKeyMiddleware.

Two assumptions testing corrected

  • GET /oauth2/does-not-exist returns 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.
  • The experiment-disabled case is unreachable from a test binary, since RequireExperimentWithDevBypass short-circuits on buildinfo.IsDev(). A unit test covers the consequence against the RequireExperiment it delegates to.

No schema, codersdk, or serpent option changes, so make gen produces no diff. Rollback is a revert.

Refs PLAT-448

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.
@linear-code

linear-code Bot commented Aug 13, 2026

Copy link
Copy Markdown

PLAT-448

@BobbyHo

BobbyHo commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-08-13 17:54 UTC by @BobbyHo

Review history
  • R1 (2026-08-13): 17 reviewers, 1 Nit, 6 Note, 5 P2, COMMENT. Review

deep-review v0.9.0 | Round 1 | 49cbd7d..98efb88

Last posted: Round 1, 12 findings (5 P2, 1 Nit, 6 Note), COMMENT. Review

Finding inventory

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Open coderd/coderd.go:2111 /api/v2/oauth2-provider/apps/{app}/secrets returns plaintext ClientSecretFull without Cache-Control, same class the PR set out to fix R1 Ryosuke P2, Razor P3, Chopper Note, Mafu-san Note Yes
CRF-2 P2 Open coderd/coderd.go:1247 Inline mount-site comment's second sentence duplicates the NoStore godoc R1 Gon P2, Zoro Nit Yes
CRF-3 P2 Open coderd/httpmw/nostore.go:20 NoStore godoc third paragraph restates the middleware signature R1 Gon Yes
CRF-4 P2 Open coderd/httpmw/nostore_test.go:24 assert field comment restates the field type R1 Gon Yes
CRF-5 P2 Open coderd/httpmw/nostore_test.go:77 HandlerOverwrites trailing clause is appreciative meta-commentary R1 Gon Yes
CRF-6 Note Open coderd/oauth2provider/nostore_test.go:1 Broad pattern of over-annotation on table entries and subtest names R1 Gon Yes
CRF-7 Note Open coderd/httpmw/nostore_test.go:117 TestNoStoreAfterExperimentGate pins net/http chaining, not the /oauth2 production order R1 Zoro, Razor Yes
CRF-8 Note Open coderd/httpmw/nostore.go:23 Middleware is advisory; a future /oauth2 handler that writes its own Cache-Control silently strips the guarantee R1 Hisoka, Mafuuu, Ryosuke, Razor Yes
CRF-9 Note Open coderd/httpmw/nostore.go:11 Doc comment does not name the chi middleware.NoCache neighbor it declines to use R1 Robin Yes
CRF-10 Note Open coderd/httpmw/nostore.go:1 Sibling class outside the OAuth2 trees: /login, /otp/change-password, /api/v2/users/{user}/keys/* also return session credentials without cache directives R1 Mafu-san Yes
CRF-11 Note Open coderd/oauth2provider/nostore_test.go:271 Cacheable/.well-known/* uses NotContains "no-store", which passes for any non-no-store directive (e.g., private) R1 Mafuuu, Razor Yes
CRF-12 Nit Open coderd/httpmw/nostore.go:5 Single-use cacheControlHeader / pragmaHeader constants add ceremony vs. package convention of inline literals R1 Razor Yes
CRF-13 Nit Open (in review body) pr-title PR title fix: ... missing the (coderd) scope the commit subject already carries R1 Leorio No

Round log

Round 1

Netero: 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-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 BobbyHo changed the title fix(coderd): set Cache-Control: no-store on OAuth2 responses fix: set Cache-Control: no-store on OAuth2 responses Aug 13, 2026

@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.

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.

Comment thread coderd/coderd.go Outdated
Comment thread coderd/httpmw/nostore.go Outdated
Comment thread coderd/httpmw/nostore_test.go Outdated
Comment thread coderd/httpmw/nostore_test.go Outdated
Comment thread coderd/oauth2provider/nostore_test.go
Comment thread coderd/httpmw/nostore.go
Comment thread coderd/httpmw/nostore.go
Comment thread coderd/httpmw/nostore.go
Comment thread coderd/oauth2provider/nostore_test.go Outdated
Comment thread coderd/httpmw/nostore.go Outdated
@BobbyHo BobbyHo changed the title fix: set Cache-Control: no-store on OAuth2 responses fix(coderd): set Cache-Control: no-store on OAuth2 responses Aug 13, 2026
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.
@BobbyHo
BobbyHo force-pushed the coder-plat-448-oauth-cache-control branch from 770a146 to 97cea43 Compare August 13, 2026 21:06
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.
@BobbyHo
BobbyHo marked this pull request as ready for review August 13, 2026 21:44

@geokat geokat 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.

A couple of nits, but LGTM 👍

Comment thread coderd/httpmw/nostore_test.go Outdated
Comment thread coderd/httpmw/nostore.go Outdated
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.
@BobbyHo
BobbyHo merged commit 1aa3553 into main Aug 14, 2026
28 checks passed
@BobbyHo
BobbyHo deleted the coder-plat-448-oauth-cache-control branch August 14, 2026 00:47
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 14, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants