Files
pure-note/docs/review-round1.md
T

224 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 《Pure Note 设计方案 v1.0》审评报告(第 1 轮)
> 审评对象:docs/design.md(v1.0,2026-09-07)
> 审评日期:2026-09-07
> 审评方法:作者自查 + 两路独立交叉评审(安全攻防视角 / 工程可行性视角),关键争议点在线核实(Vite 官方文档、rehype-highlight README、@codemirror/view 与 style-mod 源码)。所有发现均已逐条对照原文验证。
---
## 一、总体结论
**方案骨架成立,选型与架构方向无需推翻;但按现稿直接开工会在 4 个 P0 问题上翻车**(2 个功能性硬伤、2 个私密内容泄露面),契约层还埋有 11 个 P1 雷。修订后可进入 M0。
| 级别 | 定义 | 数量 |
| --- | --- | --- |
| P0 | 按文档字面实现即失败或泄露私密内容,实现前必须修订 | **4** |
| P1 | 重要缺陷/缺口,不修会在对应模块返工或留下安全弱点 | **11** |
| P2 | 加固与工程化改进 | **9** |
---
## 二、发现汇总
| # | 级别 | 位置 | 问题一句话 |
| --- | --- | --- | --- |
| 1 | P0 | §8.3 | Vite `base: './'` 与 SPA 深链路由冲突,二级路径全部白屏 |
| 2 | P0 | §5 / §10.1 | go:embed 跨包引用不可实现,构建链缺 dist 同步步骤,静默嵌入陈旧前端 |
| 3 | P0 | §7.4 | 私有图片响应 `Cache-Control: public, immutable` 授权共享缓存存储私密内容 |
| 4 | P0 | §8.3 | index.html 元信息注入未做可见性过滤,私有笔记标题/摘要泄露给匿名爬虫 |
| 5 | P1 | §7.3 / §8.2 | CSRF token 生命周期断裂:刷新后无法再获取、轮换语义未定义 |
| 6 | P1 | §7.1 | 管理员预览私有笔记的数据通路不存在(公开详情 404,管理接口只有编辑态) |
| 7 | P1 | §7.1 / §6.1 | settings 接口无字段白名单:口令哈希可能被回显/覆写;且无改密流程 |
| 8 | P1 | §7.1 | `/api/tags` 未写公开过滤,私有笔记的标签成为信息泄露面 |
| 9 | P1 | §7.1 / §8.2 | slug 自动生成不满足「唯一」承诺(同日必冲突),定稿时机未定义 |
| 10 | P1 | §6.2 / §7.6 | gc 无宽限期:编辑中已上传未保存的图片会被物理删除 |
| 11 | P1 | §7.1 | 笔记硬删除无回收站,误删当天内容不可恢复 |
| 12 | P1 | §7.2 | JSON 接口无请求体大小限制、服务器无超时(匿名单请求 OOM / slowloris) |
| 13 | P1 | §10.4 | 升级回滚说法不严谨:旧二进制遇更高 user_version 会静默在陌生 schema 上读写 |
| 14 | P1 | §12 / §9.3 | 测试策略整体缺失,可见性/越权矩阵无自动化回归 |
| 15 | P1 | §10.3 | 备份文件是全量明文私密数据:权限、加密、恢复细节未设防 |
| 16 | P2 | §7.1 / §9.3 | 图片未授权应统一 404(防 ID 枚举),文档未明确 404 vs 403 |
| 17 | P2 | §9.2 / §10.2 | HSTS 在威胁矩阵里声称了,但安全头清单和 Caddyfile 都没有 |
| 18 | P2 | §9.2 | `style-src 'unsafe-inline'` 的理由一半错误;nonce 方案可达严格 CSP |
| 19 | P2 | §7.2 | `/api/auth/*` 不在 Origin 校验分支(login CSRF + 令牌桶 DoS 放大) |
| 20 | P2 | §13 | 「XFF 可信首跳」表述含混(首/尾跳取错则限流整体失效) |
| 21 | P2 | §7.3 | Argon2id 未规定 PHC 编码存储(参数升级将无法校验旧哈希) |
| 22 | P2 | §7.1 | 分页参数无边界约定 |
| 23 | P2 | §10.3 | cron 备份与 systemd 沙箱的关系未声明(结论:不冲突,但需写明前提) |
| 24 | P2 | §10 / §12 | 优雅停机、日志选型(slog)、开发热重载工作流三处缺口 |
---
## 三、P0 详细说明
### P0-1 Vite `base: './'` 与 SPA 深链冲突(§8.3)
**问题**:文档规定 `base: './'`(来自第一轮调研结论「绝对路径会 404」——**该论断不成立,本轮已用 Vite 官方文档推翻**)。相对 base 适用于「部署路径事先未知」或 file:// 嵌入场景;本项目部署在域名根,恰恰是反面用例。
**推导**:`base: './'` 时 index.html 资源引用为 `./assets/index-HASH.js`。浏览器深链访问 `/notes/my-post`(SPA fallback 返回 index.html,地址栏不变),按 RFC 3986 相对解析:`/notes/my-post` 的目录为 `/notes/`,故解析为 `/notes/assets/index-HASH.js` → 未命中 embed FS → fallback 返回 200 + `text/html` → module script 因 MIME 校验拒绝执行 → **React 永不启动,白屏**。
**波及范围**:`/notes/:slug`、`/tags/:tag`、`/admin/login`、`/admin/notes/:id/edit` 等所有二级路径的刷新、深链、外链分享。且 `vite dev` 与客户端导航均正常,**只在嵌套路由初始加载时爆发**,测试不充分必然漏网。
**修复**:
- `vite.config.ts` 使用默认 `base: '/'`;embed handler 从站点根提供 FS,绝对路径恰好正确命中;
- 删除文档中「必须 `base: './'`」及「绝对 /assets/ 会 404」的表述;
- CI 增加冒烟测试:真实构建 → 启动二进制 → 请求任一公开 slug 页面 → 断言 script/link 以 200 + 正确 MIME 加载。
### P0-2 go:embed 跨包引用不可实现(§5 / §8.3 / §10.1)
**问题**:构建产物输出在 `web/dist`,嵌入声明却在 `internal/webui` 包。`//go:embed` 只能嵌入本包目录树内的文件(不允许 `..`、不能跨包)。Makefile 的 `web` 目标止步于 `npm run build`,没有把 `web/dist` 同步进 `internal/webui/dist` 的步骤;M0 又要求「embed 空 dist」——占位文件先入库后,**编译永远不报错,二进制里嵌的是陈旧前端**,是典型「我明明改了前端」返工源。
**修复**(二选一,写入 §8.3):
- (a) Makefile 增加 `cp -r web/dist/ internal/webui/dist/`;`internal/webui/dist` 进 .gitignore,仓库保留占位文件供 M0 编译;
- (b) embed 上提到仓库根包:`//go:embed all:web/dist` + `fs.Sub`(无需拷贝,但根包与前端目录耦合)。
### P0-3 私有图片缓存头授权共享缓存存储(§7.4)
**问题**:文档对所有图片一刀切 `Cache-Control: public, max-age=31536000, immutable`。`public` 指令(RFC 9111)明确授权**共享缓存**存储该响应——即使是对带凭据请求的答复。
**攻击链**:管理员在 TLS 拦截代理环境浏览私有笔记 → 代理缓存 `GET /api/images/47` → 同网络任何人请求同 URL 直接命中缓存,**不回源、绕过鉴权与日志**;`images.id` 自增,`/api/images/1..N` 可批量收割。另有两条独立伤害:笔记 public→private 切换后,已缓存副本因 `immutable` 一年内不再回源,违反 F2「切换即时生效」;登出后磁盘缓存仍可离线回放。
**修复**:
- 按可见性分流:公开图 `public, max-age=31536000, immutable`;非公开图 `private, no-store`(或 `private, max-age=0, must-revalidate`,且再验证必须重新执行可见性判定而非仅比 ETag);
- `/api/admin/*` JSON 响应统一 `Cache-Control: no-store`(防登出后 bfcache/返回键回看私有列表);
- 文档显式承认残余边界:「图片一旦随公开笔记暴露过,已分发副本(浏览器缓存/爬虫/RSS 阅读器)无法撤回」。
### P0-4 元信息注入泄露私有笔记(§8.3)
**问题**:§8.3 要求「请求 /notes/{slug} 时注入该笔记的 title/description/og meta」,但**没有写可见性过滤条件**;T10 的「单一可信点」也没把这条路纳入。叠加「非 API 路径一律 200 fallback」与默认 slug `post-YYYYMMDD` 的可预测性:攻击者逐个请求候选 slug,即使 SPA 渲染 404,**HTML 源码里已带私有笔记的标题与 og:description**。
**修复**:
- 注入查询与公开详情接口走同一条 `status='public'`(或管理员会话)规则,明确写入文档;
- 非公开一律回退站点默认 meta;
- 注入用 `html/template` 自动转义(或占位符替换),防标题内容打断标签结构注入任意 meta。
---
## 四、P1 详细说明
### P1-5 CSRF token 生命周期断裂(§7.3 / §8.2)
只在登录响应下发一次、仅存内存,`/api/me` 只回 `{authenticated}`。浏览器 F5 后会话 cookie 仍有效但 CSRF token 丢失 → 全部变更请求 403,实现者最可能的「自救」是把 token 落 localStorage(降级)或放宽校验(更糟),两者都是文档逼出来的。「距过期 <3 天旋转 token」也未规定 csrf_token 是否随新会话行重建。
**修复**:`GET /api/me` 在已认证时返回 csrf_token(同源 JSON,跨域读不到);会话轮换时保持 csrf_token 不变或在轮换响应中同步下发;明确禁止持久化;补充 dev 方案(`__Host-` 要求 Secure,`http://局域网IP` 登不上——dev 放宽开关必须仅 loopback 监听时生效)。
### P1-6 管理员预览私有笔记的通路缺失(§7.1)
公开 `GET /api/notes/{slug}` 对私有一律 404,管理接口只有按数字 id 的编辑态数据——前端 Note 页没有任何能取到私有笔记正文的数据源,「博客皮肤预览私有笔记」功能不存在。
**修复**:详情接口可见性规则定义为 `status='public' OR 有效管理员会话`(响应带 `status` 字段,前端显示「私有预览」横幅);meta 注入同规则。
### P1-7 settings 无字段白名单,口令哈希面临回显与覆写(§7.1 / §6.1)
`admin_password_hash` 与站点配置混存 settings 表;`GET/PUT /api/admin/settings` 未约定 DTO。朴素实现 `SELECT *` 即把 Argon2id 哈希下发浏览器(任何 XSS 即外泄凭证验证器);PUT 若接受任意 key,可携管理员会话直接覆写哈希完成「免旧密码改密」。全文也没有 init 之外的改密流程。
**修复**:显式 SettingsDTO(仅 site_title/site_desc/page_size),读永不序列化哈希、写只收白名单键;新增 `POST /api/admin/password {old, new}`,复用登录限流。
### P1-8 `/api/tags` 未写公开过滤(§7.1 / §9.1)
T10 点名了 API/RSS/sitemap/图片,唯独漏了 tags;API 表里只写「标签聚合」。私有笔记的敏感标签(病情、辞职、项目名)可被 `/api/tags` 与候选词探测确认。
**修复**:tags 聚合 SQL 统一加 `status='public'`;标签页同源过滤;§9.3 检查单补 tags 断言。
### P1-9 slug 生成不满足「唯一」承诺(§7.1 / §8.2)
§7.1 承诺「自动生成唯一 slug」,§8.2 方案是日期式 `post-20260907`——同日第二篇必然 UNIQUE 冲突,而文档把解冲突推给用户(「给出明确错误」),自相矛盾。slug 何时定稿(新建即分配还是首次保存)、自动保存下改标题是否重生成,均未定义。
**修复**:自解冲突(追加 `-2`/`-3` 或 4 位随机尾缀);「slug 仅首次保存定稿,此后除非手改不变」;冲突返回 409 + 字段级错误。
### P1-10 gc 误删编辑中的图片(§6.2 / §7.6)
图片在粘贴瞬间即上传入库,笔记保存前是 0 引用孤儿;gc 策略只有「清理 0 引用」没有时间条件,调度也未定义——长文编辑期间跑一次 gc,刚粘贴的图片被物理删除,保存后满屏死图。另外没有任何列出孤儿图片的管理接口,「孤儿仅管理员可见」实际谁也看不见。
**修复**:gc 只删 `created_at < now − 7天` 且 0 引用的图片;默认 `--dry-run`、`--commit` 才执行;调度写明(systemd timer 每日);补 `GET /api/admin/images?orphan=1` 供检视。
### P1-11 硬删除无回收站(§7.1)
恢复路径只有「停服 + 整库回滚到 03:00 备份」:当天新建又误删的笔记不在任何备份里,永久丢失;即便有备份,恢复单篇要整库回滚,会连带丢其他笔记当天改动。同类产品惯例(Notion/Google Docs 回收站 30 天、Obsidian 移入系统回收站)——「个人笔记」恰是最不该裸删的数据。
**修复**:最低成本方案——DELETE 时把笔记 JSON(含 refs)写入 tombstone 表,gc 30 天后清除,管理端加「最近删除」恢复入口;或 `notes` 加 `deleted_at` 软删列 + 查询统一过滤。
### P1-12 请求体限制与服务器超时缺失(§7.2)
5MB 限制仅在上传接口。`POST /api/auth/login` 是匿名可打的 JSON 接口,无 `http.MaxBytesReader`,单次请求即可提交任意大 body(per-IP 限流挡不住「一个」大请求);`ReadHeaderTimeout/ReadTimeout/IdleTimeout` 未提及(slowloris 面)。
**修复**:路由级 `http.MaxBytesReader`(login/settings 4–64KB、notes JSON 1MB、multipart 6MB 含开销);`http.Server{ReadHeaderTimeout: 5s, ReadTimeout: 60s, IdleTimeout: 120s}`,写入 §7.2。
### P1-13 升级回滚不严谨(§10.4)
「回滚 = 换回旧二进制(迁移可逆或旧版本可忽略新表)」不成立:`user_version` 是单向整数,旧二进制看到更高版本**不会报错**,会静默在陌生 schema 上读写,把可恢复事故变成不可恢复。「向前兼容」一词也用反了。
**修复**:启动时校验 `user_version ≤ 代码支持的最高版本`,超出拒绝启动(留 `--allow-newer` 旗标);迁移策略声明为「仅追加式」(禁止删列/重命名/改类型);升级 SOP 第一步固定为 `pn backup`。
### P1-14 测试策略缺失(§12 / §9.3)
五个里程碑无一提及测试;§9.3 是一次性人工检查单。本项目安全性的核心不变量恰是可见性/越权矩阵——{public, private} × {匿名, 管理员} × {列表, 详情, 图片, RSS, sitemap, meta 注入, tags}——最适合表驱动自动化;后续任何重构(尤其 §13 预留的 v2 SSR 化)都可能无声破坏 T10。
**修复**:Go 集成测试(httptest + 临时目录真实 SQLite)把矩阵做成表驱动用例,另加迁移幂等、限流、CSRF、魔数校验各一组;纳入 CI,从 M2 起随功能交付。
### P1-15 备份文件敏感性与恢复 SOP(§10.3)
备份 = 全量私有笔记 + 图片 BLOB + 口令哈希 + 会话哈希的**明文副本**。crontab 若以 root 运行、umask 022,备份文件 0644,同机任意本地用户可读全部私密内容;rclone 异地同步也是明文。恢复步骤未要求删除残留 `-wal`/`-shm`(旧 WAL 会污染还原后的库);「备份保留 30 天」与「删除笔记」的数据语义关系未声明。
**修复**:备份文件 chmod 0600 / 专用低权用户;异地前强制加密(age / rclone crypt);恢复 SOP 补「删除 `pn.db-wal` 与 `-shm`」;声明备份保留与删除的关系。
---
## 五、P2 简述
| # | 修复建议 |
| --- | --- |
| 16 | 图片未授权与不存在统一返回 404(与笔记 slug 一致,防自增 ID 枚举);§9.3 改为明确断言 + 遍历测试用例 |
| 17 | `Strict-Transport-Security: max-age=31536000` 写入 SecurityHeaders 中间件(经反代透传有效)或 Caddyfile,消除 T11 与 §9.2 的不一致 |
| 18 | 已核实:rehype-highlight **不注入任何样式**(只加 class),§9.2 把它列为 `unsafe-inline` 理由是错的;CodeMirror 经 style-mod 注入 `<style>` 属实,但 `@codemirror/view` 提供 `EditorView.cspNonce`——每响应由 Go 生成 nonce 注入 CSP + 前端传入,即可删掉 `'unsafe-inline'`。v1 可保守保留,但须修正注释并规定 rehype-sanitize schema 永不放开 `style` 属性;公共路由(无编辑器)可先单独下发严格 CSP |
| 19 | 所有 POST(含 `/api/auth/*`)统一 Origin/Referer → Host 校验:防 login CSRF,也堵「跨站表单无门槛消耗账号令牌桶(5 次/10 分钟)把真管理员锁在门外」的 DoS 放大 |
| 20 | 明确「唯一可信代理是 Caddy,取 XFF **最右**一个条目」(或让 Caddy 覆盖而非追加 XFF);per-IP 桶设数量上限 + TTL 清理,防伪造 IP 撑爆内存 |
| 21 | Argon2id 存 PHC 串 `$argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>`,参数随哈希走,未来调参可校验旧哈希 |
| 22 | page_size 夹取 [1,100](默认 10),page 夹取 [1,10⁴],越界 400 |
| 23 | 写明「备份以独立进程运行,不受 unit 沙箱约束;/backup 属主与权限要求」;建议 cron 改 systemd timer(`OnCalendar=03:00, Persistent=true`),日志进 journal |
| 24 | 补三件事:SIGTERM → `http.Server.Shutdown` + SQLite 干净关闭;日志选型 `log/slog`(text/JSON 双模式,登录失败记 IP 与计数);`make dev` 一键起双端(`vite dev` 配 `server.proxy` 转发 `/api` 到 127.0.0.1:8080——同源 cookie/CSRF 才成立,Go 侧 air 热重载) |
---
## 六、争议点裁定(本轮在线核实)
| 争议 | 裁定 | 依据 |
| --- | --- | --- |
| Vite `base: './'` vs `'/'` | **`'/'`(默认)**。第一轮调研结论「绝对路径会 404」不成立:embed handler 从站点根提供 FS,绝对路径恰好命中;相对 base 在深链下解析到错误目录 | Vite 官方文档(Public Base Path:relative base 适用于「路径事先未知」与嵌入场景)+ RFC 3986 相对解析推导 |
| `style-src 'unsafe-inline'` 是否必需 | 对 CodeMirror 成立(style-mod 注入 `<style>`),对 rehype-highlight 不成立(纯 class);且 nonce 方案可去掉 `unsafe-inline` | rehype-highlight README;style-mod v4.1.3 README(`StyleModule.mount` 支持 nonce);@codemirror/view `EditorView.cspNonce` facet 源码 |
| cron 备份与 systemd 沙箱是否冲突 | **不冲突**(沙箱只约束服务进程,管不到 crontab 拉起的独立进程;`VACUUM INTO` 在 WAL 下与 serve 并发安全),但前提需写明 | systemd 语义 + SQLite WAL 并发模型 |
| sha256 去重 + image_refs 并集语义 | 规则自洽但需文档化:同一图片字节被公开笔记引用即整体公开(可见性 = 所有引用的**并集**);「曾公开即视为已分发」须作为已知边界写入 | 设计内审 |
---
## 七、设计得当之处(维持不动)
1. 可见性收敛为服务端查询层单一可信点(T10),前端标识仅作展示——全文最值钱的决策;
2. 图片 BLOB 入库 + 魔数白名单不含 SVG——同时消灭 WebShell 执行面与 SVG 存储型 XSS 两类风险,且无路径穿越面;
3. 会话库存 SHA-256 摘要而非明文 token,备份/DB 泄露不直接等价会话劫持;
4. 只存 Markdown 原文、渲染期双层清洗 + CSP 兜底,安全策略升级可回溯覆盖全部存量笔记;
5. 高亮选 class-based 的 rehype-highlight 方向正确(已核实零内联样式);
6. WAL + busy_timeout + `VACUUM INTO` 在线快照 + `SetMaxOpenConns(1)`,与个人规模严格匹配;
7. ADR 记录、被否决备选、版本在线核实的文档纪律。
---
## 八、v1.1 修订清单(对 design.md 的具体动作)
| 动作 | 涉及发现 |
| --- | --- |
| §8.3:`base` 改为默认 `'/'`,删除「必须 './'」及「绝对路径 404」论断;新增「构建冒烟测试」条目 | P0-1 |
| §5 / §10.1:确定 embed 机制(建议:Makefile 拷贝 dist → internal/webui/dist + .gitignore + 占位文件),Makefile 补拷贝步骤 | P0-2 |
| §7.4:图片缓存头按可见性分流;§7.2 或 §9.2:`/api/admin/*` 响应 `no-store`;§6.2 或 §9.1:写入并集可见性与「曾公开即已分发」边界 | P0-3、P1(语义) |
| §8.3:meta 注入可见性规则(public 或管理员会话,否则站点默认 meta)+ `html/template` 转义 | P0-4 |
| §7.1:`GET /api/me` 认证态返回 csrf_token;§7.3:轮换语义、禁 localStorage、dev cookie 方案 | P1-5 |
| §7.1:详情接口规则 `public OR 会话`,响应带 status;§8.1:「私有预览」横幅 | P1-6 |
| §7.1:SettingsDTO 白名单 + `POST /api/admin/password`;§6.1:PHC 编码 | P1-7、P2-21 |
| §7.1:`/api/tags` 明确 public 过滤;图片未授权统一 404 | P1-8、P2-16 |
| §7.1 / §8.2:slug 自解冲突算法 + 定稿规则 + 409 | P1-9 |
| §6.2 / §7.6:gc 宽限 7 天 + dry-run/commit + systemd timer + `GET /api/admin/images?orphan=1` | P1-10、P2-23(半) |
| §6 / §7.1:回收站(建议 tombstone 表,30 天,恢复入口) | P1-11 |
| §7.2:分级 MaxBytesReader + Server 超时;`/api/auth/*` 纳入 Origin 校验 | P1-12、P2-19 |
| §10.4:user_version 上界校验拒绝启动 + 仅追加式迁移 + 升级先备份 | P1-13 |
| 新增 §「测试策略」:可见性矩阵表驱动集成测试 + CI,纳入 M2–M4 验收 | P1-14 |
| §10.3:备份 0600 + 加密异地 + 恢复删 `-wal/-shm` + 保留语义;systemd timer 替代 cron | P1-15、P2-23 |
| §9.2:HSTS 补入;修正 `unsafe-inline` 注释,写明 nonce 升级路径与 sanitize schema 禁 `style` 属性 | P2-17、P2-18 |
| §13:XFF 改「最右一跳」+ 限流桶上限 | P2-20 |
| §7.1:分页边界 | P2-22 |
| §10 / §12:优雅停机、slog、`make dev`(vite proxy + air) | P2-24 |
---
## 九、结论
v1.0 的架构与选型判断经受住了对抗性审评(无一条要求推翻 ADR 级决策),但在「前端构建交付链」和「私密内容的缓存/元信息边界」上存在 4 个 P0 硬伤——其中 Vite base 一条源于第一轮调研结论本身的错误,属于**调研结论未复核即采纳**的流程教训。按第八节修订清单出 v1.1 后,方可进入 M0 开发。