From 6aff9c94bec9233bbb1d31bce3ebf779a1f3f69c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:32:07 -0500 Subject: [PATCH] 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. --- src/fastmcp/server/auth/oauth_proxy/ui.py | 23 ++++++-------------- src/fastmcp/server/auth/providers/auth0.py | 2 ++ src/fastmcp/server/auth/providers/aws.py | 2 ++ src/fastmcp/server/auth/providers/azure.py | 2 ++ src/fastmcp/server/auth/providers/discord.py | 2 ++ src/fastmcp/server/auth/providers/github.py | 2 ++ src/fastmcp/server/auth/providers/google.py | 2 ++ src/fastmcp/server/auth/providers/oci.py | 2 ++ src/fastmcp/server/auth/providers/workos.py | 2 ++ tests/server/auth/test_oauth_consent_page.py | 8 +++---- 10 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/fastmcp/server/auth/oauth_proxy/ui.py b/src/fastmcp/server/auth/oauth_proxy/ui.py index 4cbb3ec2c..a8a12efe3 100644 --- a/src/fastmcp/server/auth/oauth_proxy/ui.py +++ b/src/fastmcp/server/auth/oauth_proxy/ui.py @@ -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, diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 46c4f70c9..3eec61776 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -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( diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index d684ee82f..1e4f913b6 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -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( diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 310d26f8a..009cf9316 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -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, ) diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py index 7af28e026..7ef187202 100644 --- a/src/fastmcp/server/auth/providers/discord.py +++ b/src/fastmcp/server/auth/providers/discord.py @@ -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( diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 01331ba25..3d0c31d27 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -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( diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 0dd509e33..265a9dd48 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -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, ) diff --git a/src/fastmcp/server/auth/providers/oci.py b/src/fastmcp/server/auth/providers/oci.py index c884b6c2c..29527f07a 100644 --- a/src/fastmcp/server/auth/providers/oci.py +++ b/src/fastmcp/server/auth/providers/oci.py @@ -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( diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 48ed825e1..3b81ca127 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -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( diff --git a/tests/server/auth/test_oauth_consent_page.py b/tests/server/auth/test_oauth_consent_page.py index 5e2d51737..bd394db74 100644 --- a/tests/server/auth/test_oauth_consent_page.py +++ b/tests/server/auth/test_oauth_consent_page.py @@ -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."""