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

21 KiB
Raw Permalink Blame History

《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 开发。