# 《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 注入 `