mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 05:24:18 +02:00
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:
parent
0cfc9399d7
commit
6aff9c94be
10 changed files with 27 additions and 20 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue