- docs/design.md: 技术调研与设计方案,已按 review-round1.md 的 24 项 审评发现(P0x4/P1x11/P2x9)完整修订 - docs/review-round1.md: 第 1 轮审评报告(留档) - .gitignore 与 internal/webui/dist 占位
21 KiB
《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 第一步固定为 pure-note 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 补「删除 pure-note.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 并集语义 | 规则自洽但需文档化:同一图片字节被公开笔记引用即整体公开(可见性 = 所有引用的并集);「曾公开即视为已分发」须作为已知边界写入 | 设计内审 |
七、设计得当之处(维持不动)
- 可见性收敛为服务端查询层单一可信点(T10),前端标识仅作展示——全文最值钱的决策;
- 图片 BLOB 入库 + 魔数白名单不含 SVG——同时消灭 WebShell 执行面与 SVG 存储型 XSS 两类风险,且无路径穿越面;
- 会话库存 SHA-256 摘要而非明文 token,备份/DB 泄露不直接等价会话劫持;
- 只存 Markdown 原文、渲染期双层清洗 + CSP 兜底,安全策略升级可回溯覆盖全部存量笔记;
- 高亮选 class-based 的 rehype-highlight 方向正确(已核实零内联样式);
- WAL + busy_timeout +
VACUUM INTO在线快照 +SetMaxOpenConns(1),与个人规模严格匹配; - 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 开发。