From 7bf9320fe4bcc2c31b284f25ac1e6fe6112f6ce0 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Thu, 6 Aug 2026 15:56:06 +0800 Subject: [PATCH 1/2] feat(app): add a configurable CORS middleware so the browser can reach the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 没有这个中间件,浏览器会把前端的每一个请求都拦在预检那一步: OPTIONS 返回 405、响应无 access-control-* 头,而后端日志里连请求都看不到 ——现场极易被误判成后端挂了。 - 来源由 WINDUP_CORS_ORIGINS 覆盖,默认值含 4173(vite preview,本地看真实 生产构建走这个端口)、5173(dev server)、3000 - 预览域名走 WINDUP_CORS_ORIGIN_REGEX 显式配置,**默认不开**:这里同时开了 allow_credentials,写死一条平台通配正则等于把带凭证的跨域请求放行给该平台上 任意第三方应用,且显式配了 WINDUP_CORS_ORIGINS 也关不掉它 四条断言进 CI。已用摘掉中间件的控制样本验证:其中三条在没有中间件时确实失败 (configured origin 拿到 405、4173 无放行头、regex 不生效),第四条是护栏断言。 Refs #139 Co-Authored-By: Claude Opus 5 --- .../app/src/windup_app/bootstrap/app.py | 41 +++++++++++ backend/tests/test_cors.py | 72 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 backend/tests/test_cors.py diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 8d600137..a0658845 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -4,7 +4,10 @@ 是整个 web 服务的唯一装配点(composition root)。 """ +import os + from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from windup_app.web.api.agent import router as ai_router from windup_app.web.api.generation import router as generation_router @@ -12,9 +15,47 @@ from windup_app.web.api.workflow_run import router as workflow_run_router +def _cors_origins() -> list[str]: + """允许跨域的前端来源;逗号分隔的 ``WINDUP_CORS_ORIGINS`` 覆盖。 + + 不挂这个中间件的话,浏览器会把前端的**所有**请求拦在预检那一步 + (OPTIONS 返回 405、响应无 access-control-* 头),而且后端日志里连请求都看不到, + 很容易被误判成前端问题。默认值覆盖本地 dev server。 + """ + raw = os.getenv("WINDUP_CORS_ORIGINS", "").strip() + if raw: + return [o.strip() for o in raw.split(",") if o.strip()] + # 5173 = vite dev、4173 = vite preview(生产构建,本地看真实构建走这个)、3000 = 备用 + return ["http://localhost:5173", "http://127.0.0.1:5173", + "http://localhost:4173", "http://127.0.0.1:4173", + "http://localhost:3000", "http://127.0.0.1:3000"] + + +def _cors_origin_regex() -> str | None: + """预览域名的来源正则;由 ``WINDUP_CORS_ORIGIN_REGEX`` 提供,默认不开。 + + 这里**不写死** ``https://.*\\.vercel\\.app``:下面 ``allow_credentials=True``, + 那条正则等于把带凭证的跨域请求放行给整个 vercel.app 域下的任意第三方应用, + 而且显式配了 ``WINDUP_CORS_ORIGINS`` 也关不掉它。预览域名形态随部署环境变, + 所以交给部署方自己配,例如 ``https://<项目名>-[a-z0-9-]+\\.vercel\\.app`` + (starlette 用 ``fullmatch``,不必自己加 ``^$``)。 + """ + raw = os.getenv("WINDUP_CORS_ORIGIN_REGEX", "").strip() + return raw or None + + def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0") + app.add_middleware( + CORSMiddleware, + allow_origins=_cors_origins(), + allow_origin_regex=_cors_origin_regex(), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + # 业务路由 app.include_router(media_router) app.include_router(generation_router) diff --git a/backend/tests/test_cors.py b/backend/tests/test_cors.py new file mode 100644 index 00000000..bb331c4a --- /dev/null +++ b/backend/tests/test_cors.py @@ -0,0 +1,72 @@ +"""CORS 中间件:浏览器能不能连上后端,以及不放行陌生来源。 + +这类问题只在真实浏览器请求时才暴露——预检被拦时后端日志里连请求都看不到, +很容易被误判成后端挂了。所以在 CI 里各钉一颗钉子。 +""" + +from fastapi.testclient import TestClient + +from windup_app.bootstrap.app import create_app + +PREVIEW_ORIGIN = "https://windup-git-main-preview.example.app" + + +def _preflight(client: TestClient, origin: str): + return client.options( + "/media/upload", + headers={"Origin": origin, "Access-Control-Request-Method": "POST"}, + ) + + +def test_configured_origin_passes_preflight(monkeypatch): + monkeypatch.setenv("WINDUP_CORS_ORIGINS", "https://windup.example.com") + monkeypatch.delenv("WINDUP_CORS_ORIGIN_REGEX", raising=False) + client = TestClient(create_app()) + + resp = _preflight(client, "https://windup.example.com") + + assert resp.status_code == 200 + assert resp.headers["access-control-allow-origin"] == "https://windup.example.com" + + +def test_vite_preview_port_is_allowed_by_default(monkeypatch): + """本地看真实生产构建走的是 `vite preview` 的 **4173**,不是 dev 的 5173。 + + 默认值漏掉 4173 的话,前端每个请求都会被浏览器拦在预检, + 而后端日志里连请求都看不到 —— 极易误判成后端挂了。 + """ + monkeypatch.delenv("WINDUP_CORS_ORIGINS", raising=False) + monkeypatch.delenv("WINDUP_CORS_ORIGIN_REGEX", raising=False) + client = TestClient(create_app()) + + for origin in ("http://localhost:4173", "http://localhost:5173"): + resp = _preflight(client, origin) + assert resp.headers.get("access-control-allow-origin") == origin, origin + + +def test_unknown_origin_is_rejected_by_default(monkeypatch): + """默认不带任何平台通配 —— 后端开了 allow_credentials, + 通配一个托管平台的域等于把带凭证的跨域请求放行给平台上任意第三方应用。 + """ + monkeypatch.setenv("WINDUP_CORS_ORIGINS", "https://windup.example.com") + monkeypatch.delenv("WINDUP_CORS_ORIGIN_REGEX", raising=False) + client = TestClient(create_app()) + + resp = _preflight(client, "https://someone-elses-app.example.app") + + assert "access-control-allow-origin" not in resp.headers + + +def test_preview_regex_is_opt_in_and_scoped(monkeypatch): + """预览域名要放行就显式配正则,且只匹配自家项目的域名形态。""" + monkeypatch.setenv("WINDUP_CORS_ORIGINS", "https://windup.example.com") + monkeypatch.setenv( + "WINDUP_CORS_ORIGIN_REGEX", r"https://windup-[a-z0-9-]+\.example\.app" + ) + client = TestClient(create_app()) + + allowed = _preflight(client, PREVIEW_ORIGIN) + assert allowed.headers["access-control-allow-origin"] == PREVIEW_ORIGIN + + stranger = _preflight(client, "https://someone-elses-app.example.app") + assert "access-control-allow-origin" not in stranger.headers From ee763306de2277f2e2f0e0529964a9d2dbe032e1 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Thu, 6 Aug 2026 16:18:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor(app):=20CORS=20=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=85=A8=E6=94=BE=E8=A1=8C=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20CORS=20=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allow_origins=["*"] + allow_credentials=False;鉴权走 Authorization 头不走 cookie - 删 _cors_origin_regex(),收窄入口保留 WINDUP_CORS_ORIGINS - 删 backend/tests/test_cors.py Refs #139 Co-Authored-By: Claude Opus 5 --- .../app/src/windup_app/bootstrap/app.py | 31 ++------ backend/tests/test_cors.py | 72 ------------------- 2 files changed, 4 insertions(+), 99 deletions(-) delete mode 100644 backend/tests/test_cors.py diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index a0658845..542000cf 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -16,42 +16,19 @@ def _cors_origins() -> list[str]: - """允许跨域的前端来源;逗号分隔的 ``WINDUP_CORS_ORIGINS`` 覆盖。 - - 不挂这个中间件的话,浏览器会把前端的**所有**请求拦在预检那一步 - (OPTIONS 返回 405、响应无 access-control-* 头),而且后端日志里连请求都看不到, - 很容易被误判成前端问题。默认值覆盖本地 dev server。 - """ + """开发阶段全放行;部署时用 ``WINDUP_CORS_ORIGINS``(逗号分隔)收窄。""" raw = os.getenv("WINDUP_CORS_ORIGINS", "").strip() - if raw: - return [o.strip() for o in raw.split(",") if o.strip()] - # 5173 = vite dev、4173 = vite preview(生产构建,本地看真实构建走这个)、3000 = 备用 - return ["http://localhost:5173", "http://127.0.0.1:5173", - "http://localhost:4173", "http://127.0.0.1:4173", - "http://localhost:3000", "http://127.0.0.1:3000"] - - -def _cors_origin_regex() -> str | None: - """预览域名的来源正则;由 ``WINDUP_CORS_ORIGIN_REGEX`` 提供,默认不开。 - - 这里**不写死** ``https://.*\\.vercel\\.app``:下面 ``allow_credentials=True``, - 那条正则等于把带凭证的跨域请求放行给整个 vercel.app 域下的任意第三方应用, - 而且显式配了 ``WINDUP_CORS_ORIGINS`` 也关不掉它。预览域名形态随部署环境变, - 所以交给部署方自己配,例如 ``https://<项目名>-[a-z0-9-]+\\.vercel\\.app`` - (starlette 用 ``fullmatch``,不必自己加 ``^$``)。 - """ - raw = os.getenv("WINDUP_CORS_ORIGIN_REGEX", "").strip() - return raw or None + return [o.strip() for o in raw.split(",") if o.strip()] or ["*"] def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0") + # 鉴权走 Authorization 头不走 cookie,故关掉 credentials —— 这样 "*" 才合法。 app.add_middleware( CORSMiddleware, allow_origins=_cors_origins(), - allow_origin_regex=_cors_origin_regex(), - allow_credentials=True, + allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) diff --git a/backend/tests/test_cors.py b/backend/tests/test_cors.py deleted file mode 100644 index bb331c4a..00000000 --- a/backend/tests/test_cors.py +++ /dev/null @@ -1,72 +0,0 @@ -"""CORS 中间件:浏览器能不能连上后端,以及不放行陌生来源。 - -这类问题只在真实浏览器请求时才暴露——预检被拦时后端日志里连请求都看不到, -很容易被误判成后端挂了。所以在 CI 里各钉一颗钉子。 -""" - -from fastapi.testclient import TestClient - -from windup_app.bootstrap.app import create_app - -PREVIEW_ORIGIN = "https://windup-git-main-preview.example.app" - - -def _preflight(client: TestClient, origin: str): - return client.options( - "/media/upload", - headers={"Origin": origin, "Access-Control-Request-Method": "POST"}, - ) - - -def test_configured_origin_passes_preflight(monkeypatch): - monkeypatch.setenv("WINDUP_CORS_ORIGINS", "https://windup.example.com") - monkeypatch.delenv("WINDUP_CORS_ORIGIN_REGEX", raising=False) - client = TestClient(create_app()) - - resp = _preflight(client, "https://windup.example.com") - - assert resp.status_code == 200 - assert resp.headers["access-control-allow-origin"] == "https://windup.example.com" - - -def test_vite_preview_port_is_allowed_by_default(monkeypatch): - """本地看真实生产构建走的是 `vite preview` 的 **4173**,不是 dev 的 5173。 - - 默认值漏掉 4173 的话,前端每个请求都会被浏览器拦在预检, - 而后端日志里连请求都看不到 —— 极易误判成后端挂了。 - """ - monkeypatch.delenv("WINDUP_CORS_ORIGINS", raising=False) - monkeypatch.delenv("WINDUP_CORS_ORIGIN_REGEX", raising=False) - client = TestClient(create_app()) - - for origin in ("http://localhost:4173", "http://localhost:5173"): - resp = _preflight(client, origin) - assert resp.headers.get("access-control-allow-origin") == origin, origin - - -def test_unknown_origin_is_rejected_by_default(monkeypatch): - """默认不带任何平台通配 —— 后端开了 allow_credentials, - 通配一个托管平台的域等于把带凭证的跨域请求放行给平台上任意第三方应用。 - """ - monkeypatch.setenv("WINDUP_CORS_ORIGINS", "https://windup.example.com") - monkeypatch.delenv("WINDUP_CORS_ORIGIN_REGEX", raising=False) - client = TestClient(create_app()) - - resp = _preflight(client, "https://someone-elses-app.example.app") - - assert "access-control-allow-origin" not in resp.headers - - -def test_preview_regex_is_opt_in_and_scoped(monkeypatch): - """预览域名要放行就显式配正则,且只匹配自家项目的域名形态。""" - monkeypatch.setenv("WINDUP_CORS_ORIGINS", "https://windup.example.com") - monkeypatch.setenv( - "WINDUP_CORS_ORIGIN_REGEX", r"https://windup-[a-z0-9-]+\.example\.app" - ) - client = TestClient(create_app()) - - allowed = _preflight(client, PREVIEW_ORIGIN) - assert allowed.headers["access-control-allow-origin"] == PREVIEW_ORIGIN - - stranger = _preflight(client, "https://someone-elses-app.example.app") - assert "access-control-allow-origin" not in stranger.headers