From 386b5eb90a21364d650d0951d00ebe96016f59fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 13 Oct 2026 08:32:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20/api/platform=20=E8=B1=81=E5=85=8D?= =?UTF-8?q?=E7=A7=9F=E6=88=B7=E8=A7=A3=E6=9E=90=EF=BC=88=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=AE=88=E6=8A=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- app/platform/host_middleware.py | 25 ++++++++++++++++++++- tests/test_admin_mounting.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/test_admin_mounting.py diff --git a/app/platform/host_middleware.py b/app/platform/host_middleware.py index f3e0eee..87443bd 100644 --- a/app/platform/host_middleware.py +++ b/app/platform/host_middleware.py @@ -10,6 +10,8 @@ scope type,两条路都先过租户解析。 - scope 非 http/websocket(lifespan 等)原样放行; - path 精确等于 ``/health`` 豁免(部署健康检查 ``curl 127.0.0.1:8018`` /health,Host 是 IP:端口必然未知;精确匹配,``/healthxxx`` 不豁免); +- path 前缀 ``/api/platform/`` 豁免(Phase 2 Task 3,平台后台不依赖租户 + 域名解析;安全模型详见 ``__call__`` 内注释——豁免≠放行); - Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008); 租户停用 → http 503 中性"服务暂停"页(不暴露租户信息)/ ws close(1013); - 解析成功把 tenant/site 写进 ``scope["state"]``——Request/WebSocket.state @@ -73,7 +75,28 @@ class TenantHostMiddleware: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] not in ("http", "websocket"): return await self.app(scope, receive, send) - if scope.get("path") == "/health": + + # 平台级豁免(先于租户解析;判断在 scope type 分发之后,http 与 + # websocket 两类 scope 同构生效——当前 /api/platform 前缀没有 WS + # 端点,保持与 /health 同构以防将来有): + # - /health 精确匹配(见模块 docstring); + # - /api/platform/ 前缀(Phase 2 Task 3)。 + # + # 安全模型——豁免≠放行:本豁免只跳过"租户解析"这一层,不打开 + # 任何受保护端点:/api/platform/* 的认证由路由依赖守护 + # (platform_user = get_current_user + enforce_password_changed, + # 未带有效 token 一律 401/409)。未知 Host 下 admin API 可达是 + # **有意设计**:生产的 admin 域名与租户域名在反代层区分(见 + # spec §4 边界规则与部署文档),应用层不按 Host 收紧 admin 面。 + # 前缀精确到斜杠:startswith("/api/platform/"),/api/platform-evil + # 不豁免(仍走租户解析,测试 test_prefix_match_precise 钉住)。 + # + # 已知观察项(本任务仅注记不改实现):反代部署形态下登录限流键 + # 取 request.client.host 不解析 XFF——所有请求同源(反代 IP), + # 键退化为按用户名(fail-safe:只更严不会更松,单账号爆破面 + # 不变);可信代理头解析归部署批次统一处理。 + path = scope.get("path") or "" + if path == "/health" or path.startswith("/api/platform/"): return await self.app(scope, receive, send) registry = getattr(scope["app"].state, "registry", None) diff --git a/tests/test_admin_mounting.py b/tests/test_admin_mounting.py new file mode 100644 index 0000000..94c4f9a --- /dev/null +++ b/tests/test_admin_mounting.py @@ -0,0 +1,40 @@ +# Phase 2 Task 3: /api/platform/ 前缀豁免租户解析——豁免≠放行。 +# +# Host 中间件默认按 Host 头解析租户,未知 Host → 404;平台后台 +# /api/platform/* 不依赖租户域名(生产的 admin 域名与租户域名在反代层 +# 区分,spec §4 边界规则),故对前缀豁免。豁免≠放行:豁免只跳过"租户 +# 解析"这一层,受保护端点的认证由 platform_user 依赖守护(401)。 +# +# 中间件顺序守护(豁免改动后租户中间件仍须最外层)由 test_host_routing.py +# 的 test_tenant_middleware_is_outermost_user_middleware 覆盖 +# (user_middleware[0] 断言)——全量回归确认仍绿,此处不重复建。 + +NOPE_HOST = "nope.example.com" + + +def test_platform_api_bypasses_tenant_resolution(app_client): + # 未知 Host 下 POST /api/platform/auth/login(坏凭据即可)可达路由—— + # 不被中间件 404,预期 401(防枚举静态文案,auth_routes) + r = app_client.post( + "/api/platform/auth/login", + json={"username": "nope", "password": "bad"}, + headers={"host": NOPE_HOST}, + ) + assert r.status_code == 401 + + +def test_platform_prefix_not_open(app_client): + # 豁免≠放行:未知 Host 下无 token 打已存在的受保护端点(logout) + # → 401——认证守护在 platform_user 依赖,不在中间件 + r = app_client.post( + "/api/platform/auth/logout", headers={"host": NOPE_HOST}) + assert r.status_code == 401 + + +def test_prefix_match_precise(app_client): + # /api/platform-evil/ 仍走租户解析:豁免必须 startswith("/api/platform/") + # 精确到斜杠。未知 Host → 中间件(而非路由)404——text/plain 的 + # PlainTextResponse 区别于路由 404 的 JSON body + r = app_client.get("/api/platform-evil/x", headers={"host": NOPE_HOST}) + assert r.status_code == 404 + assert r.headers["content-type"].startswith("text/plain")