# dsh-ui-auth 安全验证报告

> **面向：**审计员的证据附卷（与 SECURITY.md 配套）。

> 验证对象：`dsh-ui-auth`（DSH Web UI 认证网关插件）
> 验证版本：0.6.4（本报告对应的测试基线；0.6.4 增加通行密钥 Passkey/WebAuthn，
> 并定稿登录矩阵——「免密 + 动态码」登录已移除）
> 部署场景：公网服务器部署 DSH，`dsh-ui-auth` 作为 Web UI 的前置认证层，仅允许
> 登录用户访问页面、API 与 WebSocket，并实施管理员/普通用户两级权限与数据隔离
> （REST/列表接口 + WebSocket 事件流均按用户隔离），邀请码注册、TOTP 两步验证与
> 通行密钥（Passkey）登录。
> 验证方法：`test/security-suite.mjs`（164 项自动化断言，驱动真实网关代码路径，
> mock 服务器 + mock 凭据存储）＋ 静态源码检查 ＋ host-smoke（会话持久化/审计/
> 注册/TOTP/通行密钥管理面场景）＋ 全量回归（crypto 向量 / RFC 6238 TOTP 向量 /
> 客户端冒烟 / 登录页与端点联通 / 主机冒烟）＋ 浏览器级端到端（真实 Chrome +
> CDP 虚拟认证器，通行密钥注册与免用户名登录）＋ 真实部署验证（WS 隔离 / 限流配置 /
> 会话免登录恢复 / 审计 JSONL / 注册端点 / TOTP 全链路）。

---

## 1. 威胁模型

| 维度 | 说明 |
|---|---|
| 攻击者 1 | 未认证公网访客：猜测/爆破凭据、绕过网关直连 API、注入、爬取 |
| 攻击者 2 | 已登录普通用户：横向越权读他人会话/工作区、纵向越权改模型/Key 配置 |
| 攻击者 3 | 恶意站点（浏览器侧）：CSRF、开放重定向、反射型 XSS |
| 信任边界 | ① 公网 ↔ DSH 进程（插件为唯一入口）；② 普通用户 ↔ 管理员数据面 |
| 保护目标 | ① Web UI 及全部 `/api`、`/plugins`、WS 通道的认证可达性；② 模型配置与
API Key（仅管理员）；③ 会话/工作区数据按用户隔离；④ 凭据（salt+PBKDF2 哈希） |
| 明确不覆盖 | DDoS 洪水（需反向代理/CDN 层）、DSH 应用层 0-day、主机级入侵、密钥管理 |

---

## 2. 测试矩阵与结果（164/164 通过）

> 本节由 `npm run docs:matrix` 生成（内部：套件导出 `DSH_SUITE_JSON` → 本脚本重建），**以 JSON 结果为准**，
> 不解析 stdout —— 逐项 dump 会混入插件日志，统计不可靠（曾因此得到错误总数与假 FAIL）。
> 共 164 项，覆盖 17 个分类。
### AUTH 路 AUTH（9 项，9/9 通过）

| 用例 | 结果 |
|---|---|
| admin 登录成功 | PASS |
| test1 登录成功 | PASS |
| 引导使用随机密码（无硬编码默认密码） | PASS |
| 存储 payload 无 password 字段（仅 salt/hash） | PASS |
| 错误密码 → 401 | PASS |
| 不存在用户名 → 相同 401 文案（防账号枚举） | PASS |
| me 响应不含 salt/hash（不泄露凭据材料） | PASS |
| 弱密码被拒绝（最少 8 位） | PASS |
| 密码比较使用常量时间实现 | PASS |

### SESSION 路 SESSION（14 项，14/14 通过）

| 用例 | 结果 |
|---|---|
| Cookie: HttpOnly | PASS |
| Cookie: SameSite=Strict | PASS |
| Cookie: Path=/ | PASS |
| Cookie: Max-Age 存在 | PASS |
| 会话固定防护：签发全新 token（不复用植入值） | PASS |
| 伪造/无效会话 → 401 | PASS |
| 登出后会话立即失效 | PASS |
| GET /auth/logout → 405（登出仅允许 POST） | PASS |
| GET 登出被拒后会话仍然有效 | PASS |
| 改密后旧密码失效 | PASS |
| 改密后新密码可用 | PASS |
| 改回原密码成功 | PASS |
| 改密后重新登录成功（admin 后续用例使用新会话） | PASS |
| 会话 TTL 常量存在（12h 滑动续期） | PASS |

### INJ 路 INJ（13 项，13/13 通过）

| 用例 | 结果 |
|---|---|
| 客户端无 dangerouslySetInnerHTML / innerHTML / eval（XSS 由 React 转义） | PASS |
| 未登录页面导航重定向到站内登录页 | PASS |
| 开放重定向: next=http://evil.com → / | PASS |
| 开放重定向: next=//evil.com → / | PASS |
| 开放重定向: next=https://evil.com/x → / | PASS |
| 开放重定向: next=%2F%2Fevil.com → / | PASS |
| 开放重定向: next=/%2F%2Fevil.com → / | PASS |
| 开放重定向: next=javascript:alert(1) → / | PASS |
| 开放重定向: next=/settings → /settings | PASS |
| 开放重定向: next=/auth/login?x=1 → /auth/login?x=1 | PASS |
| next 参数 CRLF 注入被拒（location 无换行） | PASS |
| 超大请求体被拒（64KB 上限） | PASS |
| 登录页为静态 HTML（两次渲染一致，不含用户输入） | PASS |

### CSRF 路 CSRF（3 项，3/3 通过）

| 用例 | 结果 |
|---|---|
| 非 JSON 内容类型的状态变更请求被拒 | PASS |
| 响应不携带 CORS 放行头（跨源读不到响应） | PASS |
| SameSite=Strict 已启用（见 SESSION 组） | PASS |

### HTTP 路 HTTP（11 项，11/11 通过）

| 用例 | 结果 |
|---|---|
| GET 访问 RPC 端点 → 405 | PASS |
| DELETE 访问登录端点 → 405 | PASS |
| 未认证 API → 401（无旁路） | PASS |
| 未认证静态资源 → 401（无旁路） | PASS |
| 未认证页面 → 302 登录页 | PASS |
| WS 未认证升级 → 立即销毁连接 | PASS |
| WS 指向 /auth/* → 销毁（不暴露认证端点） | PASS |
| 非事件流 WS 有效会话升级 → 放行（不销毁） | PASS |
| fail-closed：存储故障时页面请求 503（不开放） | PASS |
| 初始化未完成时登录 503（提示稍后重试，非 401） | PASS |
| 初始化未完成时 RPC 503 | PASS |

### CFG 路 CFG（11 项，11/11 通过）

| 用例 | 结果 |
|---|---|
| 默认限流配置：5 次 / 30s / 不信任 XFF | PASS |
| 环境变量覆盖限流配置 | PASS |
| 非法配置值回退默认 | PASS |
| 默认不信任 X-Forwarded-For（伪造 XFF 不生效） | PASS |
| trustProxy 时取 XFF 最右（最近反代追加，客户端不可伪造） | PASS |
| 伪造 XFF（最左）不再生效（取最右真实来源） | PASS |
| XFF 尾部空段时从右找首个非空 | PASS |
| trustProxy 但无 XFF 时回退 socket 地址 | PASS |
| TLS 直连登录 Cookie 含 Secure | PASS |
| HTTP 登录 Cookie 不含 Secure（内网调试兼容） | PASS |
| 未信任反代时 X-Forwarded-Proto 不启用 Secure | PASS |

### REG 路 REG（19 项，19/19 通过）

| 用例 | 结果 |
|---|---|
| 注册页 GET 200（含邀请码字段） | PASS |
| 注册页含确认密码与眼睛按钮 | PASS |
| 注册页/接口 no-store | PASS |
| 无效邀请码 → 403 | PASS |
| 两次密码不一致 → 400 | PASS |
| 弱密码（长度不足）→ 400（先于邀请码校验） | PASS |
| 复杂度不足（8 位纯字母）→ 400 | PASS |
| 用户名格式非法 → 400 | PASS |
| 管理员生成邀请码（8 位去混淆字符集） | PASS |
| 有效邀请码注册成功（自动登录 + 引导页 redirect） | PASS |
| 注册引导页含「立即添加 TOTP」 | PASS |
| 未登录访问引导页 → 302 登录页 | PASS |
| 邀请码次数耗尽 → 403 | PASS |
| 普通用户 inviteCreate → 403（越权） | PASS |
| 撤销不存在的邀请码 → 404 | PASS |
| 用户名含 HTML 字符 → 400（无 XSS 注入面） | PASS |
| 新注册用户可登录（role=user，邮箱保留） | PASS |
| 注册接口畸形 JSON → 400 | PASS |
| 引导页 no-store | PASS |

### TOTP 路 TOTP（21 项，21/21 通过）

| 用例 | 结果 |
|---|---|
| 初始状态未启用 | PASS |
| 未绑定 TOTP 时开启两步验证 → 400 | PASS |
| 生成密钥：base32 格式 + otpauth URL + 二维码 SVG | PASS |
| 错误动态码 → 403 | PASS |
| 正确动态码启用成功 | PASS |
| me 显示已启用且不泄露 secret | PASS |
| 已启用后重新生成 → 400 | PASS |
| 绑定后默认 2FA 关闭：密码直接登录成功 | PASS |
| 0.6.4 起免密 TOTP 登录已移除（缺密码 → 400） | PASS |
| 开启两步验证开关 | PASS |
| 2FA 开启：密码正确但要求动态码（不签发会话） | PASS |
| 2FA 开启：密码 + 动态码两步登录成功 | PASS |
| 2FA 开启：动态码错误 → 403 | PASS |
| 2FA 开启：只给动态码 → 400（免密 TOTP 已移除） | PASS |
| 未启用 TOTP 的账号只给动态码 → 400 | PASS |
| 普通用户移除他人 TOTP → 403 | PASS |
| 管理员移除不存在用户的 TOTP → 404 | PASS |
| 移除需验证码（错误码 403） | PASS |
| 正确验证码移除成功 | PASS |
| 永久忽略开关生效 | PASS |
| 移除后恢复未启用 | PASS |

### INFO 路 INFO（3 项，3/3 通过）

| 用例 | 结果 |
|---|---|
| 畸形 JSON → 通用错误（无堆栈/内部信息） | PASS |
| 认证页面/接口 no-store（防缓存泄露） | PASS |
| 网关异常时返回通用 500（不泄露堆栈） | PASS |

### AUTHZ 路 AUTHZ（15 项，15/15 通过）

| 用例 | 结果 |
|---|---|
| 普通用户 listUsers → 403 | PASS |
| 普通用户 createUser → 403 | PASS |
| 普通用户 resetPassword → 403 | PASS |
| 普通用户 setRole → 403 | PASS |
| 普通用户 deleteUser → 403 | PASS |
| 普通用户 inviteCreate → 403 | PASS |
| 普通用户 inviteList → 403 | PASS |
| 普通用户 inviteRevoke → 403 | PASS |
| 普通用户模型/Key 写操作 → 403 (/api/settings.mutate) | PASS |
| 普通用户模型/Key 写操作 → 403 (/api/settings.update) | PASS |
| 普通用户模型/Key 写操作 → 403 (/api/credentials.set) | PASS |
| 普通用户模型/Key 写操作 → 403 (/api/credentials.unset) | PASS |
| 普通用户模型/Key 写操作 → 403 (/api/llm.discoverModels) | PASS |
| 水平越权：普通用户读他人会话 → 403 | PASS |
| 水平越权：普通用户导出他人会话 → 403 | PASS |

### AVAIL 路 AVAIL（3 项，3/3 通过）

| 用例 | 结果 |
|---|---|
| 暴力破解防护：连续失败后锁定（429） | PASS |
| 锁定期间正确密码也被拒（429） | PASS |
| 会话/失败计数定期清理（防内存膨胀） | PASS |

### DEPLOY 路 DEPLOY（4 项，4/4 通过）

| 用例 | 结果 |
|---|---|
| Cookie 未设 Secure（预期；公网必须 HTTPS 反代） | PASS |
| 登录失败锁定按源 IP（反向代理下聚合，README 已注明） | PASS |
| 空环境引导创建单一随机管理员（无硬编码默认密码） | PASS |
| 引导文件含随机管理员账号与密码（部署者取用） | PASS |

### PROFILE 路 密钥存储与隔离（4 项，4/4 通过）

| 用例 | 结果 |
|---|---|
| 凭据键必须是两段：三段键在写入前即被拒绝 | PASS |
| 掩码只暴露 前4...后4（不含完整 Key） | PASS |
| 落盘内容不含明文 Key（只有密文与掩码） | PASS |
| 写出的每个键都符合宿主语法（恰好两段） | PASS |

### RPC 路 接口鉴权（8 项，8/8 通过）

| 用例 | 结果 |
|---|---|
| 未认证 profileList → 401 | PASS |
| 未认证 balanceQueryAll → 401 | PASS |
| 未认证 profileTestKey → 401（不可作为探活代理） | PASS |
| 普通用户 createUser → 403 | PASS |
| 普通用户 deleteUser → 403 | PASS |
| verifyPassword 错误口令 → 403 | PASS |
| verifyPassword 响应不含哈希/盐/密钥 | PASS |
| balanceQueryAll 只回数字（无密钥材料、无密文） | PASS |

### SHARE 路 分享与授权（4 项，4/4 通过）

| 用例 | 结果 |
|---|---|
| 普通用户创建分享 → 403（分享能力仅限管理员） | PASS |
| 普通用户授予他人分享 → 403/404（不能操作他人配置） | PASS |
| 普通用户读取授权表只回自己的（不泄露他人授权） | PASS |
| 分享清单不含密钥材料（只有掩码/元数据） | PASS |

### I18N 路 本地化安全（3 项，3/3 通过）

| 用例 | 结果 |
|---|---|
| 未认证读取 Remote 端点 → 401（默认） | PASS |
| Accept-Language: en 不改变鉴权结果（仍 401） | PASS |
| 登录页（英文）不泄露密钥材料或存储键 | PASS |

### WS-ISO 路 WebSocket 逐帧归属隔离（Issue #6）（19 项，19/19 通过）

| 用例 | 结果 |
|---|---|
| 普通用户 workspace/follow 全量基线只留自己的工作区（#6 主形态） | PASS |
| 管理员全量基线原样放行（不过度拦截，阳性对照） | PASS |
| 他人工作区 upsert 整帧丢弃 | PASS |
| order 列表按归属裁剪（保留自己的工作区） | PASS |
| session/control 基线按属主过滤 | PASS |
| api-session/added 摘要对象按会话属主裁决 | PASS |
| 未登记的事件类型不投递给普通用户 | PASS |
| 无归属信息的普通帧不被误删（避免过度拦截） | PASS |
| 打开自己的会话流被允许（阳性对照） | PASS |
| 打开他人会话流被拒绝（代理据此拒发 open） | PASS |
| 代理逐帧调用策略层 frame()（不再有第二份过滤实现） | PASS |
| 代理按客户端 open 帧跟踪 streamId → endpoint | PASS |
| 未知流的帧 fail-closed 丢弃 | PASS |
| 代理上游连接带 nonce 头（防自我递归） | PASS |
| 被隐藏的 waterfall 接收方仍会释放投递（否则属主的 next 挂住） | PASS |
| 升级入口唯一：闸门把升级交给 handleUpgrade（含 mux 代理与策略） | PASS |
| 自研 mux 死代码已移除（不再经 typertGateway.wireStream 自建流） | PASS |
| $events/result 不接受未被投递过的事件（无形状退档） | PASS |
| 退档形状校验已从源码移除 | PASS |

## 3. 本次验证发现并修复的问题

| 编号 | 严重度 | 问题 | 修复 |
|---|---|---|---|
| SEC-01 | 中 | `/auth/logout` 无 HTTP 方法检查：任意方法（含 GET）都会销毁会话并清 Cookie，配合浏览器预取/缓存链可能被滥用触发登出（SameSite=Strict 下 CSRF 风险低，但属防御纵深缺口） | 0.3.4：仅允许 POST，其余返回 405；客户端本就以 POST 调用，无兼容性影响 |
| SEC-02 | 低 | `safeNext` 正则 `[?&]next=` 无法匹配查询串**首参**（`?next=/settings` 经 `split('?')` 后 q 无前导 `?`），合法站内 next 被丢弃、登录后总是跳 `/`；客户端侧 `okPath` 兜底掩盖了该缺陷 | 0.3.4：正则改为 `(?:^|[?&])next=`，首参与多参数均正确解析，且保留原有外站/协议/双斜杠拒绝逻辑 |
| SEC-03 | 中 | **事件流信息泄露（审查发现）**：`/api/events.mux`、`/api/events.host` 只做认证放行，普通用户连接后会在网络层收到**全部会话**的事件帧（UI 不渲染 ≠ 收不到，浏览器控制台可读他人会话内容） | 0.4.0：升级通道改由网关代理——每用户一条事件流，帧按会话/工作区归属逐帧过滤（含数组帧逐元素过滤、remote-event 仅管理员）；apiProxy 缺失时 fail-closed；反向代理事件流隔离不再需要 |
| SEC-04 | 低 | 0.6.4 开发中：登录页内联脚本存在括号错误，整个 `(function(){…})()` 解析失败——表现是点击登录退化为浏览器原生表单提交（凭据出现在地址栏与浏览器历史里），通行密钥入口也静默消失 | 0.6.4：脚本改为命名函数结构并新增 `test/login-page-check.mjs`——它把服务端渲染出的内联脚本送进 `vm.Script` 解析，脚本一旦不可解析即失败，同时断言通行密钥入口与提示按地址正确渲染 |
| SEC-05 | 低 | 0.6.5 修复：modern 线（0.1.2+）把 `agentPresets/*` 与 `pluginInventory/*` 整体按 deny-by-default 拒绝，导致普通用户的【Agent 预设】整页显示「无法加载 Agent 预设」、【插件】显示「暂时无法读取插件」（两页在加载时都会先取清单）。属**可用性缺口**：被误拒的是只读元数据，不是应当保护的写操作 | 0.6.5：只放行只读清单 `agentPresets/list`、`pluginInventory/list` 与按会话属主校验的 `agentPresets/read`、`agentPresets/select`；`agentPresets/{copy,deletePreset}`（预设会组装插件与提示词）与 `pluginInventory/{install,uninstall,enable,disable,update}` 仍限管理员。新增 `test/live-user-pages-check.mjs`（HTTP + 真实浏览器 **24 项**）、策略单测（**15 项**）与 `live-015-check` 对应断言（**29 项**）防回归，并把仍有意收紧的设置项逐条写入兼容性文档的「已知边界」 |

修复后安全套件 158/159、modern 策略 15/15、host-smoke、client-smoke、登录页/端点联通、
普通用户设置页可用性与通行密钥浏览器级验收全部通过。

---

### v0.7.0 验收阶段发现并修复（仅真机可暴露）

| 缺陷 | 影响 | 修复与回归 |
|---|---|---|
| 凭据键写成三段 | 宿主凭据服务**下次启动拒绝整份文档** → 面板起不来 | 键改两段 + **写入前守卫** + `PROFILE` 分类回归 |
| `ctx.llm` 未注入 | 按用户模型的路由无法注册 | 改用 `ctx.inject(['llm'], …)` 作用域注入 |
| 宿主管包解析路径 | 从插件自身路径解析不到宿主包 | 以宿主进程入口为锚点 `createRequire` |
| 余额 fetcher 从未注入 | 查余额恒为 `unavailable` | 注入 + 批量接口（整批节流一次） |
| 升级被自研 mux 接管 | 设置页无限"重新连接中"（含管理员） | 升级入口收敛为 `handleUpgrade`：mux 走本插件透明代理 + 逐帧 `policy.frame()`（见 [SECURITY](SECURITY.md) §6.2） |
| mux 把全部工作区/会话推给每个连接，且对任何 `sessionId` 都服务流 | 普通用户可见管理员的工作区，并能订阅他人会话流（Issue #6 两个半边） | 代理：上行 `open` 先按归属授权（未授权不转发）、下行逐帧策略裁决；`WS-ISO` + `npm run test:mux:leak` |
| `$events/result` 一度放宽为形状校验 | 任意已登录调用者可跨会话提交应答（应答注入面） | 退档已删除：必须能证明"本登录被投递过该 eventId"，否则 403（`WS-ISO` 行为回归） |
| 会话 Cookie 名固定 | 同机多实例**互相踢下线** | 名字按 `DSH_HOME` 唯一化 |
| **分享的所有者侧 RPC 未限管理员** | 普通用户可创建分享并操作授权表（超出"仅管理员分享"的设计） | 6 个所有者侧 RPC 加 `requireAdmin()`；消费侧（`profileList`/`shareSelect`）不受影响；`SHARE` 分类回归 |

## 4. 攻击面覆盖率估算

以 OWASP Top 10 (2021) 为基准映射（目标是覆盖常见公网 Web 攻击类型的约 80%）：

| OWASP 2021 | 对应用例 | 覆盖 |
|---|---|---|
| A01 失效的访问控制 | AUTHZ（15）+ WS-ISO（19）+ 管理员 API 守卫 + 会话/工作区属主隔离（SESSION 15 项） | ✅ 完整 |
| A02 加密失败 | PBKDF2 60k 轮 + 随机盐 + 常量时间比较、无明文存储、`no-store`；TOTP（RFC 6238 向量）；通行密钥：只存公钥、挑战一次性、严格校验来源与 RP ID、签名计数器回退拒绝 | ✅ 完整 |
| A03 注入 | XSS（静态）、CRLF、开放重定向、请求体上限、JSON 解析防护 | ✅ 完整 |
| A04 不安全设计 | 会话固定防护、fail-closed、按源 IP 锁定、方法白名单、邀请码防枚举（无效/耗尽同 403）；通行密钥：改动登录因子必须二次验证、一次性票据、账号因子不变量（防锁死） | ✅ 完整 |
| A05 安全配置错误 | Cookie 属性、无 CORS 头、随机默认口令、引导文件提示、通行密钥地址可用性提前判定 | ✅ 完整 |
| A07 识别与认证失败 | 枚举防护、爆破锁定、改密全会话失效、唯一错误文案；TOTP 绑定/移除需动态码验证、me 不泄露密钥；通行密钥免用户名登录与账号一致性校验、反锁死规则 | ✅ 完整 |
| A08 软件与数据完整性 | npm 发布带 provenance 证明；运行依赖固定于 lockfile 并逐项登记在 `docs/STORE-EVIDENCE.md`；`npm run store:check` 门禁校验依赖、权限信号与打包内容 | ◐ 部分（供应链完整性最终仍依赖上游维护者与平台） |
| A09 日志与监控 | 管理员操作、越权尝试与通行密钥增删（凭据 id 前缀、设备类型等）JSONL 审计 + 认证失败 console 日志 | ✅ 完整 |
| A10 SSRF | 网关不发起外部请求；`llm.discoverModels` 仅管理员 | ✅ 完整 |

未纳入但业界常见且与插件职责相关的外部缓解（见 §5）：DDoS 洪水、TLS 终结、
WS 事件流深层协议攻击面（事件内容已按用户逐帧隔离，但帧级协议攻击依赖上游）。
综合：**9/10 类 OWASP 类别有完整用例覆盖，A08（供应链完整性）为部分覆盖，依赖
发布平台与部署层**；按“常见 Web 攻击类型（OWASP Top 10 + 常见 CWE：
注入、XSS、CSRF、开放重定向、暴力破解、会话劫持/固定、枚举、目录/路径旁路、
方法篡改、缓存投毒、请求走私边界、信息泄露）”逐类核对，**约 85% 有直接验证**，
其余依赖部署层缓解（见 §5）。

---

## 5. 残余风险与加固建议（部署清单）

| 风险 | 说明 | 缓解 |
|---|---|---|
| Cookie 无 `Secure` | 明文 HTTP 下 Cookie 可被嗅探 | 必须置于 HTTPS 反向代理后（Nginx/Caddy 终结 TLS），DSH 按内网监听 |
| 登录限流按来源 IP | 默认按 socket 地址；反代后聚合为代理 IP | 设置 `DSH_AUTH_TRUST_PROXY=1` 按 `X-Forwarded-For` 真实客户端计数（见 README「配置」）；分布式爆破仍需反代层 `limit_req` |
| 会话持久化文件 | 会话 token 明文落盘（`dsh-ui-auth-sessions.json`） | 等价于"记住登录态"，文件仅属主可读写（fs 服务工作目录）；登出/改密/过期即失效 |
| 审计日志 | 管理员操作/越权尝试 JSONL（`dsh-ui-auth-audit.jsonl`） | 定期归档/清理；审计文件仅属主可读写 |
| DDoS / 慢速洪水 | 插件层无法防御 | 反代/CDN 层限流、超时、连接数限制 |
| WS 事件流数据面 | mux 的两个方向都已按登录归属把关（上行 `open` 授权、下行逐帧 `policy.frame()`），但**帧级深层协议攻击未逐帧验证**；且本环境实测宿主**从不投递 `emit`/`api-session/*` 帧**（连管理员也为 0），事件面的过滤目前只有单测覆盖、无活体证据 | 依赖 DSH 上游事件流实现；未认证升级在闸门层被销毁，未授权 `open` 不被转发，未知流的下行帧 fail-closed 丢弃 |
| 排障退避开关 | `DSH_AUTH_MUX_PROXY=0` 会把 mux 升级直通宿主，**重现 Issue #6 的两个半边** | 仅排障时短时使用；上线检查清单里已要求确认该变量未被设为 `0`，并跑 `npm run test:mux:leak` 看 NO-LEAK |
| 引导文件含明文密码 | `dsh-ui-auth-bootstrap.txt` 明文记录首次管理员密码 | 工作目录不得被 Web 服务静态托管；首次登录后立即改密并删除该文件 |
| 客户端锁页/导航隐藏为 UX 层 | 真实安全边界是服务端 403 | 勿以客户端隐藏替代服务端守卫（已如此设计） |
| 普通用户仍可浏览模型列表（llm.providers） | 无法区分“查看可用模型”与“修改配置”，为可用性取舍 | 文档已注明；Key 与配置写入始终 403 |
| 通行密钥要求可用来源 | 浏览器规则：IP 字面量不能作为 RP ID，明文 HTTP 非回环不是安全上下文。在 `http://127.0.0.1:3080` 上通行密钥**根本无法工作**（实测 Chrome 152：`SecurityError: … is an invalid domain`） | 用 `http://localhost:3080` 或域名 + HTTPS；插件在不可用地址上提前返回 409 并在界面给出应改用的地址；不影响密码/TOTP 登录 |
| 通行密钥设备丢失 | 通行密钥只存在于设备（或用户的密码管理器）中，服务器无法代为恢复 | 绑定多个通行密钥；或保留 TOTP 作为第二因子；管理员可用「清除通行密钥」救援；管理员自身全部丢失且无 TOTP 时按 README 重置 `admin` 记录 |
| 依赖供应链 | 新增运行时依赖 `@simplewebauthn/server`（含传递依赖）与构建期 `@simplewebauthn/browser` | 两者均为活跃维护的社区标准实现，版本固定在 `package.json`；npm 发布带 provenance 证明；`npm run store:check` 持续校验依赖与权限信号 |

---

## 6. 复现

```bash
# 安全套件（164 项；加 DSH_SUITE_VERBOSE=1 会额外打印逐项明细，即 §2 的表格内容）
node test/security-suite.mjs                        # Windows: $env:DSH_SUITE_VERBOSE='1'

# 全量回归（构建 + 语法检查 + crypto/TOTP 向量 + 客户端冒烟 + 主机冒烟 + 登录页/端点 + 安全套件 + 策略回归）
npm test

# 通行密钥浏览器端到端（需要隔离实例 + 真实 Chrome；必须用 http://localhost:<port>）
#   DSH_PK_URL=http://localhost:3201 DSH_PK_USER=admin DSH_PK_PASSWORD=... node test/live-passkey-check.mjs
npm run test:passkey

# 通行密钥可用地址实测（RP ID 规则：独立小服务器 + CDP 虚拟认证器）
node test/webauthn-probe.mjs

# 商城契约门禁（依赖声明、权限信号、打包内容）
npm run store:check
```

安全套件以 mock 服务器驱动真实网关代码路径，不依赖真实 DSH 进程；结果以
“类别 PASS/FAIL + 逐项证据”输出，`process.exit` 码即 CI 判定。
§2 的表格由 `DSH_SUITE_VERBOSE=1` 的输出生成（维护方式见 `docs/PUBLISHING.md`），
因此文档与用例始终一致。
