21 KiB
title, status, created, updated, owner, base_dev_sha, source_audit, source_backlog, task
| title | status | created | updated | owner | base_dev_sha | source_audit | source_backlog | task |
|---|---|---|---|---|---|---|---|---|
| T20 加固设计决策(W4) | proposed | 2026-09-22 | 2026-09-22 | Cursor | 364ed7f1a3 |
../audit/2026-09-20-system-triage-audit.md | ../ops/long-term/2026-09-19-audit-remainder.md | ../ops/short-term/2026-09-22-t20-design-items.md |
T20 加固设计决策(W4)
本文件是设计决策,不是实施计划全文。实现另开 short-term;本批不改业务代码。
低风险已实现项(F5/I8)交叉链接:t20-lowrisk 任务文(当前在 worktree.worktrees/t20-lowrisk-hardening-2026-09-22,合入dev后本相对路径生效)。本文不重复设计 F5/I8。
核对基线:feature/t20-design-items-2026-09-22 @ 364ed7f1。
总表
| ID | 推荐摘要 | 卡产品/鉴权/环境? | 建议优先级 |
|---|---|---|---|
| B1 | 默忽视 XFF;仅可信代理 CIDR 命中时解析 | 否(运维 CIDR) | P1 |
| B3 | 按 docID 分锁;hydrate/DB I/O 出全局锁 | 否 | P1(独立大改) |
| B4 | 主库池级 statement_timeout 分级 |
否 | P1 |
| B5 | 保留 FailChatTurn 补偿;补可观测;同事务可选 |
否 | P2 |
| B7 | drain 超时与 AGENT_TIMEOUT / compose grace 对齐 |
否(运维调参) | P1 |
| B11 | 生产禁 GET 传 message;保留 POST body |
否 | P1(小) |
| F1 | 所有窗口写路径统一 generation + Abort | 否 | P1 |
| F4 | 先隐藏死按钮;完整能力需产品选型 | 是(产品) | 决策后 |
| F7 | 先全局 401;HttpOnly cookie 独立 epic | 是(鉴权专项) | Cookie=P2 |
| F8 | poll/stats 去重 + generation;blob revoke | 否 | P1 |
| I1 | 非 root USER + mem/cpu limits | 部署需环境授权 | 设计可合 |
| I4 | 三环境统一 extensions_init.sql |
改卷需环境授权 | 设计可合 |
| I6 | systemd/cron 调备份脚本,RPO≤60m | 是(环境写) | 仅文档 |
B1 — XFF 可信代理
现状
internal/server/login_rate_limit.go 的 clientIP 无条件取 X-Forwarded-For 首跳,否则 RemoteAddr。攻击者可伪造 XFF,使登录限流桶分散,绕过爆破防护。
推荐做法
- 新增配置
TRUSTED_PROXY_CIDRS(逗号分隔 CIDR;空=不信任任何代理)。 - 仅当
RemoteAddr落入可信 CIDR 时解析转发头:- 取 XFF 链中最右非可信地址(或配置
TRUSTED_PROXY_HOPS=N剥掉右侧 N 跳)。 - 否则一律用
RemoteAddr(剥端口)。
- 取 XFF 链中最右非可信地址(或配置
- 可选:同样规则用于审计日志 IP;登录限流 key 继续
ip|username。 - 默认生产 compose 不填 CIDR(直连或仅 docker 内网时安全);有 nginx/traefik 时由运维填代理网段。
风险
- 配错 CIDR → 全站限流落到代理 IP,正当用户互锁。缓解:启动日志打印有效 CIDR;监控
login_rate_limited按 raw RemoteAddr。 - 多级代理跳数错误 → IP 仍可伪造。缓解:文档要求「只信直连上一跳」;优先 CIDR 白名单而非盲目
HOPS。
验收
- 单元:伪造 XFF + RemoteAddr∉CIDR → key 用 RemoteAddr;∈CIDR → 用解析后的客户端 IP。
- 负例:空 CIDR 时带任意 XFF 不得改变限流 key。
- 不要求浏览器 E2E。
是否卡产品/鉴权
否。运维配置即可。
B3 — docgov / docrevision 锁拆分
现状
docrevision.Service:进程级sync.Mutex;hydrateDocLocked在持锁时打 PG。docgov.GovernanceEngine:RWMutex;AcquireLease等路径在锁内getDoc/persistLease(含 DB)。
结果:单文档慢查询阻塞全部文档的修订/租约路径。
推荐做法(两阶段)
阶段 A(优先,行为不变)
- 锁外 hydrate:先在无锁或 doc 级锁下从 PG 加载到临时结构,再短临界区合并进内存 map(CAS / version 校验)。
- 按
doc_id分锁:map[string]*sync.Mutex+ 全局RWMutex仅保护「分锁表」本身;文档间并行。 - DB 写:短临界区准备参数 → 锁外执行 SQL → 短临界区提交内存;失败回滚内存镜像。
- 租约继续依赖既有 fencing token / TTL;不引入新分布式锁。
阶段 B(可选)
- 热路径逐步改为「PG 为 SSOT、内存为缓存」,减少 hydrate 频率;需单独容量评估,不与 A 绑死。
风险
- 分锁 + 锁外 I/O 易引入 TOCTOU;必须用 fencing / 乐观版本。
- 死锁:统一锁序
globalMeta → docID(docID 字典序若需多文档)。 - 回归面:
document_commit_concurrency_pg_test、lease 单测、修订 idempotent 路径。
验收
- 基准:两文档并行 hydrate/commit,全局锁持有时间不覆盖对方 DB RTT(可用 race + 计时断言或日志采样)。
- 既有 PG 并发提交 / lease busy 负例全绿。
go test -race覆盖 docrevision + docgov 包。
是否卡产品/鉴权
否。工程改造。
B4 — statement_timeout 分级
现状
- 主库:
bizdev.NewService用pgxpool.New(dsn),无statement_timeout/ MaxConns 调优。 - KB:
kb.OpenPool仅设MaxConns=8,仍无 timeout。
长查询可占满连接,拖垮健康检查与短 API。
推荐做法
- 抽出共享
db.OpenAppPool(ctx, dsn, PoolProfile)(或扩现有internal/db):- default:
statement_timeout=30s,MaxConns按环境(建议 dev 8 / test 16 / main 可配)。 - migrate:启动迁移会话显式抬高或
0(仅迁移连接)。 - long:PI/索引/报表等路径在事务内
SET LOCAL statement_timeout = '15min'(或绑定AGENT_TIMEOUT)。
- default:
- 禁止在业务 SQL 字符串里散落裸 timeout;分级只通过池默认 +
SET LOCAL。 - 超时映射为 typed 错误信封(503/504 或既有 code),不把 PG 原文回前端。
风险
- 误伤合法长事务(修订大文档、embedding 批)。缓解:long profile + 白名单调用点。
- 与上下文
context.WithTimeout双重取消;以更严者为准,日志区分来源。
验收
- 单元/集成:池
RuntimeParams含statement_timeout;人为pg_sleep超过默认 → 连接释放且 API 失败可控。 - 迁移路径仍能跑完整 goose 链。
- 健康检查不因无关慢查询长期饿死(抽样)。
是否卡产品/鉴权
否。
B5 — chat 消息与 turn 原子性
现状
chat_turns与chat_messages非同一 SQL 事务写入。- 补偿已存在:
session.sendMessageInternal在clientTurnID != ""时defer FailChatTurn;用户消息落库失败显式FailChatTurn;成功路径CompleteChatTurn(见internal/chat/session.go)。 AcquireChatTurn提供租约/幂等;过期 processing 可打 failed。
因此「完全无补偿」的审计表述已过时;剩余是窗口期孤儿与可观测性,不是从零设计补偿。
推荐做法
- 本阶段不强制同事务(改动面大,且消息与 turn 可能跨连接)。
- 加固现有补偿:
FailChatTurn/CompleteChatTurn错误打结构化日志 + 计数(勿静默_ =)。- 周期/启动扫描:
status=processing AND lease_expires_at < now()→ failed(已有单条路径,升为批量)。 - 可选:用户消息已写、turn 未绑定
user_message_id的孤儿告警。
- 可选后续(P2):同一
pgx.Tx内INSERT message+UPDATE turn;失败整回滚。仅在确认补偿指标仍噪声大时做。
风险
- 同事务可能拉长锁时间、与 stream 生命周期纠缠。
- 过度扫描压力;限制 batch size 与频率。
验收
- 文档/测试明确:persist 失败 ⇒ turn=failed;完成 ⇒ completed + assistant id。
- 注入 fail 点:用户消息成功后进程崩溃 → 租约过期后 turn 终态可查,可重放同
client_turn_id不双写助手(幂等语义保持)。 - 不把「缺同事务」单独列为发布 blocker。
是否卡产品/鉴权
否。
B7 — graceful shutdown vs AGENT_TIMEOUT
现状
cmd/server/main.go:Shutdown固定 5s。- Compose /
.env:AGENT_TIMEOUT默认 30m(示例可达 60m)。 - Compose 无
stop_grace_period→ Docker 默认 ~10s 后 SIGKILL。
结果:滚动重启时长任务被砍;in-flight SSE/PI 无法 drain。
推荐做法
- 引入
SHUTDOWN_DRAIN_TIMEOUT(默认min(AGENT_TIMEOUT, 35m)或显式 31m),main用其做Shutdowncontext。 - Shutdown 顺序:
- 停止接受新 HTTP(
http.Server.Shutdown); - 标记「拒新 Agent/taskrun」(若尚无 gate,加
atomic或复用 ready); - 等待 in-flight 结束或 drain 超时;
- 再关池 / worker。
- 停止接受新 HTTP(
- 三环境 compose:
stop_grace_period≥SHUTDOWN_DRAIN_TIMEOUT(建议 +30s 缓冲)。 - 不要求 drain 等满整个 AGENT_TIMEOUT 才退出运维窗口:可由运维设更短 drain,并接受超时强杀;但 5s 与 30m 的错配必须消除。
风险
- 过长 grace 拖慢部署。缓解:test/dev 可较短;main 对齐真实 Agent 上限。
- 任务被拒新后客户端重试风暴。缓解:503 + Retry-After;前端识别。
验收
- 配置单测:env 解析。
- 手册/注释:compose grace ≥ drain。
- 手工(授权环境):起长 sleep handler 或短 Agent,SIGTERM 后观察是否在 grace 内结束或明确超时日志(实施时做)。
是否卡产品/鉴权
否。运维调参。
B11 — GET→POST EventSource(消息体)
现状
handleChatStream:POST JSON 与 GET query(含message)并存,注释为 EventSource/调试。- 生产前端
web/src/api/chatStream.ts已用 POST + body,不依赖 EventSource GET。
GET 将用户正文写入 URL → 代理/访问日志泄露;长度受限。
推荐做法
- 生产路径:仅允许
POST /api/v1/chat/stream带 JSON body(维持现状前端)。 - GET:
- 首选:直接拒绝 GET(405),或仅当
APP_ENV=dev且CHAT_STREAM_GET_DEBUG=1允许,且禁止messagequery(只允许已有 run 订阅类参数,若有)。 - 过渡:GET 若带非空
message→ 400,文案指向 POST。
- 首选:直接拒绝 GET(405),或仅当
- 文档/OpenAPI 删除「EventSource 传消息」示例。
风险
- 外部脚本仍用 GET。缓解:变更说明;405 明确。
- 无。前端已 POST。
验收
- 测试:GET+
message→ 4xx;POST 同行仍 200 流式(现有测即可)。 rg 'EventSource|/chat/stream.*GET' web/无生产依赖。
是否卡产品/鉴权
否。
F1 — useChatWindow generation 所有权
现状
loadInitialWindow已用generationRef+AbortController。loadOlder/loadFullDay/ 锚点加载等未绑定同一 generation:切会话后晚到响应仍可能setDayStack/ 写saveSessionWindowCache。- 已有
useChatWindow.race.test.ts覆盖「主窗口」模式,未覆盖 loadOlder。
推荐做法
- 约定:凡会写
dayStack/ 缓存 / phase 的异步函数必须:- 入口捕获
const gen = generationRef.current(或++仅由 session effect 负责); fetch传AbortSignal;- resolve 后若
gen !== generationRef.current则 return,禁止 setState/写缓存。
- 入口捕获
sessionId变化时现有 effect 已abort+generation++;保持单一所有者(该 effect),子函数只读。- 扩展单测:切会话后 resolve 的 loadOlder 不得污染新会话缓存。
风险
- 漏改某一路径。缓解:checklist + 测试表驱动。
- abort 触发
AbortError日志噪音;吞掉或降级 debug。
验收
- 单测:A→B 切换后 A 的 loadOlder resolve 不改变 B 的 stack/cache。
- 手工:快速连点会话,历史不串台。
是否卡产品/鉴权
否。
F4 — adminResetPassword 产品选择
现状
- UI:
UsersSection硬编码临时口令ResetPass123,调用authClient.adminResetPassword。 - API 客户端:直接
throw new Error('请通过后端管理员接口重置密码')—— 死功能。 - 后端:有
POST /api/v1/auth/change-password(自助),无管理员重置端点;创建用户已支持初始口令 +must_change_password。
产品选项(择一)
| 选项 | 做法 | 安全 |
|---|---|---|
| A. 隐藏/禁用(推荐短期) | 去掉或 disable「重置密码」按钮;文案引导「停用 + 新建」或工单 | 无假成功 |
| B. 服务端生成临时口令 | POST /api/v1/admin/users/{id}/reset-password:服务端随机口令、哈希入库、must_change_password=true、审计;响应一次性明文仅回管理员 |
禁硬编码;禁日志打口令 |
| C. 仅发重置链 | 邮件/线下渠道发 token | 需通知系统;当前栈可能不具备 |
推荐:先 A(trivial、可立即做);产品确认后做 B(对齐创建用户语义)。禁止继续展示会失败却暗示成功的按钮;禁止客户端固定 ResetPass123。
风险
- B 的明文回传需 HTTPS + UI 一次性展示;截图泄露。
- 管理员重置自己账户的策略需产品定(禁止或二次确认)。
验收
- A:UI 无可用重置按钮或明确「未开通」;无硬编码口令字符串。
- B:API 鉴权 admin-only;负例非管理员 403;用户登录后强制改密;审计有 actor。
是否卡产品/鉴权
是(产品)。A 可不经产品;B/C 需产品签字。
F7 — HttpOnly cookie
现状
- 会话 token 在
localStorage(smdf_auth_session);请求头Authorization: Bearer。 - XSS 可窃 token;无统一「任意 API 401 → 清会话回登录」网关(多处各自处理)。
推荐做法(分阶段)
阶段 0(可先做,不改 cookie)
- 集中
apiFetch:401 →clearSession+ 跳转登录;避免半登录态。
阶段 1(独立鉴权 epic / ADR)
- 登录响应
Set-Cookie:HttpOnly; Secure; SameSite=Lax(跨站场景再评Strict/None)。 - 服务端读 cookie 建会话;逐步废弃 localStorage token(兼容双读一期)。
- 若 cookie 会话:CSRF 策略(SameSite + 写操作 CSRF token 或自定义头)。
- SSE/
fetchstream 带credentials: 'include'。
不在本设计批实现阶段 1。
风险
- CSRF、多标签登出、移动/非浏览器客户端。
- 与现有 Bearer 测试夹具大面积改动。
验收
- 阶段 0:任意受保护 API 401 后 UI 回登录且 storage 清空(单测/组件测)。
- 阶段 1:ADR + 安全负例(JS 读不到 cookie;无 token 的 XSS 载荷不能复用会话)。
是否卡产品/鉴权
是(鉴权专项)。阶段 0 否;阶段 1 需独立授权。
F8 — poll 去重 + App.tsx stats generation
现状
App.tsx:recoverableRunIds每 3sPromise.all(getTaskRun);依赖recoverableRunKey,但无 in-flight 合并——慢请求可重叠。- 同文件:
fetchChatCategories/fetchChatDates随activeSessionId触发,无 generation——快切会话可后写先到。 - 多处
URL.createObjectURL;部分路径有revokeObjectURL,需审计遗漏。
推荐做法
- Run 轮询:模块级或 hook 内
Map<runId, Promise>;同 runId 共用飞行中请求;终态入skipped后不再请求(已有 403/404 skip,扩展终态)。 - Stats:
statsGenRef;session 变更++;resolve 时比对再setCategoryStats/setDateStats。 - Blob:导出/预览统一
try/finally revoke;React 卸载时 revoke。 - 优先抽 hook(
useRecoverableRunPoll/useSessionChatStats),减轻App.tsx继续膨胀(与既有拆分方向一致)。
风险
- 去重导致状态稍旧一拍;可接受(3s 轮询)。
- 漏 revoke → 内存涨;用测试断言调用次数。
验收
- 单测:快切会话时旧 stats 不落地;并行 poll 同 runId 只 1 个 in-flight。
- 手工:多 streaming 卡片时网络面板无指数级 getTaskRun。
是否卡产品/鉴权
否。
I1 — 非 root + 资源限制
现状
deploy/app/Dockerfile:最终镜像以 root 跑/app/server;无USER。- Compose:无
mem_limit/cpus/deploy.resources。
推荐做法
- Dockerfile:创建
appuser(固定 uid/gid,如 10001);chown可写目录(DATA_DIR);USER appuser。 - 确认挂载卷权限:宿主机
data/*对 uid 可写;文档写明。 - Compose(dev/test/main 的 app + events):设
mem_limit/mem_reservation、cpus(数值按主机容量,main 更严);避免无上限 OOM 连坐。 - 保持
cap_drop: [ALL]等为后续可选项;本设计最低线是非 root + 内存上限。
风险
- 卷权限导致写入失败。缓解:先在隔离 compose 冒烟。
- 限制过紧 → PI 侧杀进程。缓解:分服务限额(runner 另算)。
验收
- 镜像
docker run内id非 root。 - 健康检查与一次写 data 冒烟 PASS。
- compose config 含 resources。
是否卡产品/鉴权
设计与 Dockerfile 改动不卡产品;部署到共享 test/main 需环境授权。
I4 — init mount 统一
现状
| 环境 | init 挂载 |
|---|---|
| dev | context_init.sql(扩展+大量业务 DDL) |
| test | extensions_init.sql(仅扩展) |
| main | goose/00001_baseline.sql |
| shared KB | kb_init.sql(保留,不在本项) |
全新卷行为三套不一致;dev 易与 goose 双源漂移。
推荐做法
- 业务库(dev/test/main):一律挂
deploy/postgres/extensions_init.sql→/docker-entrypoint-initdb.d/01-extensions.sql。 - 表结构只走 goose;废弃业务库对
context_init.sql/ baseline 文件作为入口(文件可留在 archive 或注释「勿挂载」)。 - 文档:空卷启动顺序 = Postgres init 扩展 → app 迁移。
- 已有数据卷不因改挂载自动重建;仅影响新卷。
风险
- 有人依赖 context_init「一键空库有表」而无 app 迁移。缓解:操作规范写明必须跑迁移。
- main 从 baseline 文件习惯变更;晋升/灾备手册更新。
验收
- 三环境 compose diff:业务 PG 的 init 路径相同。
- 新卷:仅扩展存在 → 起 app → goose 到当前 schema → health schema 一致。
是否卡产品/鉴权
设计否;重建卷/改共享环境需授权。
I6 — 备份调度(仅文档)
本项不实施。 在宿主机安装 systemd/cron 属于环境写,需单独授权。
现状
- 脚本:
scripts/backup-system-db.sh <dev|test|main> <dir>+ restore drill。 - 无仓库内定时器;RPO 依赖人工。
推荐做法(授权后由运维执行)
- 调度:每环境独立 timer(建议 main 每 60m;test 每 6h;dev 每日或关)。
- 单元示例(示意,勿把 secret 写入仓库):
# /etc/systemd/system/agent-system-db-backup-main.timer
[Timer]
OnCalendar=hourly
Persistent=true
# service ExecStart=
# /path/to/repo/scripts/backup-system-db.sh main /var/backups/agent-system/main
或 cron:0 * * * * 调同一脚本。
- 保留:本地保留 ≥24 份小时备份或 7 日;远端拷贝另策。
- 口令:
${HOME}/.secrets/backup-env或环境文件;禁止进 git。 - 验收(授权实施后):连续 2 个周期出现新 dump + manifest;
restore-system-db-drill在隔离目录 PASS;RPO 墙钟 ≤60m(main)。
风险
- 备份含 chat 明文 → 目录 ACL / 加密。
- 与运行库抢 IO:错峰或
nice/ionice。
是否卡产品/鉴权
是(环境授权)。本文只登记方案;实施另开 operations 任务。
明确不在本文范围
| ID | 说明 |
|---|---|
| F5 | ErrorBoundary 栏级 — W2 lowrisk 已实现;见该任务文 |
| I8 | testing.Short() 跳过 docker build — W2 lowrisk 已实现 |
| B2/B6/B8/B9/B10, F2/F3/F6, I2/I3/I5 | W2 低风险批 |
实施拆批建议(供后续认领)
| 批 | 项 | 备注 |
|---|---|---|
| S1 小改 | B11, F1, F8, F4-A | 可无环境写 |
| S2 配置/池 | B1, B4, B7 | 需回归 |
| S3 锁 | B3 | 独立 mutex |
| S4 镜像/compose | I1, I4 | 部署另授权 |
| S5 决策后 | F4-B, F7-1 | 产品/鉴权 |
| S6 运维 | I6 | 仅授权后 |
| 观测 | B5 | 可不阻塞 S1 |