docs: 计划评审收口(TDD 判别用例/实测锚点/签名对齐/拼接安全四防线)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-19 16:21:36 +08:00
parent 1c91870c76
commit 85627901d0
@@ -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 src>` 串(防配置侧注入嵌入代码)。
---
## Batch A:路由核心(后端)
@@ -88,7 +94,7 @@ TENANT_SLUG_RE = re.compile(r"^[a-z0-9-]+$")
- Modify: `app/platform/host_middleware.py`、`app/platform/routes/tenant_routes.py`(SLUG_RE 改引 registry)
- Test: `tests/test_host_routing.py`(追加;该文件既有夹具/Client 用法照抄)
- [ ] **Step 1: tenant_routes 的 SLUG_RE 换源**——`SLUG_RE = re.compile(r"^[a-z0-9-]+$")`(:65)改为 `from app.platform.registry import TENANT_SLUG_RE as SLUG_RE`(删本地定义;其余用法不动;grep `SLUG_RE` 确认只有本文件用)
- [ ] **Step 1: tenant_routes 的 SLUG_RE 换源**——`SLUG_RE = re.compile(r"^[a-z0-9-]+$")`(:65)改为 `from app.platform.registry import TENANT_SLUG_RE as SLUG_RE`(删本地定义与现在不再用的 `import re`——该文件 re 仅 :65 一处;grep `SLUG_RE` 确认只有本文件用)
- [ ] **Step 2: 写失败测试**——test_host_routing.py 追加(照既有用例的 app/Client 夹具;Host 一律用未知域名验证"路径优先于 Host"):
```python
@@ -117,16 +123,38 @@ class TestPathPrefixRouting:
... # 既有停用夹具同款
def test_无尾斜杠不剥离_走Host解析404(self, client):
# 注意只断言未知 Host 形态:/t/x 在 Host 模式下本就无路由(/ 才是聊天页),
# 若带已知 Host 断言 200 会诱导实现去剥离无斜杠形态——违反 spec §3.2
assert client.get("/t/jiancai", headers={"Host": "unknown.example"}).status_code == 404
assert client.get("/t/jiancai", headers={"Host": "chat2.agent.hnxinyouhe.com"}).status_code == 200 # 已知 Host 正常(Host 模式根路径)
# 已知 Host 的正常根路径(Host 模式对照,证明 404 不是 Host 解析坏了)
assert client.get("/", headers={"Host": "chat2.agent.hnxinyouhe.com"}).status_code == 200
def test_编码slug拒绝(self, client):
# %61uto 解码即 auto——raw 直判必须拒(spec §3.1)
assert client.get("/t/%61uto/", headers={"Host": "unknown.example"}).status_code == 404
def test_大写与点号拒绝(self, client):
# 注意不能用 /t/../:httpx 客户端侧归一点段,到不了中间件——用字面点号
assert client.get("/t/Jiancai/", headers={"Host": "unknown.example"}).status_code == 404
assert client.get("/t/../api/platform/auth/login", headers={"Host": "unknown.example"}).status_code == 404
assert client.get("/t/jian.cai/", headers={"Host": "unknown.example"}).status_code == 404
def test_site参数覆盖在路径模式生效(self, client):
# spec §5:?site= 覆盖链路既有——路径模式下同样生效
r = client.get("/t/jiancai/api/public/brand?site=es", headers={"Host": "unknown.example"})
assert r.status_code == 200 # jiancai 的 es 站点(空域名站点)brand 通路
def test_orig_path与路径改写直接钉位(self):
# 直接构造 ASGI scope 断言改写(不经 httpx 归一化):capture app 记录
# scope["path"]/raw_path/state["orig_path"]——spec §3.2/§3.3 的直证
captured = {}
async def capture_app(scope, receive, send):
captured.update(path=scope["path"], raw_path=scope.get("raw_path"),
orig=scope.get("state", {}).get("orig_path"))
await PlainTextResponse("ok")(scope, receive, send)
# 用真实 app 的中间件包 capture_app(照 test_host_routing 既有注入手法;
# 若无现成手法,直接实例化 TenantHostMiddleware(capture_app) 并注入
# scope["app"].state.registry 测试夹具)
...
def test_平台豁免优先级不受影响(self, client):
assert client.get("/health", headers={"Host": "unknown.example"}).status_code == 200
@@ -152,26 +180,31 @@ class TestPathPrefixRouting:
- [ ] **Step 3: 跑测试确认失败**
- [ ] **Step 4: 实现中间件**——host_middleware.py:
模块顶部 import 增加 `import urllib.parse`、`from app.platform.registry import TENANT_SLUG_RE`(并入既有 registry import 块);类内新增:
模块顶部 import 增加 `import re`、`import urllib.parse`、`from app.platform.registry import TENANT_SLUG_RE`(并入既有 registry import 块;registry.py 顶部同样需补 `import re`——该文件当前没有);类内新增:
```python
# 路径前缀形态(spec 2026-10-19 §3):raw_path 直判——slug 段必须
# 未编码(%61uto 解码后即 auto,解码形态判不出编码;raw bytes 上
# [a-z0-9-] 天然排除 % 与大写)。仅带尾斜杠形态剥离(/t/x 不剥)。
# 安全注记:slug 只作 registry 的 dict lookup key,绝不插入任何
# 输出(404/503 均静态响应)——无注入面。
_PREFIX_RE = re.compile(rb"^/t/([a-z0-9-]+)(/.*)?$", re.ASCII)
```
`__call__` 内,平台豁免 return 之后、`registry = ...` 之前插入:
`__call__` 内,平台豁免 return 之后插入(registry 取值**上提一次**供路径/Host 两分支共用——删除原 Host 分支里的重复 `getattr`):
```python
registry = getattr(scope["app"].state, "registry", None)
if registry is None:
raise RuntimeError(
"app.state.registry 未挂载——TenantHostMiddleware 需要 "
"lifespan(或测试注入缝)先加载 TenantRegistry")
# 路径前缀模式(spec 2026-10-19 §3):/t/<slug>/ 前缀优先于 Host 解析。
raw = scope.get("raw_path") or b""
m = self._PREFIX_RE.match(raw)
if m and m.group(2) is not None: # group(2) None = /t/x 无尾斜杠 → 不剥离
slug = m.group(1).decode("ascii")
registry = getattr(scope["app"].state, "registry", None)
if registry is None:
raise RuntimeError("app.state.registry 未挂载(见模块 docstring)")
try:
runtime, site = await registry.resolve_by_slug(slug)
except UnknownTenantError:
@@ -199,7 +232,7 @@ class TestPathPrefixRouting:
return
```
(其后原有 Host 解析逻辑不动;既有 `registry = getattr(...)` 行保留共用——路径分支提前取过一次,注意变量复用别重复声明报错,实现时合并为一次取值放豁免之后、两个分支之前。)
(其后原有 Host 解析逻辑不动——其 try 块起于 `try: runtime, site = await registry.resolve_site(...)`。)
- [ ] **Step 5: 跑 Task 2 测试 + 全文件 + test_host_routing 既有用例全绿**
- [ ] **Step 6: Commit**——`feat(middleware): /t/<slug>/ 路径前缀解析(与 Host 模式共存)`
@@ -288,7 +321,8 @@ def test_默认头像路径带前缀():
def test_引导脚本存在且定义_BASE_PATH():
src = read("static/chat.html")
assert "__BASE_PATH__" in src
assert "/t/" in src # 路径形态推导正则
# 代码锚(非注释):base 推导必须走正则白名单 match
assert "location.pathname.match(" in src
def test_不再有静态绝对引用标签():
src = read("static/chat.html")
@@ -296,6 +330,11 @@ def test_不再有静态绝对引用标签():
assert 'src="/static/js/' not in src
assert 'src="/api/public/avatar"' not in src
def test_引导脚本零innerHTML_拼接安全防线():
# 安全防线 §2/§3:base 只做 URL 属性赋值,绝不拼 HTML
boot = src_between(src, "<script>", "</script>") # 引导块(首个内联 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/<slug>/ 形态的基地址,不带 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/<slug>/ 形态的基地址,不带 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])?$ ——拒绝引号/尖括号/空格等
* (防配置侧把恶意字符注入生成的 <script src> 串);空或非法 → null */
export function normalizeSharedDomain(v: string): string | null
/** 嵌入代码双形态(spec §5):
* domain 形态 = <script src="https://<域名>/static/widget.js"[ data-site]></script>
* shared 形态 = <script src="https://<共享域名>/t/<slug>/static/widget.js"[ data-site]></script> */
* shared 形态 = <script src="https://<共享域名>/t/<slug>/static/widget.js"[ data-site]></script>
* 生成前域名须过同款主机名校验(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`、`<script>`、带空格 → null)、双形态代码串、缺料/非法域名空串、data-site 可选。
- [ ] **Step 2: RED → 实现 model.ts → GREEN**
- [ ] **Step 3: 后端端点**(小:读 settings_store 快照返回;测试加在既有 settings 测试文件——两种角色 200 / 未认证 401)
- [ ] **Step 4: index.vue 嵌入卡改造**——加载数据时请求 widget-domain;形态 radio(域名形态/共享域名形态——按可用性禁用);embedCode computed 换用 buildEmbedCode;无域名租户仅共享形态;都无显示引导文案(去平台设置配共享域名)。设置页推荐键渲染无需改(表驱动)
@@ -478,8 +548,7 @@ vitest:normalize 各形态、双形态代码串、缺料空串、data-site 可
### Task 9: 创建向导域名可选
**Files:**
- Modify: `admin/src/views/tenants/list/index.vue`(向导步骤②「绑定域名」改为可跳过——校验放宽、步骤文案「可留空 = 使用共享域名路径模式」;创建摘要显示「未绑定域名(路径模式)」)
- Test: 若向导校验逻辑在 model.ts(查 `admin/src/views/tenants/list/` 有无 model.ts/校验纯函数)则 vitest 追加「空域名列表合法」用例;纯模板改动则手动走查记录
- Modify: `admin/src/views/tenants/list/index.vue`(向导步骤②「绑定域名」改为可跳过——校验放宽、步骤文案「可留空 = 使用共享域名路径模式」;创建摘要显示「未绑定域名(路径模式)」。注:该目录**无 model.ts**,向导校验是 index.vue 内联逻辑——纯模板/校验放宽,无 vitest 新用例,走手动走查记录)
- [ ] **Step 1: 定位向导校验**(grep 域名/必填/required)→ 放宽 + 文案
- [ ] **Step 2: 走查**——dev server 建一个零域名租户 → 详情正常 → 配置中心品牌页出共享形态嵌入代码(依赖 Task 8 的 shared_widget_domain 已配)