自检 46 条零驳回 · 同类中的异类才是缺陷藏身处
入档:2026-08-19 来源:PB Arena(pb.tiaozhuxiansheng.com)「全项目自检一次,重点检查交互式 bug 及边缘问题」的整轮会话,两批修复共 11 文件 +1585/−184,后端用例 21 → 27,三次部署上线 状态:已上线并线上验证;insight 1、3、4、6 为本次首次发现 ⚠️;insight 2 是 全站文字截断体检_检测工具本身要先被证伪_v1 的第六次现形(新亚型);insight 5 是 公屏下线只删了界面_删界面不等于关链路_v1 的同族新变体 前情:公屏下线只删了界面_删界面不等于关链路_v1 · 零作品的比赛_界面演完不等于链路存在_v1 · 内测修复逐项审计闭环_v1(均同项目) 续篇(2026-08-20):零路由补路由_普查要能推翻已写的代码_v1 —— 本篇清单里最后一项、也是唯一的架构级缺口(全站零路由)的落地。那一轮又贡献四条新律:普查要能推翻已写的代码、探路实验是免费的 bug 探针、完备方案 ≠ 该做的方案、异步修复要问「在途期间会发生什么」;并把本篇 insight 2 的「觉得自己知道于是没查」记到人肉版第三次现形,改成了具体动作(写接口测试前先 grep 出字符串贴过来,不手打)
事实记录(不可修改区)
- 触发:用户一句「全项目自检一次,重点检查交互式 bug 及边缘问题」,没有清单、没有指定范围
- 做法两条腿:① 12 个方向并行静态排查,60 个 agent,逐条对抗式复核;② 在隔离实例(独立端口 + scratchpad 临时库)上跑真实用户路径
- 静态排查交回 46 条,全部标记「已确认」,驳回 0 条
- 实机另跑出 11 条,与静态排查重叠度低——实机独有的是「连接指示器恒显示连接正常」「断网切页静默失败」「79 个错误码里 42 个没汉化」,静态独有的是后端并发与状态机问题
- 隔离性核对:全程独立端口(3300/3301/3302)+ scratchpad 临时库;收工时
git status干净、真实开发库pb-arena-server/data/pb-arena.sqlitemtime 未变
客观查证(不加解释)
-
昵称 XSS:注册昵称填
<img src=x onerror=top.PWN=1>(29 字,未超 32 上限,后端当时只校验长度)→ 任意用户打开封神榜 →window.PWN === 1。/api/leaderboard为公开接口,排行榜任意名次都触发 -
结算页:三人真实对战,数据库
votes表 2 票 / 1 票;同一时刻屏幕显示「本场无优胜 / #1 0 票 #2 0 票 #3 0 票 / 胜负积分 +0 / 不撒花」;按 F5 后退回index.html里写死的「本场优胜 Mori,作品获得 68% 的投票支持」 -
房间唯一索引:A 建房 → A 又加入 B 的房间(服务端允许)→ B 掉线 90 秒 → 请求路径全员 500,随后进程退出,日志:
Error: UNIQUE constraint failed: match_rooms.host_user_id at Object.expireWaitingRooms (database.js:2079) at Timeout._onTimeout (app.js:1860) [exited with code 1]app.js:1860是三个 sweep 定时器里唯一没有 try/catch 的那个;deploy/pb-arena.service有Restart=always / RestartSec=3,重启后未复现崩溃循环 -
频控:一名选手连错 11 次密码后 → 管理员用正确密码登录 429、另一名选手用正确密码 429、新用户注册 429。生产是 nginx 反代到 127.0.0.1,而频控 key 取
request.socket.remoteAddress;deploy/nginx-pb.conf一直在传X-Real-IP与X-Forwarded-For,后端没读 -
房间心跳:建房后停在大厅 168 秒不动 → 全程只发出 1 次心跳(建房那一下)→ 服务端约 90 秒把房间置
closed、参与者清零 → 页面上那张卡片仍在、「已等待 1 秒」的时间戳冻住、计数器仍显示 1 -
刷新即退房:建房后按一次 F5 → 房间
status=closed、房内人数 0 -
匹配成功页:
#matchedState里写死「亚总 VS Mori」,全文 grep 无任何代码填充过它 -
错误码:后端 79 个
HttpError码,前端authErrorMessage映射表覆盖 37 个,其余 42 个走error.message兜底把英文原文弹给中文用户
两条 agent 描述偏差(抽验后纠正,偏差都指向「比报告说的更糟」)
| agent 报告 | 实测 |
|---|---|
| 结算页出问题后「F5 刷新就正确」 | 刷新更糟:接口对已结束对战返回 null,面板退回写死的演示数据,大屏打出一个不存在的选手名 |
| 房间唯一索引冲突让「大厅接口永久 500」 | 是整个进程被打挂(全站而非只有大厅);但 systemd 3 秒拉起,且无崩溃循环 |
自己的失误:新增回归测试第一次跑挂两条,均因未先查接口就凭印象写——提前结束对战的 action 是 finished(写成了 finish)、接受好友申请走 /api/friend-requests/:id/respond(写成了 POST 到本体)
一句话总结
一堆同类代码里唯一不一致的那一处,就是缺陷的藏身处——三处最严重的问题(打挂进程的定时器、能偷管理员令牌的 XSS、漏转义的赛事标题)全是「两个包了 try/catch 第三个没包」「所有渲染函数都过 escapeHtml 只有这两行没过」「紧挨着的字段转义了它没转义」这种形状;排查先找异类,比逐行读快一个量级。
六条可复用 insight
1 · 同类中的异类才是缺陷藏身处 ⚠️首次
这次三个最严重的问题,定位方式完全一样:
| 缺陷 | 同类 | 异类 |
|---|---|---|
| 进程被打挂 | 三个 sweep 定时器 | 只有一个没包 try/catch |
| 存储型 XSS,可偷管理员令牌 | 全文十几个渲染函数都 escapeHtml | 只有 loadLeaderboard 那两行没过 |
| 赛事页注入 | 同一模板里 round.name 转义了 | 紧挨着的 event.title 没转义 |
方法:不要逐行读代码找 bug。先把「做同一件事的那批代码」枚举出来(grep 同一个函数名、同一个模式),然后看谁跟别人不一样。人写代码时会把一批同类的写成同一个样子,写漏的那处天然是异类。
这条和 全站文字截断体检_检测工具本身要先被证伪_v1 里的「找同类问题按写法搜不按页面搜」是一对:那条讲怎么找全,这条讲找全之后看哪个不对。
2 · 复核全票通过等于没复核 ⭐ 第六次现形,新亚型
「检测工具本身要先被证伪」这次的载体不是脚本,是一支被明确要求「尽力反驳」的 agent 复核团——46 条提案,驳回 0 条。
前五次的亚型是「工具算错了」和「我把工具输出读成了别的意思」。这次是新的第三种:工具的判断机制本身有偏——复核 agent 拿到一段看起来合理的分析文本,倾向于确认而不是去跑一遍。光在 prompt 里写「默认怀疑」「证据不足判 false」不够。
判据:复核结果的驳回率是可信度指标。全过或全不过都不可信,必须抽最严重的几条自己验。这次抽验了,纠正了两条最严重的描述偏差,而这两条纠正直接改变了修法(一条要顺手清生产 HTML 里的占位数据,一条的止血点是那个定时器而不是大厅接口)——照单全收会修错地方。
下次改法:复核 agent 不给原始分析全文(避免锚定),只给「位置 + 一句话结论」要求独立重新推导;或强制要求附「我实际执行了哪些命令、看到什么输出」,没有执行记录的判定不算数。
同一毛病也发生在我自己身上:写回归测试时凭印象写 action 名和路由路径,挂了两条。觉得自己知道,于是没去查——和复核 agent 是同一种失败。
3 · 占位数据必须一眼可辨为占位,假数据比空白更危险 ⚠️首次
一轮里撞到两个实例,是同一失败模式:
- 结算面板的
resultWinner写死「Mori」+「作品获得 68% 的投票支持」——接口返回null时它原样留在屏幕上; #matchedState写死「亚总 VS Mori」——全文没有任何代码填充过它,每个匹配成功的人看到的都是这两个不存在的选手。
第二个尤其说明问题:它不是逻辑错误,是「静态 HTML 里的假数据从来没被替换过」,静态排查和实机路径都抓不到,是部署后核对线上 HTML 才发现的。
律:生产 HTML 里的占位内容必须一眼可辨为占位(—、空字符串、「正在读取…」),不能是像模像样的假数据。空白至少能看出坏了,假数据看起来一切正常——尤其当它会被投到大屏上给一屋子人看。
推论:设计稿转代码时塞进去的示例姓名/数字,是这类问题的天然来源。交付前应该 grep 一遍设计稿里的人名。
4 · 保活信号不能搭业务轮询的便车 ⚠️首次
房间心跳原来写在 loadWaitingRooms() 里,而这个函数只被 2 秒轮询调用。后来做 WebSocket 优化,加了「WS 连着时轮询整体空转」——心跳跟着一起被停掉了。
代码里那句 if (window.pbRealtime?.connected) return; 旁边还有注释说明这是有意设计。设计本身没错,错在心跳搭了业务轮询的便车:优化的人只在想「要不要拉列表」,不会想到这条路上还挂着一个保活信号。
后果是完整的一条现场故事:房间被服务端悄悄关掉 → 界面卡着不动(没人推送、也没人轮询)→ 人的第一反应是刷新 → 而 beforeunload 又会主动退房 → 房间真的没了。
律:保活/心跳是独立关注点,必须有自己的生命周期,不能寄生在任何「可能被优化掉」的业务循环里。判据很简单——问一句「如果有人把这个循环关了会怎样」。
5 · 安全属性只做在渲染层等于没做 ⭐ 同族新变体
「匿名投票」界面上老老实实显示「作品 A / B / C」,但接口无条件下发 submissions[].userId,同一个响应体里的 players[] 又是 id→昵称的对照表。打开 DevTools 的 Network 面板就能把每件作品对上具体的人。
这是 公屏下线只删了界面_删界面不等于关链路_v1 的同族——那条是「删界面不等于关链路」,这条是「匿名只做在渲染层不等于真的匿名」。同一个家族还有「权限门禁只做 button.hidden = true」。
判据:任何以「用户看不到」为前提的安全属性(匿名、脱敏、权限),验收方式都是绕过界面直接打接口,看它还给不给。给 → 没做。
修法上有个通用形状:前端需要那个字段往往只是为了判断「这是不是我自己的」,换成一个布尔位就够了,不需要暴露全表身份。
6 · 反代之后 socket 地址不代表用户;线下场景整场共用出口网 ⚠️首次
频控按 request.socket.remoteAddress 计数。生产是 nginx 反代到 127.0.0.1,这个值对所有外部用户恒为 127.0.0.1——全站共用一个桶。一个人连错 11 次密码,管理员用正确密码也登不上,现场当场失去控场能力。
讽刺的是 nginx 配置里一直在传 X-Real-IP 和 X-Forwarded-For,只是后端没读。配置和代码各自都「对」,接缝处没人管。
更要紧的一层:就算读对了真实 IP,线下赛整个会场共用一条出口网——按 IP 卡死照样是全场连坐。所以频控必须以账号为主维度(严,10 次),IP 维度只用来兜住明显的批量扫描(松,60 次),并且只在真正失败时计数(原来在入口无条件 +1,成功的登录也在吃配额)。
推论:任何「按来源限流」的设计,先问两句——① 反代之后这个来源还是用户吗?② 目标用户会不会成批共用同一个来源?线下活动、学校、公司网络全都会。
关联文档
- 同项目前情:公屏下线只删了界面_删界面不等于关链路_v1 · 零作品的比赛_界面演完不等于链路存在_v1 · 赛事状态机到点不切换_写入方不等于推进方_v1 · 内测修复逐项审计闭环_v1
- 核心律同族:全站文字截断体检_检测工具本身要先被证伪_v1(insight 2 是它的第六次现形)
- 区索引:09_平台工程索引
- 对外版(无双链,可直接转发):
08_对外分发/自检怎么做才不白做_六条可迁移的排查经验.md