mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 22:04:57 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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=["*"],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
65
tests/test_factory_cors.py
Normal file
65
tests/test_factory_cors.py
Normal 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
|
||||
Reference in New Issue
Block a user