docs: spec 评审收口(XFF 取末段纠偏/raw_path 重算/清扫范围修正)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -30,17 +30,17 @@ Host 模式(不动):chat.xxx.com → registry.resolve_site(host)
|
||||
|
||||
优先级:**平台豁免 → 路径前缀解析 → Host 解析**。
|
||||
|
||||
1. **slug 形态校验先行**:前缀段须 `^[a-z0-9-]+$`(与租户创建 `SLUG_RE` 一致,tenant_routes.py:65 同款正则常量提取共用);不匹配(含 `..`、编码畸形、大小写不符)直接走 Host 解析(共享域名下即 404),**不做任何剥离**。
|
||||
2. **路径改写**:匹配 `/t/<slug>/` 或精确 `/t/<slug>`(后者无尾斜杠 → 不剥离,走 Host 解析 → 共享域名 404,语义记录在案);剥离时同步改写 `scope["path"]` 与 `scope["raw_path"]`(bytes,存在才改);http 与 websocket 两类 scope 同构处理。
|
||||
1. **slug 形态校验先行**:在**解码后的 `scope["path"]`** 上校验前缀段须 `^[a-z0-9-]+$`(与租户创建 `SLUG_RE` 一致,tenant_routes.py:65 同款正则常量提取共用);不匹配(含 `..`、大小写不符、含编码百分号的原始形态)直接走 Host 解析(共享域名下即 404),**不做任何剥离**——注意 `/t/%61uto/` 解码后虽成合法 slug,但原始形态含编码即不匹配校验(校验看 path 的原始段需由未编码字符构成:实现取 raw_path 对应段做 ASCII 直判,或等价地在 path 段上拒绝含 `%` 的输入——`%` 不在合法 slug 字符集内,解码后 slug 不可能含 `%`,故「解码后含 %」必为编码形态,拒绝)。
|
||||
2. **路径改写**:匹配 `/t/<slug>/` 或精确 `/t/<slug>`(后者无尾斜杠 → 不剥离,走 Host 解析 → 共享域名 404,语义记录在案);`scope["path"]` 剥前缀;`scope["raw_path"]` **按剥离后的解码 path 重新编码重算**(不按长度截断——编码形态下解码/原始长度不一致);http 与 websocket 两类 scope 同构处理。
|
||||
3. **orig_path 留痕**:原始 path 存 `scope["state"]["orig_path"]`(排障/日志对账用;uvicorn 访问日志显示剥离后路径为已知性状)。
|
||||
4. **解析与错误语义**:slug → `registry._by_slug` 查租户(缺停用判断与 resolve_site 同款:未知 slug → http 404 / ws close(1008);停用 → 503 暂停页 / ws close(1013));站点 = 租户**首个站点**为默认(`?site=` 覆盖链路既有),`state["site"]` 同样写入。
|
||||
4. **解析与错误语义**:slug 经 registry 公开方法 `resolve_by_slug`(见 §5;中间件不触私有 `_by_slug`)——未知 slug → http 404 / ws close(1008);停用 → 503 暂停页 / ws close(1013)(与 resolve_site 同款语义);站点 = 租户**首个站点**为默认(`?site=` 覆盖链路既有),`state["site"]` 同样写入。
|
||||
5. **平台豁免优先级在前**:`/health`、`/api/platform/`、`/admin` 不受影响;`/t/x/api/platform/...` 剥离后命中平台路由属 by design(认证守护,与未知 Host 下 admin 面可达同级)。
|
||||
6. 已知边界(记录不修):StaticFiles 目录 308 的 Location 不带前缀(挂件/页面只引用具体文件,不触发)。
|
||||
|
||||
## 4. 静态面前缀自推导
|
||||
|
||||
- **widget.js**:`var basePath = new URL(script.src).pathname` 剥掉尾部 `/static/widget.js...`(得 "" 或 `/t/<slug>`);brand / avatar(含 DEFAULT_AVATAR_PATH)/ WS 三类端点构造统一 `wsBase + basePath + 原路径`。Host 模式 basePath="" 零变化;`tests/test_widget_static.py` 风格静态断言钉住。
|
||||
- **chat.html**:新增 15 行内联引导脚本——按 `location.pathname` 推导 basePath(`/t/<slug>/` → `/t/<slug>`;`/` → ""),改写自身 `<link>/<script>/<img>` 的绝对引用前缀并定义 `window.__BASE_PATH__`;`static/js/chat.js` 的 brand fetch / avatar / WS 消费该变量(缺省 "" 兼容)。`test_chat_static.py` 同款守卫。
|
||||
- **widget.js**:`var basePath = new URL(script.src).pathname` 剥掉尾部 `/static/widget.js...`(得 "" 或 `/t/<slug>`);brand / avatar(含 DEFAULT_AVATAR_PATH)/ WS 三类端点构造统一 `wsBase + basePath + 原路径`。Host 模式 basePath="" 零变化;**新建** `tests/test_widget_static.py`(既有 test_placeholder_static / test_theme_static 同款 grep 式静态断言)钉住。
|
||||
- **chat.html**:新增 15 行内联引导脚本——按 `location.pathname` 推导 basePath(`/t/<slug>/` → `/t/<slug>`;`/` → ""),改写自身 `<link>/<script>/<img>` 的绝对引用前缀并定义 `window.__BASE_PATH__`;`static/js/chat.js` 的 brand fetch / avatar / WS 消费该变量(缺省 "" 兼容)。**新建** `tests/test_chat_static.py` 同款守卫。
|
||||
|
||||
## 5. 平台管理面
|
||||
|
||||
@@ -49,13 +49,19 @@ Host 模式(不动):chat.xxx.com → registry.resolve_site(host)
|
||||
- **平台设置**:推荐键新增 `shared_widget_domain`(共享挂件域名,运营填一次)。
|
||||
- **嵌入代码生成器**(配置中心品牌页):双形态——租户有域名 → 域名形态 + 共享域名形态(`shared_widget_domain` 已配置时)都给;无域名 → 仅共享形态;都无 → 引导文案(去平台设置配共享域名 / 去租户详情绑域名)。`data-site` 等参数两形态通用。
|
||||
|
||||
## 6. 登录限流 XFF(可信代理语义)
|
||||
## 6. 登录限流 XFF(可信代理语义——取**末段**)
|
||||
|
||||
与聊天面 `_server_ip`(XFF 服务端优先、取首段)同款提取:登录端点把 `request.headers.get("x-forwarded-for")` 首段作为限流 IP,无 XFF 回退 `request.client.host`。**只进限流键**(认证语义、日志、其他端点不动)。部署注记:直连形态(无反代)行为不变;反代形态下 XFF 由我方 1Panel 反代附加,客户端伪造的 XFF 会被反代追加在尾部、首段仍为真实来源(OpenResty `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` 语义——部署模板已有则复用,没有则在模板补此头)。
|
||||
登录端点的限流 IP 提取:`X-Forwarded-For` **末段**(无 XFF 回退 `request.client.host`)。**只进限流键**(认证语义、日志、其他端点不动)。
|
||||
|
||||
为什么是末段而非首段(评审纠偏,安全关键):部署模板用 `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`——该语义为**客户端自带 XFF 保留在前、反代把真实来源 `$remote_addr` 追加在尾**。若取首段,攻击者每次请求换一个伪造首段即得全新限流键(零失败历史)→ 无限爆破,比现状更糟。取末段:单跳反代(客户端 → OpenResty → 应用,当前部署形态)下末段=反代亲见的真实 IP,**不可伪造**;将来若加 CDN(客户端 → CDN → OpenResty),末段退化为 CDN IP → 键退化为按用户名——即现状行为,故障安全。
|
||||
|
||||
**与引擎 `_server_ip`(取首段)的刻意不对称**:聊天面取首段是上游 ai-kefu 传承的"CDN 在前"拓扑假设,服务的是地理推测/数据质量(伪造无害);登录限流是安全控件,取末段。两者拓扑假设不同,**不许互相"统一"**——注释双向注记。
|
||||
|
||||
测试项相应为:末段入键 / 无 XFF 回退 client.host / 伪造首段不影响(换首段仍同键计数)/ 单段直连形态。
|
||||
|
||||
## 7. limiter 周期清扫(内存上限铁律合规)
|
||||
|
||||
- 登录限流器(platform/auth.py LoginLimiter)与聊天限流桶(engine/services/rate_limit.py `_buckets`)、WS 连接计数:注册**定期清扫**(lifespan 启动 asyncio 任务,每 300s 全量清过期键/空键;停用即取消)。实现走各模块自有数据结构私有方法,不新增共享抽象(YAGNI)。
|
||||
- 登录限流器(platform/auth.py LoginLimiter)与聊天限流桶(engine/services/rate_limit.py `_buckets`):注册**定期清扫**(lifespan 启动 asyncio 任务,每 300s 全量清过期键/空键;停用即取消)。实现走各模块自有数据结构私有方法,不新增共享抽象(YAGNI)。**WS 连接计数不列入**——ConnectionLimiter 的 release() 已自清零计数键,无时间戳结构可清扫(评审核实)。
|
||||
- 验收注记:compose 已有 `mem_limit: 768m` 与日志 `json-file 10m×3` 封顶(铁律的容器面已合规,本条补齐进程内面)。
|
||||
|
||||
## 8. 部署形态(本 spec 落地后)
|
||||
@@ -66,10 +72,10 @@ Host 模式(不动):chat.xxx.com → registry.resolve_site(host)
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
- 中间件:slug 形态校验(畸形/编码/大小写/无尾斜杠)、path+raw_path 双改写、orig_path、未知/停用语义(http+ws 双 scope)、Host 模式回归(既有 test_host_routing 全绿)、平台豁免优先级、`/t/x/static/…` 全链(chat 页/静态/public/chat REST/WS)。
|
||||
- 中间件:slug 形态校验(畸形/编码/大小写/无尾斜杠)、path 改写 + raw_path 重算、orig_path、未知/停用语义(http+ws 双 scope)、Host 模式回归(既有 test_host_routing 全绿)、平台豁免优先级、`/t/x/static/…` 全链(chat 页/静态/public/chat REST/WS)。
|
||||
- registry:resolve_by_slug(默认首站点/site 覆盖/停用/空站点表)。
|
||||
- 静态守卫:widget.js basePath 构造、chat.html 引导脚本、chat.js __BASE_PATH__ 消费(grep 式静态测试既有风格)。
|
||||
- 登录限流:XFF 首段入键/无 XFF 回退/伪造 XFF 追加形态(反代追加语义模拟)。
|
||||
- 静态守卫:widget.js basePath 构造、chat.html 引导脚本、chat.js __BASE_PATH__ 消费(新建 grep 式静态测试,既有风格)。
|
||||
- 登录限流:XFF **末段**入键/无 XFF 回退 client.host/伪造首段仍同键计数(防绕过)/单段直连形态。
|
||||
- 清扫:过期键回收、lifespan 启停。
|
||||
- 前端 vitest:嵌入代码生成器双形态 model 纯函数。
|
||||
- 基线:pytest 632+2skip 与 vitest 182 不回归,新增只增不减。
|
||||
|
||||
Reference in New Issue
Block a user