Skip to content

feat(auth): send a nonce-based CSP on the consent and error pages - #242

Open
mattzcarey wants to merge 2 commits into
mainfrom
feat/consent-page-csp
Open

mattzcarey wants to merge 2 commits into
mainfrom
feat/consent-page-csp

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

The consent and error pages show text anyone can set: a DCR client_name, a Client ID Metadata Document's name and redirect URI, a scope name repeated in an error. It is all escaped. This adds the backstop for a value that ever isn't: only the response's own inline script and styles run.

default-src 'none'; script-src 'nonce-…'; style-src 'nonce-…' https://fonts.googleapis.com;
font-src https://fonts.gstatic.com; img-src 'self'; base-uri 'none'; frame-ancestors 'none'
  • Both pages render through renderPage(). It generates the nonce, puts it on the page's only <style> and <script>, and sets the policy on the same response, so no template can carry inline code the policy doesn't allow or forget the nonce. It also escapes the title and sets X-Frame-Options: DENY.

    return renderPage(
      { title: `Authorize ${consent.clientName}`, css, card, script },
      { headers } // beginConsent()'s binding cookie; the policy replaces its frame-ancestors-only one
    )
  • The error page's close link used an inline onclick and a javascript: URL. It is now a <button> with a nonced listener, and the page sends form-action 'none'.

  • The consent page sends no form-action, on purpose. Chrome applies it to the redirect after a submission too:

    sequenceDiagram
      participant B as Browser, consent page
      participant M as mcp.cloudflare.com
      participant C as Client redirect URI
      B->>M: POST /authorize (Cancel)
      M-->>B: 302
      B->>C: GET ?error=access_denied, form-action must allow C
    
    Loading

    So it would have to list the client's redirect origin, next to Cloudflare's for Continue. The client picks that origin and also supplies the text an injection would come from, so an injected form could post there anyway. CSP can't write an IPv6 loopback address (http://[::1]:…) at all. And the form's only field is a handle bound to this browser.

Inline scripts and styles on both pages now run only with a per-response nonce, so markup that slips past escaping cannot execute. The consent form's form-action allows Cloudflare's authorization origin and the client's redirect origin, because Chrome checks form-action on the redirect after each button. The error page's close link moves from an inline onclick to a nonced listener.
…the consent page

renderPage() generates the nonce, puts it on the page's only <style> and
<script>, and sets the Content-Security-Policy that names it, so a page
template can't add inline code the policy doesn't allow, or forget the
nonce. It also escapes the title and sets X-Frame-Options for both pages.

The consent page no longer sends form-action. Chrome applies it to the
redirect after a submission, so it had to list Cloudflare's origin for
Continue and the client's redirect origin for Cancel, and CSP can't
write an IPv6 loopback address at all. The client that picks that origin
also supplies the text an injection would come from, so listing it let
an injected form post there anyway, and the form's only field is a
handle bound to this browser. The error page has no form and keeps
form-action 'none'.
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