Merge pull request #6923 from cwd0204/fix/cors-wildcard-credentials

This commit is contained in:
jxxghp
2026-10-09 13:03:06 +08:00
committed by GitHub
3 changed files with 84 additions and 4 deletions

View File

@@ -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=["*"],
)

View File

@@ -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

View File

@@ -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