Skip to content

Commit cfeafb8

Browse files
committed
fix(consent): review follow-ups — preserve replay expiry, align docs/specs, harden tests
- replay re-render keeps the ORIGINAL ExpiresAt (closes the indefinite keep-alive primitive a refreshed TTL would hand a captured consent blob); pinned by TestConsent_Replay_DoesNotExtendExpiry - adapt keycloak_e2e tests to the interstitial (build tag hid the 302 expectations from the default suite) - specs.md POST /consent block rewritten for the interstitial + replay re-render contract; consent_replay error code removed - replay_detected{kind=consent} semantic drift documented in specs/README/configuration/redis-production (counts benign double-submits; alert on rate, not ticks) - stale comments fixed: securityHeaders middleware, Consent godoc, consentTmpl CSP attribution, CanonicalCSPHostSource - consent/interstitial CSP literals share one base (only form-action differs); replayNotice + meta-refresh ;-safety documented - new tests: NavError parse-failure fallback, interstitial URL escaping round-trip, deny→replay→approve cross-action chain; consent-token extraction deduped onto one helper
1 parent dcbb500 commit cfeafb8

11 files changed

Lines changed: 311 additions & 70 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,9 @@ ordering) live in [`specs.md`](./specs.md).
234234
- `mcp_auth_consent_decisions_total{decision}` — funnel approve/deny
235235
- `mcp_auth_access_denied_total{reason}` — every denial bucket
236236
- `mcp_auth_replay_detected_total{kind}``code` / `refresh` /
237-
`consent` / `callback_state`
237+
`consent` / `callback_state`. `consent` also counts benign
238+
double-submits (replay answers with a re-rendered consent page,
239+
not a 4xx) — alert on sustained rate, not single ticks
238240
- `mcp_auth_rate_limited_total{endpoint}` — pre-auth httprate 429s
239241
- `mcp_auth_idp_exchange_throttled_total` — outbound bucket denials
240242
- `mcp_auth_clients_registered_total`, `mcp_auth_token_seals_total{purpose}`,

config/config.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -711,10 +711,16 @@ var cspHostSourceHostPort = regexp.MustCompile(
711711
)
712712

713713
// CanonicalCSPHostSource validates an operator-supplied CSP host-source
714-
// (scheme://host[:port]) and returns the lower-cased canonical form
715-
// suitable for emission into the consent page's CSP form-action
716-
// directive. Returns an error with the offending input quoted on any
717-
// validation failure.
714+
// (scheme://host[:port]) and returns the lower-cased canonical form.
715+
// Returns an error with the offending input quoted on any validation
716+
// failure.
717+
//
718+
// Since the consent interstitial removed form-action origin
719+
// enumeration, the only caller is the deprecated
720+
// CSP_FORM_ACTION_EXTRA parse path: the result is validated but
721+
// discarded (nothing emits it into a header anymore). Kept so a
722+
// malformed value still fails startup loud instead of silently
723+
// changing meaning; delete together with CSP_FORM_ACTION_EXTRA.
718724
//
719725
// Canonicalisation: scheme and host are ASCII-lower-cased per CSP3
720726
// §6.7.2.5 (host-source matching is case-insensitive); operators

docs/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ sum(mcp_auth_consent_decisions_total{decision="approved"})
160160
refresh-rotation outcomes.
161161
- `mcp_auth_replay_detected_total{kind}``code` / `refresh` /
162162
`consent` / `callback_state` replays caught by the Redis-backed
163-
store.
163+
store. The `consent` kind answers with a re-rendered consent page
164+
(200), so it also counts benign double-submits / back-button
165+
re-POSTs — not a pure attack signal.
164166
- `mcp_auth_groups_claim_shape_mismatch_total` — id_token `groups`
165167
claim failed to decode as `[]string`. **No denial occurs** — user
166168
is admitted with empty groups; the counter surfaces an IdP schema

docs/redis-production.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ The proxy's Prom counters surface Redis-related behavior:
143143
counts 503s returned because Redis errored.
144144
- `mcp_auth_replay_detected_total{kind="code"|"refresh"}` — counts
145145
legitimate replay-detection events. A spike may be an attack, or a
146-
broken client that retries with the same code.
146+
broken client that retries with the same code. The `consent` kind
147+
is softer: a replayed consent POST is answered with a re-rendered
148+
consent page, so it also counts benign double-submits.
147149
- `/readyz` on the metrics port — probes Redis with a cached
148150
`Exists`. Feeds K8s readiness.
149151

docs/runbooks/consent-denials.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ stays clean.
3333
3. **Automated test rig.** A CI job that drove `/authorize` and
3434
expected a 302 now sees a 200 HTML page; whatever scraped
3535
the form the wrong way looks like a denial in the logs.
36+
Same trap one step later: `POST /consent` no longer 302s
37+
either — approve/deny answer with a 200 navigation
38+
interstitial whose `meta http-equiv="refresh"` carries the
39+
next URL. Rigs must extract that target, not the `Location`
40+
header.
3641
4. **Consent page rendering broken behind the ingress.** Strict
3742
CSP or content-rewriting at the L7 hop mangles the form;
3843
user can't click. Symptom: zero approves, zero denies, just

handlers/consent.go

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,12 @@ type consentPageData struct {
4646
}
4747

4848
// consentTmpl is the proxy-rendered consent page. Plain HTML, no
49-
// JavaScript, CSP-tight (default-src 'none' from the
50-
// security-headers middleware) — the only interactivity is the two
51-
// submit buttons on the embedded form. Keeping the page free of
52-
// remote subresources also keeps the operator from having to relax
53-
// CSP just to render consent.
49+
// JavaScript, CSP-tight (consentPageCSP, set self-contained by
50+
// renderConsent — it overrides the security-headers middleware
51+
// baseline) — the only interactivity is the two submit buttons on
52+
// the embedded form. Keeping the page free of remote subresources
53+
// also keeps the operator from having to relax CSP just to render
54+
// consent.
5455
var consentTmpl = template.Must(template.New("consent").Parse(`<!doctype html>
5556
<html lang="en">
5657
<head>
@@ -127,15 +128,28 @@ var consentTmpl = template.Must(template.New("consent").Parse(`<!doctype html>
127128
</html>
128129
`))
129130

130-
// consentPageCSP is the static CSP for the consent page. form-action
131-
// stays 'self'-only: the approve/deny POST is answered with a 200
132-
// same-origin interstitial (renderNavInterstitial) rather than a
133-
// redirect, which terminates Chromium's form-action enforcement of
134-
// the navigation chain. No IdP / client origin enumeration needed —
135-
// the header is independent of IdP topology and client redirect
136-
// targets (previously CSP_FORM_ACTION_EXTRA + per-render widening,
137-
// both obsoleted by the interstitial).
138-
const consentPageCSP = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
131+
// Consent-flow CSP headers, sharing one base so the two
132+
// security-critical literals cannot drift apart — the single
133+
// intentional difference is the form-action source.
134+
//
135+
// consentPageCSP (form-action 'self'): the approve/deny POST is
136+
// answered with a 200 same-origin interstitial
137+
// (renderNavInterstitial) rather than a redirect, which terminates
138+
// Chromium's form-action enforcement of the navigation chain. No
139+
// IdP / client origin enumeration needed — the header is
140+
// independent of IdP topology and client redirect targets
141+
// (previously CSP_FORM_ACTION_EXTRA + per-render widening, both
142+
// obsoleted by the interstitial).
143+
//
144+
// navInterstitialCSP (form-action 'none'): the interstitial carries
145+
// no form; meta-refresh / anchor navigation is not governed by any
146+
// fetch or form-action directive, so everything stays locked down.
147+
const (
148+
cspConsentPrefix = "default-src 'none'; style-src 'unsafe-inline'; form-action "
149+
cspConsentSuffix = "; frame-ancestors 'none'; base-uri 'none'"
150+
consentPageCSP = cspConsentPrefix + "'self'" + cspConsentSuffix
151+
navInterstitialCSP = cspConsentPrefix + "'none'" + cspConsentSuffix
152+
)
139153

140154
// navInterstitialTmpl carries the user from a /consent form POST to
141155
// the next location: the IdP authorize URL on approve, the client
@@ -156,6 +170,13 @@ const consentPageCSP = "default-src 'none'; style-src 'unsafe-inline'; form-acti
156170
// enforcement; the meta refresh then starts a regular navigation
157171
// that form-action does not govern. Meta refresh needs no
158172
// JavaScript, so script-src stays 'none'.
173+
//
174+
// {{.URL}} in the content attribute relies on the upstream
175+
// builders for `;`-safety: html/template HTML-escapes the
176+
// attribute (quotes, <, &) but leaves `;` raw, and the meta-refresh
177+
// parse algorithm takes everything after "url=" as the URL — safe
178+
// because both producers (oauth2Cfg.AuthCodeURL, authzErrorURL's
179+
// q.Encode) percent-encode `;` in query values.
159180
var navInterstitialTmpl = template.Must(template.New("nav").Parse(`<!doctype html>
160181
<html lang="en">
161182
<head>
@@ -186,10 +207,7 @@ func renderNavInterstitial(w http.ResponseWriter, logger *zap.Logger, targetURL
186207
// the client's error envelope — never serve it from a cache.
187208
w.Header().Set("Cache-Control", "no-store")
188209
w.Header().Set("Pragma", "no-cache")
189-
// No form on this page; meta-refresh / anchor navigation is not
190-
// governed by any fetch or form-action directive, so everything
191-
// stays locked down.
192-
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'")
210+
w.Header().Set("Content-Security-Policy", navInterstitialCSP)
193211
w.WriteHeader(http.StatusOK)
194212
if err := navInterstitialTmpl.Execute(w, struct{ URL string }{targetURL}); err != nil {
195213
// Body already started — log only.
@@ -223,6 +241,10 @@ func consentNavError(w http.ResponseWriter, logger *zap.Logger, redirectURI, sta
223241
// already trusted at this point in the flow. The interstitial (not
224242
// a 302) because this renderer also answers the POST /consent
225243
// replay path, where a cross-origin redirect would trip form-action.
244+
//
245+
// replayNotice=true adds the "previous response already processed"
246+
// banner — set only by the POST /consent replay re-render; the
247+
// GET /authorize first render passes false.
226248
func renderConsent(w http.ResponseWriter, tm *token.Manager, logger *zap.Logger, baseURL, resourceName string, consent sealedConsent, replayNotice bool) {
227249
consentToken, err := tm.SealJSON(consent, token.PurposeConsent)
228250
if err != nil {
@@ -299,15 +321,17 @@ type ConsentConfig struct {
299321
//
300322
// Replays /authorize Phase 3 on approval: opens the sealedConsent,
301323
// mints the upstream OIDC nonce and PKCE verifier, seals a
302-
// sealedSession, and 302s to the IdP. The original sealedClient is
303-
// NOT reopened here — the consent blob carries only the inner
304-
// client_id UUID, not the sealed registration handle, so a
305-
// re-validation would have nothing to re-validate against. The
306-
// audience + TTL + AAD-purpose triple binding on the consent blob
307-
// is the integrity check.
324+
// sealedSession, and answers with the navigation interstitial
325+
// targeting the IdP (see renderNavInterstitial for why not a 302).
326+
// The original sealedClient is NOT reopened here — the consent blob
327+
// carries only the inner client_id UUID, not the sealed
328+
// registration handle, so a re-validation would have nothing to
329+
// re-validate against. The audience + TTL + AAD-purpose triple
330+
// binding on the consent blob is the integrity check.
308331
//
309-
// On deny: redirects 302 to the user's registered redirect_uri
310-
// with `error=access_denied` per RFC 6749 §4.1.2.1.
332+
// On deny: answers with the interstitial targeting the user's
333+
// registered redirect_uri carrying `error=access_denied` per
334+
// RFC 6749 §4.1.2.1.
311335
//
312336
// CSRF: the sealedConsent itself is the CSRF token (audience- and
313337
// purpose-bound, 5-min TTL). A POST without a valid consent_token
@@ -416,8 +440,12 @@ func Consent(tm *token.Manager, logger *zap.Logger, baseURL string, oauth2Cfg *o
416440
// above), only its JTI is spent. The fresh token
417441
// still requires a new explicit click, so the
418442
// single-use guarantee on the *decision* holds.
443+
// ExpiresAt is deliberately NOT refreshed: the
444+
// consentTTL window counts from the original
445+
// /authorize render, otherwise replay→re-render
446+
// cycles would keep a captured blob alive
447+
// indefinitely.
419448
consent.JTI = uuid.New().String()
420-
consent.ExpiresAt = time.Now().Add(consentTTL)
421449
renderConsent(w, tm, logger, baseURL, cfg.ResourceName, consent, true)
422450
return
423451
}

handlers/consent_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,89 @@ func registerClientNamed(t *testing.T, tm *token.Manager, redirectURIs []string,
511511
return enc, internalUUID
512512
}
513513

514+
// TestConsent_NavErrorParseFailureFallback pins consentNavError's
515+
// fallback: when the sealed redirect_uri does not url.Parse (an
516+
// invariant breach — DCR validates the shape — but the branch must
517+
// stay fail-safe), the error envelope is served as proxy-hosted
518+
// JSON 400 instead of an interstitial targeting a garbage URL.
519+
func TestConsent_NavErrorParseFailureFallback(t *testing.T) {
520+
tm := newTestTokenManager(t)
521+
// Space in host fails url.Parse.
522+
consentToken := mintConsentToken(t, tm, "https://bad host.example.com/cb", "s")
523+
524+
form := url.Values{
525+
"consent_token": {consentToken},
526+
"action": {"deny"},
527+
}
528+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/consent", strings.NewReader(form.Encode()))
529+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
530+
rr := httptest.NewRecorder()
531+
Consent(tm, zap.NewNop(), testBaseURL, testOAuth2Config(), ConsentConfig{})(rr, req)
532+
533+
if rr.Code != http.StatusBadRequest {
534+
t.Fatalf("want 400 JSON fallback, got %d: %s", rr.Code, rr.Body.String())
535+
}
536+
var oauthErr OAuthError
537+
if err := json.NewDecoder(rr.Body).Decode(&oauthErr); err != nil {
538+
t.Fatalf("decode: %v", err)
539+
}
540+
if oauthErr.Error != "access_denied" {
541+
t.Errorf("error = %q, want access_denied", oauthErr.Error)
542+
}
543+
}
544+
545+
// TestConsent_InterstitialEscapesTargetURL pins the escaping
546+
// round-trip the extraction helpers rely on: the target URL is
547+
// HTML-attribute-escaped in the raw body (& → &amp;) in BOTH the
548+
// meta-refresh content attribute and the anchor-href fallback, and
549+
// unescaping restores a parseable multi-param URL. A regression to
550+
// template.HTML (raw &) or a divergence between the two attributes
551+
// fails here.
552+
func TestConsent_InterstitialEscapesTargetURL(t *testing.T) {
553+
tm := newTestTokenManager(t)
554+
consentToken := mintConsentToken(t, tm, "https://app.example.com/cb", "s")
555+
556+
form := url.Values{
557+
"consent_token": {consentToken},
558+
"action": {"approve"},
559+
}
560+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/consent", strings.NewReader(form.Encode()))
561+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
562+
rr := httptest.NewRecorder()
563+
Consent(tm, zap.NewNop(), testBaseURL, testOAuth2Config(), ConsentConfig{})(rr, req)
564+
565+
body := rr.Body.String()
566+
const marker = `content="0;url=`
567+
_, rest, found := strings.Cut(body, marker)
568+
if !found {
569+
t.Fatalf("meta refresh not found:\n%s", body)
570+
}
571+
rawAttr, _, found := strings.Cut(rest, `"`)
572+
if !found {
573+
t.Fatalf("meta refresh close-quote not found")
574+
}
575+
// The IdP authorize URL always carries multiple query params, so
576+
// the escaped form MUST contain &amp; and the raw form must not
577+
// leak a bare & into the attribute.
578+
if !strings.Contains(rawAttr, "&amp;") {
579+
t.Errorf("meta refresh attribute not HTML-escaped (no &amp;): %q", rawAttr)
580+
}
581+
if strings.Contains(html.UnescapeString(rawAttr), "&amp;") {
582+
t.Errorf("double-escaped meta refresh attribute: %q", rawAttr)
583+
}
584+
u, err := url.Parse(html.UnescapeString(rawAttr))
585+
if err != nil {
586+
t.Fatalf("unescaped target does not parse: %v", err)
587+
}
588+
if len(u.Query()) < 2 {
589+
t.Errorf("expected multi-param IdP URL, got %q", u.String())
590+
}
591+
// Anchor fallback must carry the identical escaped URL.
592+
if !strings.Contains(body, `<a href="`+rawAttr+`"`) {
593+
t.Errorf("anchor href does not match the meta refresh target")
594+
}
595+
}
596+
514597
func mintConsentToken(t *testing.T, tm *token.Manager, redirectURI, state string) string {
515598
t.Helper()
516599
consent := sealedConsent{

0 commit comments

Comments
 (0)