Files
coder/coderd/httpmw/nostore_test.go
T
Bobby Ho 1aa3553b52 fix(coderd): set Cache-Control: no-store on OAuth2 responses (#28143)
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
2026-08-13 17:47:12 -07:00

103 lines
2.9 KiB
Go

package httpmw_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/httpmw"
)
func TestNoStore(t *testing.T) {
t.Parallel()
tests := []struct {
Name string
Handler http.HandlerFunc
expectStatus int
expectCacheControl string
expectPragma string
assert func(t *testing.T, res *httptest.ResponseRecorder)
}{
{
// The POST /oauth2/tokens shape.
Name: "OK",
Handler: func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write([]byte(`{"access_token":"secret"}`))
},
expectStatus: http.StatusOK,
expectCacheControl: "no-store",
expectPragma: "no-cache",
},
{
// The DELETE /oauth2/clients/{client_id} shape: headers on a
// response with no body.
Name: "NoContent",
Handler: func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusNoContent)
},
expectStatus: http.StatusNoContent,
expectCacheControl: "no-store",
expectPragma: "no-cache",
},
{
// The POST /oauth2/authorize shape: http.Redirect writes its own
// headers without clearing the map.
Name: "Redirect",
Handler: func(rw http.ResponseWriter, r *http.Request) {
http.Redirect(rw, r, "https://example.com/callback?code=abc", http.StatusFound)
},
expectStatus: http.StatusFound,
expectCacheControl: "no-store",
expectPragma: "no-cache",
assert: func(t *testing.T, res *httptest.ResponseRecorder) {
require.Equal(t, "https://example.com/callback?code=abc", res.Header().Get("Location"))
},
},
{
// The revoke.go and registration.go shape: a bare WriteHeader,
// never httpapi.Write.
Name: "BareWriteHeaderError",
Handler: func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusBadRequest)
},
expectStatus: http.StatusBadRequest,
expectCacheControl: "no-store",
expectPragma: "no-cache",
},
{
// The headers are advisory: a handler that writes its own
// Cache-Control wins, and Pragma survives alongside it.
Name: "HandlerOverwrites",
Handler: func(rw http.ResponseWriter, _ *http.Request) {
rw.Header().Set("Cache-Control", "private")
rw.WriteHeader(http.StatusOK)
},
expectStatus: http.StatusOK,
expectCacheControl: "private",
expectPragma: "no-cache",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
res := httptest.NewRecorder()
httpmw.NoStore(tt.Handler).ServeHTTP(res, req)
require.Equal(t, tt.expectStatus, res.Code)
require.Equal(t, tt.expectCacheControl, res.Header().Get("Cache-Control"))
require.Equal(t, tt.expectPragma, res.Header().Get("Pragma"))
if tt.assert != nil {
tt.assert(t, res)
}
})
}
}