diff --git a/.doc/audit/2026-09-13-document-revision-duty-etag-overwrite.md b/.doc/audit/2026-09-13-document-revision-duty-etag-overwrite.md index 6bf7fbd3..e067a22d 100644 --- a/.doc/audit/2026-09-13-document-revision-duty-etag-overwrite.md +++ b/.doc/audit/2026-09-13-document-revision-duty-etag-overwrite.md @@ -25,7 +25,16 @@ severity: critical | 保存 | `handleSaveBlockEdit` 在 PATCH 前 `getRevisionState` 取最新 ETag | 打开时基线被丢弃;后写覆盖先写 | | E2E | UI-13b 在 A1 完整保存后才打开 A2 编辑器 | 不是真实竞争,无法钉住上述 ETag 缺陷 | -安全含义:职责伪造是**越权写**(viewer/reviewer 可改受控正文并送审)。ETag 刷新是**完整性破坏**(并发编辑丢失且无冲突提示)。 +独立审查补强(同一分支): + +| 层 | 问题 | 风险 | +|---|---|---| +| PATCH | `resolvePatchEtag` 在 controlled 缺基线时仍返回 `0:0` | 服务异常被当成合法 bootstrap,可能覆盖已有账本 | +| hydrate | revision-state 5xx/网络失败只写 undefined 仍返回可编辑 AST | 用户能改正文,保存再 PATCH `0:0` | +| 批注 | viewer 被 cast 为 reviewer/approver,`canAnnotate` 对 viewer 开放 | 查阅者可新增评审批注 | +| 职责 | `isLegacyLocalDoc = !ids`:服务端 duty id 为 0 仍走 name fallback | 同名用户被提权为编制人 | + +安全含义:职责伪造是**越权写**(viewer/reviewer 可改受控正文并送审)。ETag 刷新或 `0:0` 误用是**完整性破坏**。viewer 批注是**越权写元数据**。 ## 严重度 @@ -41,6 +50,7 @@ severity: critical - 代码(修复前):`internal/docgov/project_docs.go` summary 无职责列;`web/src/lib/deliverableSync.ts` `author: fallbackUser.name`;`web/src/App.tsx` 默认 `'author'` 与保存前 `getRevisionState` - 单测钉住:B/陌生人不成 author;stale hydrate ETag 用于 PATCH +- 补强单测:controlled 缺 ETag/5xx 不可保存或不可打开;freeform 可 `0:0`;viewer 不能批注;服务端 duty id=0 不按 name 提权 - 本轮不跑真实 E2E、不提交 evidence assets ## 开放问题 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index ccf4419a..e59721f7 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **DOCREV-RT**: [E2E UI-05b + 职责/ETag 产品修复](short-term/2026-09-13-document-revision-e2e-reject-routing.md)(`fix/document-revision-e2e-reject-routing`:权威职责 + 打开时基线 ETag;不重跑 E2E / 不操作容器;仍 `in_progress`) +1. **DOCREV-RT**: [E2E UI-05b + 职责/ETag 产品修复](short-term/2026-09-13-document-revision-e2e-reject-routing.md)(`fix/document-revision-e2e-reject-routing`:权威职责 + 打开时基线 ETag + fail-closed/viewer 补强;不重跑 E2E / 不操作容器;仍 `in_progress`) 2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) 3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term diff --git a/.doc/ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md b/.doc/ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md index 54ebf4cf..cfb56dd3 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md @@ -36,7 +36,8 @@ mutex: document-revision-runtime-ui 4. [x] 仅修 05a/05b/10 遗留弹窗对 UI-06 的确切遮挡 5. [x] 产品修复:权威职责 + viewer fail-closed + hydrate 基线 ETag 6. [x] UI-13b:A1 保存前 A2 编辑器已打开并填入文本;两窗口同一 r/etag -7. [ ] 占槽后真实 UI 重跑(本轮不做) +7. [x] 审查补强:controlled 缺 ETag fail-closed;viewer 只读批注;服务端零 id 不按 name 提权 +8. [ ] 占槽后真实 UI 重跑(本轮不做) ## 验收标准 @@ -46,6 +47,9 @@ mutex: document-revision-runtime-ui - [x] UI-13a/13b 结果分别记录;B 不作正文编辑者 - [x] B/陌生人不会变 author;A 仍 author(Go + 前端单测) - [x] stale hydrate ETag 用于 PATCH(不能用最新 ETag 掩盖冲突) +- [x] controlled 缺打开时 ETag 不得 PATCH 0:0;hydrate 5xx 进加载失败屏障 +- [x] viewer 无新增批注入口 +- [x] 服务端受控 duty id=0 不按 name 提权 - [x] web tests + `tsc`/build + `git diff --check` - [ ] 真实 UI-05b+ 后段及 UI-13a/13b PASS 后才可将本任务标 `done` diff --git a/.doc/remediation/2026-09-13-document-revision-duty-etag-overwrite.md b/.doc/remediation/2026-09-13-document-revision-duty-etag-overwrite.md index 8359c4e7..e578bd32 100644 --- a/.doc/remediation/2026-09-13-document-revision-duty-etag-overwrite.md +++ b/.doc/remediation/2026-09-13-document-revision-duty-etag-overwrite.md @@ -17,8 +17,11 @@ updated: 2026-09-13 1. 列表 summary 返回台账 author/reviewer/approver 的 id+name;前端保留权威字段,禁止用当前登录用户伪造 author。 2. `currentDocRole` 按权威 user id 解析;name 仅兼容旧本地文档;未知身份 fail-closed 为 `viewer`;viewer 无编辑/审核/批准动作。 -3. 受控正文 hydrate 同时取 revision-state ETag 写入 AST/缓存;人工 PATCH 只用该基线;成功后用响应 `state.etag` 更新;冲突保留编辑器与文本。无账本 bootstrap/freeform 仍可用 `0:0`。 -4. UI-13b:A1 保存前两窗口已加载相同 r/etag,且 A2 编辑器已打开并填入文本。 +3. 受控正文 hydrate 同时取 revision-state ETag 写入 AST/缓存;人工 PATCH 只用该基线;成功后用响应 `state.etag` 更新;冲突保留编辑器与文本。 +4. **受控 fail-closed**:缺打开时 ETag 禁止 PATCH `0:0`;hydrate 遇 revision-state 网络/5xx/NOT_FOUND/NEEDS_CONTENT 走正文加载失败屏障。仅 freeform 允许 `0:0`。 +5. viewer 只读批注:无右键/浮标新增入口,面板不可提交;author/reviewer/approver 语义不变。 +6. 服务端受控 / `dutiesAuthoritative` 一律按 id fail-closed;name 回退仅真正旧本地文档。duty id 为 0 不得当成本地旧文档。 +7. UI-13b:A1 保存前两窗口已加载相同 r/etag,且 A2 编辑器已打开并填入文本。 ## 分波次计划 @@ -28,6 +31,7 @@ updated: 2026-09-13 | Wave 2 | 前端 merge / `UserRole=viewer` / `resolveCurrentDocRole` | B/陌生人不成 author;viewer 无动作 | | Wave 3 | hydrate 基线 ETag + PATCH 不再 GET | stale ETag 单测;不能用最新 ETag 掩盖冲突 | | Wave 4 | UI-13b 真实竞争脚本 | A2 先开编辑器再 A1/A2 保存 | +| Wave 4b | ETag/viewer/零 id 补强 | controlled 缺基线不可保存;viewer 只读批注;id=0 不按 name 提权 | | Wave 5 | 占槽真实 UI(本轮不做) | UI-13a/13b 实战 PASS 后才可 done | ## 验收标准 @@ -36,6 +40,9 @@ updated: 2026-09-13 - [x] 前端 merge + `resolveCurrentDocRole` 单测 - [x] stale hydrate ETag 用于保存(纯函数 + hydrate 单测) - [x] UI-13b 脚本不降级(本轮不跑真实 E2E) +- [x] controlled 缺 hydrate ETag / 5xx 不可保存或打开;freeform 可 `0:0` +- [x] viewer 不能新增批注 +- [x] 服务端 duty id=0 不按 name 提权 - [ ] 真实 UI 重跑 PASS ## 相关链接