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`、`