Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Tool usage is tracked via the `MCP_METRICS` Analytics Engine binding into the sh
- OAuth uses PKCE (RFC 7636) for secure authorization
- Cookie encryption for OAuth sessions (`MCP_COOKIE_ENCRYPTION_KEY`)
- The `/mcp` route validates Host and present browser Origin headers against deployment-static allowlists before authentication
- The consent and error pages send a nonce-based Content-Security-Policy: inline `<script>` and `<style>` need the response's nonce, and inline event handlers, `javascript:` URLs and `style` attributes are blocked. Both pages render through `renderPage()` in `src/auth/workers-oauth-utils.ts`, which generates the nonce, adds it to the page's only `<style>` and `<script>`, and sets the policy, so add inline code there rather than in a page template. The consent page deliberately has no `form-action`: Chrome applies it to the redirects after Continue (to Cloudflare) and Cancel (to the client), the client chooses its redirect origin, and CSP can't name an IPv6 loopback address. The error page has no form and sends `form-action 'none'`

## Testing

Expand Down
173 changes: 112 additions & 61 deletions src/auth/workers-oauth-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,91 @@ function sanitizeHtml(unsafe: string): string {
.replace(/'/g, '&#039;')
}

/** A nonce for one response's inline script and styles: 128 random bits, base64. */
function generateCspNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}

/** What a consent or error page puts inside the shared page chrome. */
interface Page {
/** Plain text for the title bar; escaped here. */
readonly title: string
/** The page's CSS, after the shared chrome's. */
readonly css: string
/** The card's markup. Client-supplied values in it must already be escaped. */
readonly card: string
/** The page's script, if it has one. */
readonly script?: string
/** `'none'` for a page without a form. The consent page leaves it out: see `contentSecurityPolicy()`. */
readonly formAction?: "'none'"
}

/**
* Content-Security-Policy for the consent and error pages. They show client-supplied text, all of
* it escaped; this is the backstop for a value that ever isn't. Only the response's own inline
* script and styles run, nothing can frame the page, and a `<base>` can't rebase its links or
* its form.
*
* The consent page has no form-action. Chrome checks form-action against the redirect after a
* submission as well, so Continue needs Cloudflare's origin listed and Cancel the client's
* redirect origin. That origin belongs to the same client that supplies the page's text, so
* listing it would let an injected form post there too, and an IPv6 loopback redirect URI can't
* be listed at all. The form's only field is a handle bound to this browser.
*/
function contentSecurityPolicy(nonce: string, formAction: Page['formAction']): string {
const directives = [
"default-src 'none'",
`script-src 'nonce-${nonce}'`,
`style-src 'nonce-${nonce}' https://fonts.googleapis.com`,
'font-src https://fonts.gstatic.com',
"img-src 'self'",
"base-uri 'none'",
"frame-ancestors 'none'"
]
if (formAction) directives.push(`form-action ${formAction}`)
return directives.join('; ')
}

/**
* Render a consent or error page. The nonce is generated here, put on the page's only `<style>`
* and `<script>`, and named in the Content-Security-Policy on the same response, so no inline code
* can run without it and no page has to remember it. `headers` is updated in place, so the consent
* page keeps `beginConsent()`'s binding cookie; the policy replaces its frame-ancestors-only one.
*/
function renderPage(
page: Page,
{ headers = new Headers(), status = 200 }: { headers?: Headers; status?: number } = {}
): Response {
const nonce = generateCspNonce()
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${sanitizeHtml(page.title)} | Cloudflare</title>
${PAGE_FONT_LINKS}
<style nonce="${nonce}">${PAGE_CHROME_CSS}${page.css}</style>
</head>
<body>
${PAGE_HEADER_HTML}
<main class="main">
<div class="card">${page.card}</div>
</main>
${PAGE_FOOTER_HTML}
${page.script === undefined ? '' : `<script nonce="${nonce}">${page.script}</script>`}
</body>
</html>
`

headers.set('Content-Security-Policy', contentSecurityPolicy(nonce, page.formAction))
headers.set('Content-Type', 'text/html; charset=utf-8')
headers.set('X-Frame-Options', 'DENY')
return new Response(html, { status, headers })
}

/**
* Render a URL as the browser parsed it, without credentials or a fragment.
* The host keeps the default text colour and the scheme and path are dimmed,
Expand Down Expand Up @@ -347,15 +432,10 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
</div>`
: ''

const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authorize ${clientName} | Cloudflare</title>
${PAGE_FONT_LINKS}
<style>${PAGE_CHROME_CSS}
return renderPage(
{
title: `Authorize ${consent.clientName}`,
css: `
.card-header { padding: 1.5rem 2rem; border-bottom: 1px solid var(--kumo-line); text-align: center; }
/* Kumo Text variant="heading" size="lg" */
.card-title { font-size: 20px; font-weight: 600; line-height: 1.4; }
Expand Down Expand Up @@ -508,13 +588,8 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
.actions .button { flex: 1 1 100%; }
.actions .button-primary { order: -1; }
}
</style>
</head>
<body>
${PAGE_HEADER_HTML}

<main class="main">
<div class="card">
`,
card: `
<div class="card-header">
<h1 class="card-title">Authorize application</h1>
<p class="card-subtitle">Grant access to Cloudflare API</p>
Expand Down Expand Up @@ -571,12 +646,8 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
</div>
</form>
</div>
</div>
</main>

${PAGE_FOOTER_HTML}

<script>
`,
script: `
(function() {
const TEMPLATES = ${templateDataJson};
const TEMPLATE_NAMES = ${templateNamesJson};
Expand Down Expand Up @@ -705,14 +776,10 @@ export function renderApprovalDialog(request: Request, options: ApprovalDialogOp
renderTemplates();
applyTemplate(INITIAL_TEMPLATE);
})();
</script>
</body>
</html>
`

// beginConsent() headers: the browser binding cookie, frame-ancestors 'none', X-Frame-Options DENY
headers.set('Content-Type', 'text/html; charset=utf-8')
return new Response(htmlContent, { headers })
`
},
{ headers }
)
}

/**
Expand Down Expand Up @@ -759,15 +826,10 @@ export function renderErrorPage(
details?: string,
status = 400
): Response {
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${sanitizeHtml(title)} | Cloudflare</title>
${PAGE_FONT_LINKS}
<style>${PAGE_CHROME_CSS}
return renderPage(
{
title,
css: `
.main { align-items: center; }
.card { max-width: 440px; padding: 2.5rem 2rem; text-align: center; }
.error-icon {
Expand Down Expand Up @@ -795,12 +857,8 @@ export function renderErrorPage(
text-align: left;
word-break: break-word;
}
</style>
</head>
<body>
${PAGE_HEADER_HTML}
<main class="main">
<div class="card">
`,
card: `
<div class="error-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
Expand All @@ -811,20 +869,13 @@ export function renderErrorPage(
<h1 class="card-title">${sanitizeHtml(title)}</h1>
<p class="card-message">${sanitizeHtml(message)}</p>
${details ? `<div class="error-details">${sanitizeHtml(details)}</div>` : ''}
<a href="javascript:window.close()" class="button button-primary" onclick="window.close(); return false;"><span class="button-label">Close window</span></a>
</div>
</main>
${PAGE_FOOTER_HTML}
</body>
</html>
`

return new Response(htmlContent, {
status,
headers: {
'Content-Security-Policy': "frame-ancestors 'none'",
'Content-Type': 'text/html; charset=utf-8',
'X-Frame-Options': 'DENY'
}
})
<button type="button" class="button button-primary" id="closeWindow"><span class="button-label">Close window</span></button>
`,
script: `
document.getElementById('closeWindow').addEventListener('click', () => window.close());
`,
formAction: "'none'"
},
{ status }
)
}
108 changes: 105 additions & 3 deletions tests/auth/approval-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
type ApprovalDialogOptions
} from '../../src/auth/workers-oauth-utils'

function render(options: Partial<ApprovalDialogOptions> = {}): Promise<string> {
const response = renderApprovalDialog(new Request('https://mcp.cloudflare.com/authorize'), {
function renderResponse(options: Partial<ApprovalDialogOptions> = {}): Response {
return renderApprovalDialog(new Request('https://mcp.cloudflare.com/authorize'), {
consent: {
clientId: 'opaque-client-id',
clientName: 'Test client',
Expand All @@ -26,8 +26,44 @@ function render(options: Partial<ApprovalDialogOptions> = {}): Promise<string> {
initialScopes: [],
...options
})
}

return response.text()
function render(options: Partial<ApprovalDialogOptions> = {}): Promise<string> {
return renderResponse(options).text()
}

function consentRedirectingTo(redirectUri: string): ApprovalDialogOptions['consent'] {
const { hostname } = new URL(redirectUri)
return {
clientId: 'opaque-client-id',
clientName: 'Test client',
redirectUri,
redirectHost: hostname,
redirectIsLoopback: hostname !== 'callback.example',
scope: []
}
}

/** The nonce a page's policy lets scripts run with. */
function policyNonce(policy: string | null): string {
const nonce = policy?.match(/script-src 'nonce-([^']+)'/)?.[1]
if (!nonce) throw new Error(`No script nonce in the policy: ${policy}`)
return nonce
}

/**
* Every script and style tag carries the nonce, and no tag relies on what a nonce can't allow:
* inline event handlers, `javascript:` URLs and style attributes. Tags built by the page's
* script sit inside it as strings, so they are checked too.
*/
function expectOnlyNoncedInlineCode(body: string, nonce: string): void {
const tags = body.match(/<[a-z][^>]*>/gi) ?? []
const code = tags.filter((tag) => /^<(script|style)\b/i.test(tag))
expect(code.length).toBeGreaterThan(0)
for (const tag of code) expect(tag).toContain(`nonce="${nonce}"`)
for (const tag of tags) {
expect(tag).not.toMatch(/\son[a-z]+\s*=|javascript:|\sstyle\s*=/i)
}
}

/**
Expand Down Expand Up @@ -138,6 +174,72 @@ describe('OAuth approval dialog identity details', () => {
})
})

describe('OAuth page Content-Security-Policy', () => {
it('runs only the script and styles the consent page ships', async () => {
const response = renderResponse()
const policy = response.headers.get('Content-Security-Policy')
const nonce = policyNonce(policy)

expect(policy).toContain("default-src 'none'")
expect(policy).toContain(`style-src 'nonce-${nonce}' https://fonts.googleapis.com`)
expect(policy).toContain('font-src https://fonts.gstatic.com')
expect(policy).toContain("base-uri 'none'")
expect(policy).toContain("frame-ancestors 'none'")
expect(policy).not.toContain('unsafe-inline')
expectOnlyNoncedInlineCode(await response.text(), nonce)
// beginConsent()'s binding cookie survives the new policy.
expect(response.headers.get('Set-Cookie')).toContain('__Host-oauth-consent-')
})

it('uses a fresh nonce for every page', () => {
const first = policyNonce(renderResponse().headers.get('Content-Security-Policy'))
const second = policyNonce(renderResponse().headers.get('Content-Security-Policy'))
expect(first).not.toBe(second)
})

it('gives every client the same policy, with no form-action to stop Continue or Cancel redirecting', () => {
// Chrome applies form-action to the redirect after a submission: to Cloudflare on Continue,
// to the client on Cancel. CSP can't name an IPv6 literal, and the client picks its origin.
const policies = [
'https://callback.example/oauth/callback',
'http://127.0.0.1:6274/oauth/callback',
'http://[::1]:6274/oauth/callback'
].map((redirectUri) =>
renderResponse({ consent: consentRedirectingTo(redirectUri) })
.headers.get('Content-Security-Policy')
?.replaceAll(/'nonce-[^']+'/g, "'nonce'")
)

expect(new Set(policies).size).toBe(1)
expect(policies[0]).not.toContain('form-action')
})

it('escapes the client name in the title bar', async () => {
const body = await render({
consent: {
...consentRedirectingTo('https://callback.example/cb'),
clientName: '</title><b>x'
}
})

expect(body).toContain('<title>Authorize &lt;/title&gt;&lt;b&gt;x | Cloudflare</title>')
})

it('runs only the script and styles the error page ships', async () => {
const response = renderErrorPage('Server Error', 'Try again.')
const policy = response.headers.get('Content-Security-Policy')
const nonce = policyNonce(policy)

expect(policy).toContain("default-src 'none'")
expect(policy).toContain("form-action 'none'")
expect(policy).toContain("frame-ancestors 'none'")
expect(policy).not.toContain('unsafe-inline')
const body = await response.text()
expectOnlyNoncedInlineCode(body, nonce)
expect(body).toContain('id="closeWindow"')
})
})

describe('OAuth approval dialog templates', () => {
const templates = {
scopeTemplates: SCOPE_TEMPLATES,
Expand Down
9 changes: 9 additions & 0 deletions tests/auth/oauth-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,15 @@ describe('GET /authorize', () => {
// Consent form with CSRF protection and a session-binding cookie.
expect(body).toContain('<form')
expect(res.headers.get('Set-Cookie')).toBeTruthy()
// Only this response's nonced script runs. No form-action, which Chrome would apply to the
// redirects after Continue (to Cloudflare) and Cancel (to the client).
const policy = res.headers.get('Content-Security-Policy')
const nonce = policy?.match(/script-src 'nonce-([^']+)'/)?.[1]
expect(nonce).toBeTruthy()
expect(body).toContain(`<script nonce="${nonce}">`)
expect(policy).not.toContain('form-action')
expect(policy).toContain("frame-ancestors 'none'")
expect(res.headers.get('X-Frame-Options')).toBe('DENY')
// Cloudflare's authorization screen picks individual scopes. This page offers
// the built-in templates, plus any saved in the browser by the old picker, and
// says where to narrow them. New templates can no longer be saved.
Expand Down
Loading