@@ -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.
5455var 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.
159180var 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.
226248func 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 }
0 commit comments