| 加同款 ghost 按钮「origin ↗」,originUrl 为空时不渲染——空值时页面与画板一字不差 |
后端 · metrics 服务(新)
后端 · about 服务(新)
后端 · MCP 管理面
前端
配置与文档
测试
| # | 检查 | 命令 / 期望 | 结果 |
|---|---|---|---|
| 1 | 编译与测试 | dev.ps1 check / dev.ps1 test 全过 | ✅ 9 files / 147 tests passed(第 2 轮整改后) |
| 2 | 前端类型与生产构建 | npx tsc --noEmit、npx next build 无错 | ✅ 构建通过 |
| 3 | 打点端到端 | 浏览 /、/notes、/about 后 visits 有对应计数行 | ✅ 8 PV / 2 visitor / 3 路径 |
| 4 | 库中无原始 IP | SELECT * FROM visits 只有 32 位 hex 的 visitor | ✅ 无任何 IP 形状的值 |
| 5 | 原始 IP 不进 trace | metrics trace 的 request_payload 为 <redacted> | ✅ sensitive: true 生效 |
| 6 | 路径归一挡灌库 | 直发 /wp-admin.php、/notes/<不存在>、//evil.com/x 一律落 /* | ✅ 四次合并成一行 /* |
| 7 | query/hash 被丢掉 | 直发 /about?utm=spam 记成 /about | ✅ |
| 8 | 统计结果与打点一致 | 三个 tool 的 PV/UV/路径/UA 与库内逐项核对 | ✅ 见下方实测 |
| 9 | About 经 MCP 改后前端生效 | about_set 写入 → GET /about → 页面渲染 | ✅ 双链 + 5 条 buildPoints + 3 张仓库卡 + 4 段语言条 |
| 10 | About 部分更新不丢数据 | 只传 intro 时 repos / langBar / buildPoints 原样保留 | ✅ 单测覆盖 |
| 11 | 备案号占位 | ICP_BEIAN 未配置无底栏;配置后底部出现并链到工信部 | ✅ 两种情况都实测 |
| 12 | 前端零 About 硬编码 | demo-data.ts 里搜不到 buildPoints / repos / langBar / githubUser | ✅ |
| 13 | visitor 取值有界(审查整改后新增) | 伪造 XFF 首段 ×3 + 换 UA 串 ×4 + 同 /24 换主机位 ×5 = 12 次请求 | ✅ 合并成 1 行 hits=12 |
| 14 | 只配 originUrl 时链接可见(同上) | 清空其余字段后页面仍渲染 origin 按钮 | ✅ |
| 15 | 畸形外链入不了库(第 2 轮整改后新增) | 经 MCP 直发 https:// / http://?x / javascript:… | ✅ 三个全拒且未入库 |
[P1] visitor 的哈希输入含原始 UA,/api/t 可被用来无界撑库 — metrics/visitor.ts
采纳,判定属实且比 finding 描述的更宽。visitor 是 visits 主键的一部分,而 /t 无认证: 哈希输入里任何一个分量只要请求方能自由左右,他就能自由制造新行。审查者指出了原始 UA 串 这一条;复核时发现 IP 那条同样成立——clientIp 取的是 X-Forwarded-For 的第一段, 而 Caddy 的 reverse_proxy 是追加不是覆盖,所以第一段恰恰是请求方自己写的那个。 两条合起来:一个 curl 循环就能把 visits 撑到磁盘满。路径归一挡不住这个,它只管 path 那一维。
整改口径是让哈希的每个输入分量都有界,而不是加限流或行数上限(后者是新机制):
| 分量 | 整改前 | 整改后 | 上界 |
|---|---|---|---|
| day | 一天一值 | 不变 | 1 |
| ua | 原始 UA 串 | uaDigest 闭集 | ≤42 |
| ip | XFF 第一段的完整地址 | XFF 最后一段 → 收敛到网段(IPv4 /24 / IPv6 /48) | 真实网段数 |
| path | 已有界 | 不变 | 站内路径数 |
取最后一段是因为那一段由我们自己的反代写入;这条依赖「Caddy 前面没有别的代理」, 将来加 CDN / 云 LB 必须改成「从右往左跳过 N 层可信代理」,已写进代码注释与 docs/security.md §6。 IP 收敛到网段同时是隐私改进:一台机器手上常有一整个 IPv6 /64,逐个换地址几乎零成本, 收到 /48 之后再怎么换都是同一行。代价是同网段 + 同浏览器族的两位访客会被算成一个人 ——个人站量级下可接受的低估。
实测复现与验证(12 次请求,整改前会产生 12 行):
[P2] 只配了 originUrl 时头部整块不渲染,那条链接永远不出现 — about/page.tsx
采纳。about_set 的每个字段都可省略,「只有 originUrl 的库行」是合法状态,而外层守卫 写的是 (gh || about.intro)。改成 (gh || origin || about.intro)。 实测:清空 githubUser / intro / 三个数组、只留 originUrl 后,页面正确渲染 origin 按钮, 且不出现 GitHub 按钮与 404 头像。
apps/api/mcp/audit.ts 的 remoteOf 与 mcp/server.ts 的 remoteOfRequest 也取 XFF 第一段, 同样可被写入方伪造。影响有界(管理面只有一个使用者、认证不依赖这个头,它只是审计线索), 属跨轮次问题,按 CLAUDE.md 记 rounds/BACKLOG.md 不顺手改。
写 P1 整改的用例时抓到 ipNetwork 的第一版对 IPv6 直接 split(":") 取前三组是错的: fe80::1 会切成 ["fe80","","1"],把主机位当成了网段的一部分,于是 fe80::1 与 fe80::2 落进两个不同的桶——那正好复活了 P1 要消除的「一个 /64 里换地址就能造新行」。 已改成先展开 :: 再取前三组并做前导零归一,补了 4 条用例(压缩/未压缩必须同桶)。
[P3] originUrl 的前缀匹配放得过 https:// 与 http://?x — mcp/tools.ts
采纳。/^https?:\/\// 只看开头,https://(没有主机)与 http://?x 都能通过, 于是一个点不开的链接进得了库、也渲染得出来。前端 safeExternal 是同一份判据, 所以两道防线一起漏。
整改是换判据、不是加机制:交给 WHATWG 的 URL 解析,再要求 protocol ∈ {http:, https:} 且 hostname 非空。两个条件缺一不可 —— new URL("javascript:alert(1)") 是能解析成功的(protocol=javascript:、hostname 为空), 只判解析成功等于把原来防的 XSS 又放回来。服务端与前端两处同步改(web 与 api 不共享源码,规则 6)。
实测经 MCP 直发:https:// / http://?x / javascript:alert(1) 三个全部被拒且未入库, https://ok.example.com/x 正常写入。补 4 条单测(共 147 项通过)。
范围按 CLAUDE.md「第 3 轮起只审整改 diff」收窄为 --base 8112879(第 2 轮已审提交)。 审查结论原文:URL 校验现在正确地要求「可解析的 http(s) 地址 + 主机名」,前端在渲染前 应用同一份校验,新增用例覆盖了畸形与不安全输入。
同一验收项针对性整改后连续 2 次验证仍不过 → 写 rounds/round-08/BLOCKED.md,停下呼人。
1. 统计口径的核心取舍:visitor 按天轮换,所以「区间 UV」这个数不存在
访客标识是 sha256(salt ‖ day ‖ IP网段 ‖ UA摘要) 的前 128 bit(输入分量的收敛见「代码审查」第 1 轮 P1)。把 day 放进哈希输入,是为了 让「库整个泄漏也串不出任何人的跨天访问史」成立。直接后果是跨天的 visitor 不可比: 近 30 天只能给「各日 UV 之和」。三个 tool 里它一律叫 visitorDays 而不是 UV, description 里逐个重申过——一个叫 UV 的字段被读成「多少个人」是迟早的事。
2. POST /t 是无认证的公开写入口,所以路径必须归一
第一版只做形状白名单,随即发现挡不住灌库:/notes/aaaa、/notes/aaab… 全是合法形状, 任何人都能让 visits 长到任意大。最终口径是形状白名单 + 库内存在性校验,归不出来的 一律折进常量桶 /*。行数上界从此由站内内容量决定,而不是由请求方决定。 代价是每次打点多一次走唯一索引的 EXISTS 查询——本站量级下可忽略。
实测直发的四种脏输入(/wp-admin.php、/notes/does-not-exist、//evil.com/x、 指向已删系列的 /notes/pi/01)全部合并进同一行 /*。
3. 两个「今天」必须是同一个
第一版用 SQL 的 CURRENT_DATE 切区间,而落库的 day 是 JS 按站点时区算的。 Postgres 容器默认 UTC,两者在跨日附近会错开一天——不报错、也不好复现。 改成时间源只在 JS 侧(shared/site-time.ts),SQL 只收一个算好的日期参数。 时区固定 UTC+8 且不做成配置项:用 Intl 按 IANA 时区算要依赖容器里有完整 ICU 数据, 而中国自 1991 年起没有夏令时,固定偏移在这里是精确的而不是近似。
4. 爬虫识别里 \bbot 是写错的(自测抓到)
\b(bot|crawler|…)\b 匹配不上 Googlebot——几乎所有爬虫都是「厂商 + bot」连写, 前置词边界一个都命中不了。改成 bot\b|… 并补了 Googlebot / bingbot / YandexBot / Twitterbot / curl / HeadlessChrome 六条用例。
5. apps/api/mcp/mcp.test.ts 里混着一个裸 NUL 字节(R6 遗留),git 把整个文件判成二进制
表现是 git diff 显示 Bin 14975 -> 20419 bytes——代码审查者读不到这个文件的任何改动。 本轮往这个文件里加了 141 行测试,不修的话那 141 行等于没写。已把它换成 \0 转义写法 (字节语义不变),并在 .gitattributes 里给 *.ts / *.tsx 加上 diff 属性兜底: 再混进控制字符时,diff 仍然可读,而不是整份消失。
6. about_set 从「整体覆盖」改成「部分更新」
字段从 4 个涨到 6 个、其中两个是几十行的数组之后,整体覆盖就变成了一个只会静默丢数据的 接口:「只想改一句 intro」必须把 7 张仓库卡与整条语言构成原样重报一遍,少报一个字段不会 报错、只会把它清空。改成部分更新后,清空是显式动作(传 "" / []),与 llm_provider_upsert 的口径一致。
7. 备案号底栏必须 await connection()
Runtime Tab(/)原本是静态页,SiteFooter 会在 next build 期间被预渲染, process.env.ICP_BEIAN 于是被烧进构建产物——而镜像是不可变制品、预发与生产共用同一个 SHA(规则 10),备案号只可能在部署期由 compose 注入。表现会是「配了也不显示,且只在 / 上不显示」。加 connection() 后 next build 的路由表里 / 从 ○ Static 变成 ƒ Dynamic,实测配上 ICP_BEIAN 后底栏正常渲染并链到 beian.miit.gov.cn。
8. 三个统计 tool 的实测输出(浏览 / ×3、/notes ×2、/about ×3 之后)
与 SELECT day, path, visitor, ua, hits FROM visits 逐行核对一致。
9. 审查整改后的口径变化(codex 第 1 轮 P1)
visitor 的哈希输入从 salt ‖ day ‖ ip ‖ ua 改成 salt ‖ day ‖ IP网段 ‖ UA摘要。 这不是「更保守一点」的调整,而是一个安全前提的修复:visitor 是主键的一部分, 而 /t 无认证——哈希输入里只要有一个分量是请求方能自由左右的,这个端点就是一条 撑爆数据库的通道。原先 UA(原始串)与 IP(XFF 第一段,而 Caddy 是追加不是覆盖) 两个分量都满足这个条件。
副作用要说清:UV 从此是按网段 + 浏览器族去重,不是按设备。同一个 /24 里用同款 浏览器的两位访客会被算成一个人。这是有意的取舍——换来的是「行数上界不由请求方决定」。
10. 与计划的偏离