Remove form-action from default consent CSP, forward consent_csp_policy in all providers

Drop form-action from the default Content Security Policy on the OAuth
consent page. Chrome enforces form-action across the entire redirect
chain, which breaks flows where an HTTPS callback internally redirects
to a custom scheme (e.g. claude://, cursor://). Since the form posts
to itself and all redirects are server-controlled, form-action adds
no security value here.

Also forward the consent_csp_policy parameter through all concrete
OAuth providers (Auth0, Azure, Google, GitHub, Discord, WorkOS, AWS
Cognito, OCI) so users can override the CSP without accessing private
attributes.
This commit is contained in:
Jeremiah Lowin 2026-03-03 14:32:07 -05:00
commit 6aff9c94be
10 changed files with 27 additions and 20 deletions

View file

@ -5,8 +5,6 @@ This module contains HTML generation functions for consent and error pages.
from __future__ import annotations
from urllib.parse import urlparse
from fastmcp.utilities.ui import (
BUTTON_STYLES,
DETAIL_BOX_STYLES,
@ -198,20 +196,13 @@ def create_consent_html(
# If csp_policy is empty string, CSP will be disabled entirely in create_page
# If csp_policy is a non-empty string, use it as-is
if csp_policy is None:
# Need to allow form-action for form submission
# Chrome requires explicit scheme declarations in CSP form-action when redirect chains
# end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme.
parsed_redirect = urlparse(redirect_uri)
redirect_scheme = parsed_redirect.scheme.lower()
# Build form-action directive with standard schemes plus custom protocol if present
form_action_schemes = ["https:", "http:"]
if redirect_scheme and redirect_scheme not in ("http", "https"):
# Custom protocol scheme (e.g., cursor:, vscode:, etc.)
form_action_schemes.append(f"{redirect_scheme}:")
form_action_directive = " ".join(form_action_schemes)
csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}"
# The consent form posts to itself (action="") and all subsequent redirects
# are server-controlled. Chrome enforces form-action across the entire redirect
# chain (Chromium issue #40923007), which breaks flows where an HTTPS callback
# internally redirects to a custom scheme (e.g., claude:// or cursor://).
# Since the form target is same-origin and we control the redirect chain,
# omitting form-action is safe and avoids these browser-specific CSP issues.
csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'"
return create_page(
content=content,

View file

@ -70,6 +70,7 @@ class Auth0Provider(OIDCProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
consent_csp_policy: str | None = None,
) -> None:
"""Initialize Auth0 OAuth provider.
@ -114,6 +115,7 @@ class Auth0Provider(OIDCProxy):
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
)
logger.debug(

View file

@ -109,6 +109,7 @@ class AWSCognitoProvider(OIDCProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
consent_csp_policy: str | None = None,
):
"""Initialize AWS Cognito OAuth provider.
@ -161,6 +162,7 @@ class AWSCognitoProvider(OIDCProxy):
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
)
logger.debug(

View file

@ -108,6 +108,7 @@ class AzureProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
consent_csp_policy: str | None = None,
base_authority: str = "login.microsoftonline.com",
http_client: httpx.AsyncClient | None = None,
) -> None:
@ -233,6 +234,7 @@ class AzureProvider(OAuthProxy):
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
valid_scopes=parsed_required_scopes,
)

View file

@ -193,6 +193,7 @@ class DiscordProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
consent_csp_policy: str | None = None,
http_client: httpx.AsyncClient | None = None,
):
"""Initialize Discord OAuth provider.
@ -253,6 +254,7 @@ class DiscordProvider(OAuthProxy):
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
)
logger.debug(

View file

@ -192,6 +192,7 @@ class GitHubProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
consent_csp_policy: str | None = None,
http_client: httpx.AsyncClient | None = None,
):
"""Initialize GitHub OAuth provider.
@ -247,6 +248,7 @@ class GitHubProvider(OAuthProxy):
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
)
logger.debug(

View file

@ -207,6 +207,7 @@ class GoogleProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
consent_csp_policy: str | None = None,
extra_authorize_params: dict[str, str] | None = None,
http_client: httpx.AsyncClient | None = None,
):
@ -283,6 +284,7 @@ class GoogleProvider(OAuthProxy):
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
extra_authorize_params=extra_authorize_params_final,
)

View file

@ -129,6 +129,7 @@ class OCIProvider(OIDCProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
consent_csp_policy: str | None = None,
) -> None:
"""Initialize OCI OIDC provider.
@ -161,6 +162,7 @@ class OCIProvider(OIDCProxy):
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
)
logger.debug(

View file

@ -157,6 +157,7 @@ class WorkOSProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool = True,
consent_csp_policy: str | None = None,
http_client: httpx.AsyncClient | None = None,
):
"""Initialize WorkOS OAuth provider.
@ -218,6 +219,7 @@ class WorkOSProvider(OAuthProxy):
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_csp_policy=consent_csp_policy,
)
logger.debug(

View file

@ -289,8 +289,8 @@ class TestConsentPageServerIcon:
class TestConsentCSPPolicy:
"""Tests for Content Security Policy customization on consent page."""
async def test_default_csp_includes_form_action(self):
"""Test that default CSP includes form-action directive."""
async def test_default_csp_omits_form_action(self):
"""Test that default CSP omits form-action to avoid Chrome redirect chain issues."""
verifier = Mock(spec=TokenVerifier)
verifier.required_scopes = ["read"]
@ -335,9 +335,9 @@ class TestConsentCSPPolicy:
response = client.get(f"/consent?txn_id={txn_id}")
assert response.status_code == 200
# Default CSP should be present with form-action
# Default CSP should be present but WITHOUT form-action
assert 'http-equiv="Content-Security-Policy"' in response.text
assert "form-action" in response.text
assert "form-action" not in response.text
async def test_empty_csp_disables_csp_meta_tag(self):
"""Test that empty string CSP disables CSP meta tag entirely."""