diff --git a/docs/superpowers/plans/2026-10-19-shared-domain-routing.md b/docs/superpowers/plans/2026-10-19-shared-domain-routing.md index 85afc6d..799dc5e 100644 --- a/docs/superpowers/plans/2026-10-19-shared-domain-routing.md +++ b/docs/superpowers/plans/2026-10-19-shared-domain-routing.md @@ -15,6 +15,12 @@ - 本地路径模式验证技巧:路径前缀优先于 Host——`curl http://127.0.0.1:8018/t/jiancai/`(Host 是 IP 未知也没关系,路径解析兜住) - 单进程铁律、日志/内存上限铁律(本计划 Task 7 为其进程内面)不变 +**拼接安全性四防线(用户 2026-10-19 要求,实现与测试双重钉住):** +1. **后端 slug 只查表不拼输出**——`TENANT_SLUG_RE` 白名单(`[a-z0-9-]`)后仅作 dict lookup key;404/503 响应为静态常量串,任何用户输入(slug/路径段)都不进响应体 → 无服务端注入/反射面; +2. **前端 base 值白名单推导**——chat.html 引导脚本的 base 来自 `location.pathname.match(/^\/t\/[a-z0-9-]+\/$/)`(match 命中即字符集白名单),widget.js 的 basePath 来自自身 script.src 定位切分;两者**只做 URL 属性赋值**(`l.href`/`s.src`/`img.src`/fetch/WebSocket),**绝不进任何 innerHTML/字符串拼 HTML**; +3. **既有 innerHTML 纪律不破**——widget.js 现有插值全走 `esc()` 五字符转义(已核:panel/invite/toggle 三处);本计划新增代码**零 innerHTML**(静态测试断言钉住); +4. **嵌入代码生成侧域名校验**——`normalizeSharedDomain` 与域名形态生成前做主机名字符集校验(`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`,拒绝引号/尖括号/空格/协议),非法返回 null/空串不产出 `") # 引导块(首个内联 script) + assert "innerHTML" not in boot + def test_chatjs_端点全部走_BASE_PATH(): src = read("static/js/chat.js") assert '__BASE_PATH__' in src @@ -324,9 +363,12 @@ def test_chatjs_端点全部走_BASE_PATH(): document.head.appendChild(s); } css("/static/css/style.css"); - js("/static/js/theme.js"); - js("/static/js/chat.js"); document.addEventListener("DOMContentLoaded", function () { + /* js 装载放 DOMContentLoaded:head 动态插入的 async=false 脚本与 + 解析进度无形式化顺序保证,且 chat.js 顶层有同步 DOM 查询—— + DOM 就绪后装载与原 body 末尾形态等价且保序(theme 先于 chat) */ + js("/static/js/theme.js"); + js("/static/js/chat.js"); var img = document.querySelector(".avatar-img"); if (img) img.src = base + "/api/public/avatar"; }); @@ -353,8 +395,8 @@ def test_chatjs_端点全部走_BASE_PATH(): class TestClientIpXffLast: """spec 2026-10-19 §6:限流 IP 取 XFF 末段(反代追加语义,不可伪造)。""" - def test_末段入键(self, client, monkeypatch): - # 伪造首段轮换 + 固定真末段 → 连续失败 5 次后第 6 次 429(同键计数) + def test_末段入键_伪造首段轮换不绕过(self, client): + # 伪造首段轮换 + 固定真末段 → 同键计数,第 5 次失败后第 6 次 429 for i in range(5): client.post("/api/platform/auth/login", headers={"X-Forwarded-For": f"6.6.6.{i}, 1.2.3.4"}, @@ -364,6 +406,18 @@ class TestClientIpXffLast: json={"username": "admin", "password": "wrong"}) assert r.status_code == 429 + def test_末段不同即独立键_判别用例(self, client): + # 这是 RED 判别用例:现状(无 XFF 解析)所有请求同键(client.host + # 恒 testclient)→ 第二段也会 429;新行为下 8.8.8.8 是独立键 → 401 + for i in range(5): + client.post("/api/platform/auth/login", + headers={"X-Forwarded-For": f"6.6.6.{i}, 1.2.3.4"}, + json={"username": "admin", "password": "wrong"}) + r = client.post("/api/platform/auth/login", + headers={"X-Forwarded-For": "7.7.7.7, 8.8.8.8"}, + json={"username": "admin", "password": "wrong"}) + assert r.status_code == 401 # 独立键:未锁,密码错 → 401(现状此断言为 429 → RED) + def test_无XFF回退client_host(self, ...): ... def test_空串XFF回退(self, client): r = client.post(..., headers={"X-Forwarded-For": " "}, ...) @@ -412,21 +466,33 @@ class TestLimiterSweep: """spec 2026-10-19 §7:过期键周期清扫(日志/内存上限铁律)。""" def test_LoginLimiter_sweep_清过期保活跃(self): - limiter = LoginLimiter(window=900.0, max_fails=5) # 参数名照类定义 - limiter.record_fail("u", "1.1.1.1", now=1000.0) # 签名照实类 - limiter.record_fail("u", "2.2.2.2", now=1000.0 + 1000.0) # 活跃 - n = limiter.sweep(now=1000.0 + 900.0 + 1.0) + from app.platform.auth import LoginLimiter, login_limiter # 实例名照实文件 + limiter = LoginLimiter(window_seconds=900.0, max_fails=5) # 参数名照 __init__ 实参 + limiter.record_fail("u", "1.1.1.1") + limiter.record_fail("u", "2.2.2.2") + # 时间推进手法照 test_auth.py 既有 lockout 用例(:66-69):直接改 + # _fails 内时间戳(record_fail 不收 now 参数) + limiter._fails[("u", "1.1.1.1")] = [0.0] # 远古 → 待清 + n = limiter.sweep(now=time.time()) assert n == 1 and ("u", "1.1.1.1") not in limiter._fails + assert ("u", "2.2.2.2") in limiter._fails - def test_RateLimiter_sweep同款(self): ... + def test_engine_rate_limit_sweep同款(self): + # app.engine.services.rate_limit 是模块级 check() + _buckets(非类) + from app.engine.services import rate_limit + rate_limit._buckets[("a", "1.1.1.1", "")] = [0.0] + rate_limit._buckets[("b", "2.2.2.2", "")] = [time.time()] + n = rate_limit.sweep(now=time.time()) + assert n == 1 and ("a", "1.1.1.1", "") not in rate_limit._buckets def test_lifespan注册与取消(self): - # 用 TestClient with 生命周期后任务创建过/退出后 cancelled—— - # 用 monkeypatch 记录 create_task/cancel 调用即可(不真等 300s) + # monkeypatch asyncio.create_task 记录调用(返回真实 task 语义的 + # stub);TestClient with 生命周期 → 断言 sweep loop 任务创建过; + # 退出后断言 cancel 被调(不真等 300s——loop 里 sleep 在前) ... ``` -- [ ] **Step 2: RED** → **Step 3: 实现**——两个类各加 `sweep(self, now=None) -> int`(清"全部时间戳均过期"的键,返回清除数;实现读各自 `_fails`/`_buckets` 结构,时间窗常量复用类内既有值);main.py lifespan(:108 起,`yield` :199 前): +- [ ] **Step 2: RED** → **Step 3: 实现**——`LoginLimiter` 加 `sweep(self, now=None) -> int`(清"全部时间戳均过期"的键);engine `rate_limit.py` 加**模块级** `sweep(now=None) -> int`(同语义,清 `_buckets`);main.py lifespan(:108 起):`import asyncio` 后在 `yield`(:199)前注册任务、`yield` 后既有 finally/清理段里 `cancel()`: ```python # 铁律(2026-10-19)进程内面:登录/聊天限流器过期键周期清扫(§7) @@ -447,28 +513,32 @@ class TestLimiterSweep: ### Task 8: 共享域名端点 + 设置键 + 嵌入代码双形态 **Files:** -- Modify: `app/platform/routes/settings_routes.py` 或对应文件(新增 GET /api/platform/widget-domain——两种平台角色可读;实现读 settings_store 的 shared_widget_domain 值;返回 {"widget_domain": str | None})。路由挂载照既有 settings 路由文件的组织方式 -- Modify: `admin/src/api/platform.ts`(或对应 api 模块)加 `getWidgetDomain()` -- Modify: `admin/src/views/settings/platform/model.ts`(推荐键表加 `shared_widget_domain`:文本键、说明「共享挂件域名(嵌入代码 /t// 形态的基地址,不带 https:// 前缀)」) +- Modify: `app/platform/routes/config_routes.py`(settings_router 所在文件——新增 GET /api/platform/widget-domain:`platform_user` 依赖(两种角色可读),读 settings_store 快照的 `shared_widget_domain`,返回 `{"widget_domain": str | None}`;测试加在 tests/test_config_api.py 既有 SETTINGS 区(~:394)) +- Modify: `admin/src/api/settings.ts`(settings API 模块)加 `getWidgetDomain()` +- Modify: `admin/src/views/settings/platform/model.ts`(推荐键表加 `shared_widget_domain`:文本键、说明「共享挂件域名(嵌入代码 /t// 形态的基地址,不带 https:// 前缀)」——**注意 SETTING_FIELD_GROUPS 的组 id 联合类型需扩一个组**(如 'site'),表驱动渲染即吃上) - Modify: `admin/src/views/config/center/model.ts`(新增纯函数)+ `index.vue` 嵌入卡(形态切换 UI) - Test: `admin/src/views/config/center/__tests__/model.test.ts`(追加) - [ ] **Step 1: model.ts 纯函数 + 失败测试**: ```ts -/** 共享域名规范化:去协议/尾斜杠/转小写/去空白;空 → null */ +/** 共享域名规范化(安全防线 §4):去协议/尾斜杠/转小写/去空白后须过 + * 主机名字符集 ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ——拒绝引号/尖括号/空格等 + * (防配置侧把恶意字符注入生成的 - * shared 形态 = */ + * shared 形态 = + * 生成前域名须过同款主机名校验(tenantSlug 由创建侧 SLUG_RE 保证字符集); + * 不可用(缺域名/共享域名/校验不过)返回 '' */ export type EmbedForm = 'domain' | 'shared' export function buildEmbedCode(form: EmbedForm, opts: { domain: string; sharedDomain: string | null; tenantSlug: string; site: string | null -}): string // 不可用(缺域名/共享域名)返回 '' +}): string ``` -vitest:normalize 各形态、双形态代码串、缺料空串、data-site 可选。 +vitest:normalize 各形态 + **恶意字符样例**(`kefu.com" onload="x`、`