fix(consent): break CSP form-action chain with navigation interstitial#36
Merged
Conversation
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
cfeafb8 to
38de952
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Chromium enforces the consent page's CSP
form-actiondirective 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'sglobal.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_replay400s.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 carryingmeta 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 thatform-actiondoes not govern.Consequences:
form-action 'self'; interstitial isform-action 'none'— strictly stronger than masterCSP_FORM_ACTION_EXTRAdeprecated (parsed + validated so deployments keep starting;csp_form_action_extra_deprecatedstartup warn);buildConsentCSPSources/formatConsentCSPdeletedReplay 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 originalExpiresAtis 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)mcp_auth_replay_detected_total{kind="consent"}now also counts benign double-submits — documented in specs/README/runbooks; alert on rate, not ticksValidation
go vet -tags keycloak_e2eclean, golangci-lint 0 issues, complexity unchanged (Consent20 == master)form-action 'self', approve → 200 interstitial →login.microsoftonline.com, replay → re-render with notice — verified end-to-endspecs.md,README,configuration.md,threat-model.md,redis-production.md, consent-denials runbook