From 1267e2b1a911b32a71d581f73155770b61b238b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E5=B0=8F=E8=88=9F?= Date: Tue, 29 Sep 2026 15:50:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E5=85=B6=E4=BB=96=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E4=B8=8D=E8=83=BD=E5=86=8D=E8=AF=BB=E5=8F=96=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=90=8E=E7=AB=AF=E7=9A=84=20API=20Key=20=E6=88=96?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=E5=86=99=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORS 由 * + credentials 收紧为 AutoClip 自家来源;带 Origin 的写请求必须同源或在白名单内, 表单/multipart 简单请求也会被拦;桌面模式只接受本机 Host,防 DNS 重绑定。 调试路由默认不挂载,web 入口默认只听 127.0.0.1。Docker 局域网访问用 AUTOCLIP_ALLOWED_ORIGINS。 Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/api/v1/__init__.py | 5 +- backend/app_factory.py | 8 +- backend/core/local_origin_guard.py | 104 +++++++++++++++++++++++ backend/main.py | 7 +- backend/tests/test_local_origin_guard.py | 77 +++++++++++++++++ docker-compose.yml | 1 + env.example | 4 + 7 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 backend/core/local_origin_guard.py create mode 100644 backend/tests/test_local_origin_guard.py diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py index 5bcffff7..4c095f36 100644 --- a/backend/api/v1/__init__.py +++ b/backend/api/v1/__init__.py @@ -2,6 +2,7 @@ API v1 package for FastAPI routes. 统一管理所有API路由 """ +import os from fastapi import APIRouter @@ -48,7 +49,9 @@ api_router.include_router(subtitle_editor_router, prefix="/subtitle-editor", tag api_router.include_router(upload_router, tags=["upload"]) api_router.include_router(progress_router, prefix="/progress", tags=["progress"]) api_router.include_router(pipeline_control_router, prefix="/pipeline", tags=["pipeline"]) -api_router.include_router(debug_router, tags=["debug"]) +# 调试路由能直接往发布通道塞消息,只在显式开启时挂载 +if os.getenv("AUTOCLIP_ENABLE_DEBUG_ROUTES", "").lower() in ("1", "true", "yes"): + api_router.include_router(debug_router, tags=["debug"]) api_router.include_router(simple_progress_router, tags=["simple-progress"]) # api_router.include_router(environment_router, tags=["environment"]) # 文件不存在,暂时注释 api_router.include_router(settings_router, tags=["settings"]) diff --git a/backend/app_factory.py b/backend/app_factory.py index a0c23d63..cc6f8214 100644 --- a/backend/app_factory.py +++ b/backend/app_factory.py @@ -14,6 +14,7 @@ from backend.core.database import engine from backend.models.base import Base from backend.core.config import get_logging_config, get_api_key from backend.core.error_middleware import global_exception_handler +from backend.core.local_origin_guard import LocalOriginGuard, allowed_origins logger = logging.getLogger(__name__) @@ -60,10 +61,13 @@ def create_app(mode: str = "web") -> FastAPI: # 设置应用状态 app.state.mode = mode - # 配置 CORS + # 来源守卫在内、CORS 在外:预检由 CORS 应答,其他网页的写请求由守卫拦下。 + # 以前是 allow_origins=["*"] + allow_credentials,任意网页都能读设置里的 API Key。 + origins = allowed_origins() + app.add_middleware(LocalOriginGuard, origins=origins, enforce_local_host=(mode == "desktop")) app.add_middleware( CORSMiddleware, - allow_origins=["*"], # 生产环境需要配置具体域名 + allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/core/local_origin_guard.py b/backend/core/local_origin_guard.py new file mode 100644 index 00000000..d7f69d98 --- /dev/null +++ b/backend/core/local_origin_guard.py @@ -0,0 +1,104 @@ +""" +本地后端的来源守卫。 + +后端没有登录态,任何能发 HTTP 请求到它的页面都能读设置里的 API Key、触发导入和发布。 +浏览器里打开的普通网页也能向 127.0.0.1 发请求,所以要在服务端拦: + +1. CORS 只对 AutoClip 自己的前端放行,其他网页读不到响应。 +2. 带 Origin 的写请求(POST/PUT/PATCH/DELETE)必须来自允许的来源,或与请求 Host 同源。 + 表单 / multipart 这类“简单请求”不会触发预检,只靠 CORS 拦不住。 +3. 桌面模式只接受 Host 为 127.0.0.1 / localhost 的请求,挡住 DNS 重绑定。 + +CLI、curl、Tauri 的 reqwest 不带 Origin,照常放行。 +Docker 通过局域网 IP 或自定义域名访问时,用 AUTOCLIP_ALLOWED_ORIGINS 追加来源(逗号分隔)。 +""" +from __future__ import annotations + +import os +from collections.abc import Iterable +from urllib.parse import urlsplit + +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +# Tauri 2:macOS / Linux 为 tauri://localhost,Windows 为 http(s)://tauri.localhost;3000 是 Vite 开发端口 +DEFAULT_ALLOWED_ORIGINS = ( + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost", + "http://localhost:3000", + "http://127.0.0.1:3000", +) + +LOCAL_HOSTNAMES = {"127.0.0.1", "localhost", "::1", "[::1]"} +SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} + + +def allowed_origins() -> list[str]: + extra = [o.strip().rstrip("/") for o in os.getenv("AUTOCLIP_ALLOWED_ORIGINS", "").split(",") if o.strip()] + return list(dict.fromkeys([*DEFAULT_ALLOWED_ORIGINS, *extra])) + + +def _header(scope: Scope, name: bytes) -> str: + for key, value in scope.get("headers") or (): + if key == name: + return value.decode("latin-1") + return "" + + +def _hostname(host: str) -> str: + host = host.strip().lower() + if host.startswith("["): + return host.split("]")[0] + "]" + return host.rsplit(":", 1)[0] if ":" in host else host + + +def is_same_origin(origin: str, host: str) -> bool: + """Origin 的 host:port 与请求 Host 一致(Docker 直接访问 :8000 或反代保留 Host 的情况)。""" + if not origin or not host: + return False + parsed = urlsplit(origin) + return bool(parsed.netloc) and parsed.netloc.lower() == host.strip().lower() + + +class LocalOriginGuard: + def __init__(self, app: ASGIApp, origins: Iterable[str], enforce_local_host: bool) -> None: + self.app = app + self.origins = {o.lower() for o in origins} + self.enforce_local_host = enforce_local_host + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] not in ("http", "websocket"): + await self.app(scope, receive, send) + return + + host = _header(scope, b"host") + if self.enforce_local_host and host and _hostname(host) not in LOCAL_HOSTNAMES: + await self._reject(scope, receive, send, "host_not_allowed") + return + + origin = _header(scope, b"origin").rstrip("/").lower() + method = scope.get("method", "GET").upper() + cross_site_write = scope["type"] == "http" and method not in SAFE_METHODS + if origin and (cross_site_write or scope["type"] == "websocket"): + forwarded_host = _header(scope, b"x-forwarded-host") + trusted = ( + origin in self.origins + or is_same_origin(origin, host) + or is_same_origin(origin, forwarded_host) + ) + if not trusted: + await self._reject(scope, receive, send, "origin_not_allowed") + return + + await self.app(scope, receive, send) + + async def _reject(self, scope: Scope, receive: Receive, send: Send, code: str) -> None: + if scope["type"] == "websocket": + await send({"type": "websocket.close", "code": 1008}) + return + response = JSONResponse( + status_code=403, + content={"detail": "请求来源不被允许", "error_code": code}, + ) + await response(scope, receive, send) diff --git a/backend/main.py b/backend/main.py index 65cb8ada..de314608 100644 --- a/backend/main.py +++ b/backend/main.py @@ -25,5 +25,8 @@ if __name__ == "__main__": logger.error(f"无效的端口号: {sys.argv[i + 1]}") port = 8000 - logger.info(f"启动服务器,端口: {port}") - uvicorn.run(app, host="0.0.0.0", port=port) \ No newline at end of file + # 默认只听本机;Docker / 局域网访问显式设 AUTOCLIP_HOST=0.0.0.0(Dockerfile 的 CMD 自带 --host) + import os + host = os.getenv("AUTOCLIP_HOST", "127.0.0.1") + logger.info(f"启动服务器,地址: {host}:{port}") + uvicorn.run(app, host=host, port=port) \ No newline at end of file diff --git a/backend/tests/test_local_origin_guard.py b/backend/tests/test_local_origin_guard.py new file mode 100644 index 00000000..5ec959ed --- /dev/null +++ b/backend/tests/test_local_origin_guard.py @@ -0,0 +1,77 @@ +"""本地后端来源守卫:其他网页不能读 Key、不能发写请求,自家前端和 CLI 不受影响。""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from backend.core.local_origin_guard import LocalOriginGuard, allowed_origins + + +def make_client(enforce_local_host: bool, base_url: str = "http://127.0.0.1:8000") -> TestClient: + app = FastAPI() + origins = allowed_origins() + app.add_middleware(LocalOriginGuard, origins=origins, enforce_local_host=enforce_local_host) + app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True, + allow_methods=["*"], allow_headers=["*"]) + + @app.get("/settings") + def read(): + return {"key": "sk-secret"} + + @app.post("/import") + def write(): + return {"ok": True} + + return TestClient(app, base_url=base_url) + + +def test_foreign_page_cannot_read_response_via_cors(): + c = make_client(True) + r = c.get("/settings", headers={"Origin": "https://evil.example"}) + assert "access-control-allow-origin" not in r.headers + r = c.get("/settings", headers={"Origin": "tauri://localhost"}) + assert r.headers["access-control-allow-origin"] == "tauri://localhost" + + +def test_foreign_page_cannot_post_simple_request(): + c = make_client(True) + r = c.post("/import", data={"url": "x"}, headers={"Origin": "https://evil.example"}) + assert r.status_code == 403 + assert r.json()["error_code"] == "origin_not_allowed" + r = c.post("/import", headers={"Origin": "null"}) + assert r.status_code == 403 + + +def test_app_origins_and_non_browser_clients_allowed(): + c = make_client(True) + for origin in ("tauri://localhost", "http://tauri.localhost", "http://localhost:3000"): + assert c.post("/import", headers={"Origin": origin}).status_code == 200 + # CLI / curl / Tauri reqwest 不带 Origin + assert c.post("/import").status_code == 200 + + +def test_preflight_from_app_origin_succeeds(): + c = make_client(True) + r = c.options("/import", headers={"Origin": "tauri://localhost", "Access-Control-Request-Method": "POST"}) + assert r.status_code == 200 + + +def test_desktop_rejects_dns_rebinding_host(): + c = make_client(True, base_url="http://attacker.example:8000") + assert c.get("/settings").status_code == 403 + assert make_client(True, base_url="http://localhost:51234").get("/settings").status_code == 200 + + +def test_web_mode_same_origin_and_extra_origins(monkeypatch): + # Docker 从局域网 IP 直接访问 :8000 → 同源放行 + c = make_client(False, base_url="http://192.168.1.20:8000") + assert c.post("/import", headers={"Origin": "http://192.168.1.20:8000"}).status_code == 200 + # 前端在 :3000、后端在 :8000 的局域网部署 → 需要显式加入 + assert c.post("/import", headers={"Origin": "http://192.168.1.20:3000"}).status_code == 403 + monkeypatch.setenv("AUTOCLIP_ALLOWED_ORIGINS", "http://192.168.1.20:3000") + c = make_client(False, base_url="http://192.168.1.20:8000") + assert c.post("/import", headers={"Origin": "http://192.168.1.20:3000"}).status_code == 200 + + +def test_debug_routes_not_mounted_by_default(): + from backend.api.v1 import api_router + assert not any(getattr(r, "path", "").startswith("/debug") for r in api_router.routes) diff --git a/docker-compose.yml b/docker-compose.yml index 1b2d58de..7a79ba1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,7 @@ services: - API_GEMINI_API_KEY=${API_GEMINI_API_KEY:-} - API_SILICONFLOW_API_KEY=${API_SILICONFLOW_API_KEY:-} - UPLOAD_POST_API_KEY=${UPLOAD_POST_API_KEY:-} + - AUTOCLIP_ALLOWED_ORIGINS=${AUTOCLIP_ALLOWED_ORIGINS:-} - UPLOAD_POST_USER=${UPLOAD_POST_USER:-} - AUTOCLIP_YT_SUBTITLE_LANGS=${AUTOCLIP_YT_SUBTITLE_LANGS:-} depends_on: diff --git a/env.example b/env.example index 0159e739..f48ec3b2 100644 --- a/env.example +++ b/env.example @@ -67,3 +67,7 @@ LOG_FILE=backend.log # 环境配置 ENVIRONMENT=development DEBUG=true + +# 允许访问后端的前端来源(逗号分隔)。本机 localhost:3000 与桌面端已默认允许; +# 从局域网 IP / 自定义域名打开前端时加上,例如 http://192.168.1.20:3000 +AUTOCLIP_ALLOWED_ORIGINS=