菜单

一条有 2% 的时候会失败的测试

2026-10-15MCP Checkup

本文译自 2026-09-23 的英文原文。

我们套件里的一条测试变红了,下一次运行又变绿,中间没有任何代码改动。这种现象只有一个含义,接下来的故事就是找出哪条测试学会了掷骰子。

这道防护

首页的检查表单会提交一个 endpoint URL,然后重定向到结果页,结果页的 URL 里带着一个 43 字符的 base64url token。由于重定向目标是用请求数据拼出来的,这里有一道开放重定向防护:一条测试提交一组恶意的探针字符串,断言它们都不会出现在 Location 头里。一共十一个探针,比如 //evil.example.com、../evil、百分号编码的 %2F%2Fevil.example.com,还有几个形似语言代码的字符串,比如 "ZH" 和 "de"。

断言是对每个探针检查 location.includes(probe) === false。

骰子

其中两个探针是 "ZH" 和 "de"。一个 43 字符的 base64url token 取自 64 个符号;对每个探针来说,某个特定的两字母序列出现在 token 的 42 组相邻字符对中某一处的概率,接近 42/4096。两个探针,每次测试运行约 2%。计算和一次抽样运行得出的数字一致。

所以,这道防护有 2% 的时候检测到的并不是开放重定向,而是一个随机 token 里恰好含有字母「de」。

第一个修法会让防护失明

显而易见的修法是:在做 includes 检查之前,先把 Location 里的 token 段去掉。这个方案被提了出来,一开始也被接受了。后来在准备改动时,我们顺着它推了一遍。token 段是 Location 里唯一不来自固定路径的部分。把它去掉,剩下的字符串就是一个常量,永远不可能含有探针。测试会永远是绿的,因为它不再看 Location 里唯一一个探针够得着的部分。

这一点值得说准确,因为我们自己的笔记第一次就写错了。原来的子串检查并非毫无用处。它看的是整条 Location,包括 token 在内,能抓住其他检查漏掉的两类变异。它唯一真正的问题是随机性。提出的那个修法,会把一道偶尔出错的防护,换成一道永远不可能对的防护。

这个版本在合并前就撤回了。原先把它写成计划的那条 TODO 也重写了:一条把已被证伪的修法当作计划的 TODO,是仓库里的一句假陈述,而这类陈述我们会在合并前改正。

留下来的修法

重定向里唯一可变的内容就是 token,而 token 在签发的那一刻就写进了一行账本记录。所以这道防护现在做的是精确的事:从这次请求刚刚创建的那一行里读回 token,断言 Location 恰好是预期的路径加上恰好这个 token。比的是相等,不是子串。十一个探针保留下来作为输入,因为一个探针即使以某种方式混进了 token,也同样会破坏相等。断言里不再有任何随机成分。

我们写下的规则

现在,这个仓库里的每一条防护注释,声称能抓住的每一样东西,都必须有一次测量来支撑:以那种方式变异代码的命令,以及因此变红的断言。「这条测试防范开放重定向」是一个声称。「把重定向改成包含 //evil.example.com 会让这条断言失败,这是演示它的命令」才是证据。什么都不声称的注释不需要测量。声称了什么却没有测量的注释,审查时不予接受。

第二条规则小一些,也更疼:随机失败的测试不会挑方便的时候失败。我们这一条,本来会在某次部署正等着的那一轮 CI 上发作,偏偏是在我们最没工夫和 CI 争论的那一天。我们把它和一个功能一起修了,就在那个功能的 pull request 里。后来一位审查者偶然撞上了它的一个兄弟,另一条带随机成分的测试,那一条还在清单上。

事实从哪里来

探针列表和断言都在我们仓库里这个测试文件的历史中;这条测试的第一个版本就已经包含那些形似语言代码的探针。2% 这个数来自上面的算术,以及仓库里这个 bug 的 TODO 条目所记录的一次抽样运行;这个条目也记下了被撤回的修法和撤回的理由。