Skip to content

fix(consent): break CSP form-action chain with navigation interstitial#36

Merged
babs merged 2 commits into
masterfrom
fix/consent-form-action-interstitial
Jun 4, 2026
Merged

fix(consent): break CSP form-action chain with navigation interstitial#36
babs merged 2 commits into
masterfrom
fix/consent-form-action-interstitial

Conversation

@babs

@babs babs commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Problem

Chromium enforces the consent page's CSP form-action directive against every hop of the redirect chain a form submit initiates — including redirects the OAuth client performs after receiving the authorization code. Observed in production: Power Platform's global.consent.azure-apim.net (registered redirect_uri, allowed since #35) 302s onward to a regional UI origin → Chrome blocks the final hop → the client never completes the code exchange → users retry Approve → consent_replay 400s.

Client-side hops are unknowable in advance: origin enumeration (#33 IdP extras, #35 redirect_uri widening) can never be complete.

Fix

Navigation interstitial: POST /consent (approve / deny / server-error) answers with a 200 same-origin page carrying meta http-equiv="refresh" (no JS) instead of a 302. The form navigation terminates at the proxy — downstream IdP → callback → client hops become a regular navigation that form-action does not govern.

Consequences:

  • consent page CSP tightens to form-action 'self'; interstitial is form-action 'none' — strictly stronger than master
  • CSP_FORM_ACTION_EXTRA deprecated (parsed + validated so deployments keep starting; csp_form_action_extra_deprecated startup warn); buildConsentCSPSources/formatConsentCSP deleted
  • net −216 lines

Replay UX: a replayed consent POST (double-submit, back-button) re-renders the consent page with a notice and a fresh single-use JTI instead of a dead-end 400 consent_replay. The original ExpiresAt is preserved — replay→re-render cycles cannot extend a captured blob's life — and a new explicit click is always required, so nothing auto-approves.

Security notes

  • {{.URL}} verified XSS-safe in both template contexts (attribute escaping + URL filtering, javascript:ZgotmplZ); DCR restricts redirect URIs to http(s)
  • Fail-closed ordering unchanged: JTI claim before the decision branch; replay-store outage → 503, never silent approve
  • mcp_auth_replay_detected_total{kind="consent"} now also counts benign double-submits — documented in specs/README/runbooks; alert on rate, not ticks

Validation

  • Unit suite green (handlers 81.9%), go vet -tags keycloak_e2e clean, golangci-lint 0 issues, complexity unchanged (Consent 20 == master)
  • Two review rounds (solo + 7-lens swarm); all findings fixed or rationale-documented in the second commit
  • Deployed to lab demo (Entra): consent page form-action 'self', approve → 200 interstitial → login.microsoftonline.com, replay → re-render with notice — verified end-to-end
  • Docs aligned: specs.md, README, configuration.md, threat-model.md, redis-production.md, consent-denials runbook

@babs babs changed the title fix/consent form action interstitial fix(consent): break CSP form-action chain with navigation interstitial Jun 4, 2026
babs added 2 commits June 4, 2026 14:43
Chromium enforces the consent page's form-action directive against
every hop of the redirect chain a form submit initiates. Client-side
hops after redirect_uri (e.g. Power Platform's
global.consent.azure-apim.net -> regional UI origin) are unknowable
in advance, so origin enumeration (#33 IdP extras, #35 redirect_uri)
could never be complete.

- answer POST /consent (approve/deny/server-error) with a 200
  same-origin interstitial (meta refresh, no JS) instead of a 302;
  form navigation ends at the proxy, downstream hops are a regular
  navigation that form-action does not govern
- tighten consent page CSP to form-action 'self'; drop
  buildConsentCSPSources/formatConsentCSP and per-render widening
- deprecate CSP_FORM_ACTION_EXTRA (parsed, ignored, startup warn)
- re-render consent page with fresh JTI on replayed submit instead
  of a dead-end 400 consent_replay; decision still requires a new
  explicit click, single-use guarantee unchanged
…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
@babs babs force-pushed the fix/consent-form-action-interstitial branch from cfeafb8 to 38de952 Compare June 4, 2026 12:44
@babs babs merged commit f29ec7b into master Jun 4, 2026
7 checks passed
@babs babs deleted the fix/consent-form-action-interstitial branch June 4, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant