From 0cfb292cef56a084e7edae4dfadaf21682ac0805 Mon Sep 17 00:00:00 2001 From: freeman Date: Fri, 9 Oct 2026 11:39:12 +0800 Subject: [PATCH] fix(api): disable CORS credentials for wildcard origins ALLOWED_HOSTS defaults to ["*"] and the CORS middleware always set allow_credentials=True. Starlette echoes any request Origin with Access-Control-Allow-Credentials in that combination, which lets every site make credentialed cross-origin calls. resolve_cors_policy now returns credentials=False for wildcard origins and keeps credentials only when explicit origins are configured. Co-Authored-By: Claude Fable 5.1 --- app/factory.py | 21 ++++++++++-- app/runtime/config.py | 2 +- tests/test_factory_cors.py | 65 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 tests/test_factory_cors.py diff --git a/app/factory.py b/app/factory.py index d75fe6ee9..c0bb8c2d6 100644 --- a/app/factory.py +++ b/app/factory.py @@ -328,6 +328,20 @@ async def localized_unhandled_exception_handler( ) +def resolve_cors_policy(allowed_hosts: Any) -> tuple[list[str], bool]: + """ + 把 ALLOWED_HOSTS 解析为 CORS 的允许来源与凭据开关。 + + 通配来源不得与 allow_credentials 并用:Starlette 在两者同时成立时会把任意 + 请求 Origin 原样回显并声明允许携带凭据,等于对所有站点开放带 Cookie 的跨域 + 访问。只有显式列出来源时才允许携带凭据。 + """ + origins = [str(item).strip() for item in (allowed_hosts or ["*"]) if str(item).strip()] + if not origins or "*" in origins: + return ["*"], False + return origins, True + + def create_app() -> FastAPI: """ 创建并配置 FastAPI 应用实例。 @@ -365,11 +379,12 @@ def create_app() -> FastAPI: enabled=lambda: bool(get_runtime_setting("API_DOCS_ENABLE")), ) - # 配置 CORS 中间件 + # 配置 CORS 中间件;通配来源时不允许携带凭据 + allow_origins, allow_credentials = resolve_cors_policy(get_runtime_setting('ALLOWED_HOSTS')) _app.add_middleware( CORSMiddleware, # noqa - allow_origins=get_runtime_setting('ALLOWED_HOSTS'), - allow_credentials=True, + allow_origins=allow_origins, + allow_credentials=allow_credentials, allow_methods=["*"], allow_headers=["*"], ) diff --git a/app/runtime/config.py b/app/runtime/config.py index 88c8f0098..3580dc072 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -204,7 +204,7 @@ class ConfigModel(BaseModel): SECRET_KEY: str = secrets.token_urlsafe(32) # RESOURCE密钥 RESOURCE_SECRET_KEY: str = secrets.token_urlsafe(32) - # 允许的域名 + # 允许的跨域来源;为 "*" 时浏览器跨域请求不携带凭据,需要跨域带 Cookie 请列出显式来源 ALLOWED_HOSTS: list = Field(default_factory=lambda: ["*"]) # TOKEN过期时间 ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 diff --git a/tests/test_factory_cors.py b/tests/test_factory_cors.py new file mode 100644 index 000000000..ff86905d3 --- /dev/null +++ b/tests/test_factory_cors.py @@ -0,0 +1,65 @@ +"""CORS 中间件在通配来源与显式来源下的凭据策略契约测试。""" + +import pytest +from fastapi.testclient import TestClient + +from app.factory import create_app, resolve_cors_policy +from app.runtime.config import settings + +LOGIN_PATH = "/api/v1/login/access-token" + + +def _preflight(client: TestClient, origin: str): + """发送一次登录接口的 CORS 预检请求。""" + return client.options( + LOGIN_PATH, + headers={ + "Origin": origin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + ) + + +@pytest.mark.parametrize( + ("allowed_hosts", "expected"), + [ + (None, (["*"], False)), + ([], (["*"], False)), + (["*"], (["*"], False)), + (["*", "https://mp.example"], (["*"], False)), + (["https://mp.example"], (["https://mp.example"], True)), + ([" https://mp.example ", "", "https://b.example"], (["https://mp.example", "https://b.example"], True)), + ], +) +def test_resolve_cors_policy_disables_credentials_for_wildcard(allowed_hosts, expected): + """通配来源一律关闭凭据,显式来源才允许携带凭据。""" + assert resolve_cors_policy(allowed_hosts) == expected + + +def test_wildcard_origins_do_not_allow_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + """默认通配配置下预检返回 *,且不声明允许凭据。""" + monkeypatch.setattr(settings, "ALLOWED_HOSTS", ["*"]) + client = TestClient(create_app()) + + response = _preflight(client, "https://evil.example") + + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "*" + assert "access-control-allow-credentials" not in response.headers + + +def test_explicit_origins_echo_origin_with_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + """显式来源命中时回显 Origin 并允许携带凭据,未列出的来源不返回 CORS 头。""" + monkeypatch.setattr(settings, "ALLOWED_HOSTS", ["https://mp.example"]) + client = TestClient(create_app()) + + allowed = _preflight(client, "https://mp.example") + assert allowed.status_code == 200 + assert allowed.headers["access-control-allow-origin"] == "https://mp.example" + assert allowed.headers["access-control-allow-credentials"] == "true" + + # Starlette 对未列出的来源拒绝预检:400 且不回显 allow-origin,浏览器据此阻止请求 + denied = _preflight(client, "https://evil.example") + assert denied.status_code == 400 + assert "access-control-allow-origin" not in denied.headers