...
Back

构建日志也是用户输入

CI 日志由别人能控制的字节拼成,日志查看器就是一个和评论框一样的 HTML 输出点;同一条管道反过来还会把密钥带出去。构建的输入和输出,两个方向都别信。

构建日志也是用户输入

构建日志也是用户输入 📜

这周,Arusekk 发表了 SourceHut account takeover via build logs,编号 CVE-2026-92973。问题出在 ansi2html 上 —— 一个 Python 库,SourceHut 的 CI 服务 builds.sr.ht 用它把任务输出里的 ANSI 转义码转成带颜色的 HTML。谁能往一份构建日志里塞进几个字节,谁就能在打开这份日志的人的浏览器里跑脚本。

这件事的教训不止于一个库:构建日志是一份大部分内容由别人写成的文档,而我们大多数人渲染它的方式,就好像它是我们自己写的。


这些字节是怎么进到页面上的

ansi2html 除了处理颜色,还会自动把 URL 变成链接,并且支持 OSC 8 —— 一种给一段终端文字挂上超链接的转义序列。这个序列里的 URL 被原样放进了 href 属性,没有做任何安全处理。一个双引号提前闭合了属性,后面的内容就成了这个链接自己的属性。作者的概念验证输出是这样的:

<a href="https://example.com/"/autofocus/tabindex="1"/onfocus="alert`xss`">Nothing to see here</a>

浏览器会把它解析成一个带 autofocus、tabindex 和 onfocus 处理器的锚点。一个普通的 javascript: URL 也能直接通过。

在旗舰实例上自己提交任务需要付费账号,但攻击者根本用不着。按作者的说法,完全不需要账号也能把这些字节送进日志:向一个开启了持续集成的公开邮件列表发一个补丁,或者控制任何一个恰好会被打印到日志里的远程资源。结果就是一个挂在别人名下的任务页面,每个来访者的浏览器都会执行这段载荷。

作者明说接下来的影响分析是推测,但推测得有道理。日志页面上本来就有 CSRF 令牌和一个"Resubmit build"(重新提交构建)的表单,所以页面上的脚本可以以正在看页面的那个人的身份提交构建任务,而这些任务能用到部署密钥;在 builds.sr.ht 上,其中包括 sr.ht 自己的部署密钥。作者给出的 CVSS 向量把它标成了可蠕虫传播。

SourceHut 在 2026-08-04,也就是报告后第三天,做了缓解:在 builds.sr.ht 内部对 ansi2html 的输出做净化。上游修复随 ansi2html 1.9.4 在 2026-09-02 发布。按作者的计算,builds.sr.ht 暴露了几乎整整四年半。全文最尖锐的一句是关于依赖的:"even carefully auditing ansi2html would not save SourceHut, unless redone on every bump." —— 就算仔细审计过 ansi2html,只要不是每次升级都重审一遍,也救不了 SourceHut。


日志里的每一行都有人写

数一数一份 CI 日志究竟是谁写的:

  • 分支名和提交信息:推送或邮寄补丁的那个人写的。
  • 测试名:贡献者写在代码里的字符串。
  • 依赖安装输出:每一个安装脚本,都是别人的程序在往你的 stdout 里写东西。
  • 构建拉取并打印出来的任何东西:也就是原文里说的"远程资源"那条路。
  • ANSI 转义序列:它不是装饰,而是一门小小的控制语言,超链接也包括在内。

日志查看器把这些全部变成 HTML,放进一个持有查看者会话的页面。这就是一个 HTML 输出点(sink),和评论框没有区别,也需要同样的对待:

  • 先转义,再渲染。 把转义序列解析成"文本片段 + 样式"的结构,输出时对每一个文本节点和属性值做转义。链接目标只放行白名单里的协议。
  • 在一个不持有会话的源上渲染。 如果日志视图放在一个独立的、没有 Cookie、没有表单的源上,就算哪里漏了转义,攻击者在那儿也拿不到任何值钱的东西。
  • 严格的 CSP。 不要 'unsafe-inline'。原文提到,这一条对 SourceHut 来说没法马上做到,因为日志页面自己就在用内联脚本控制滚动。页面上的那些便利功能,决定了你能打开哪些防线。

管道的另一头也在漏

日志和构建产物同样是密钥流出去的通道:一个把环境变量整个打印出来的构建,一个把配置内联进产物的打包器,都是例子。

拿我们自己来说。我们的生产站点是一个 Next.js 应用,用 OpenNext 构建、部署到 Cloudflare Workers。OpenNext 会把构建时的环境变量以明文打包进可部署的 Worker 产物:一个生成出来的模块,里面有一个形如 export const production = { ... } 的对象,装着构建时 .env 里的每一个值。到了运行时,两个来源按固定顺序合并:

// Worker 启动时,简化版
for (const [key, value] of Object.entries(cloudflareEnv)) {
  process.env[key] = value;     // Cloudflare 侧的密钥和变量,先赋值
}
for (const [key, value] of Object.entries(production)) {
  process.env[key] ??= value;   // 打包进去的构建时副本,只补空缺
}

所以,只要一个键在 Cloudflare 侧已经作为密钥存在,产物里烤进去的那份副本就永远不会被读到。它唯一的作用就是暴露。我们的部署脚本在构建和上传之间插了一步:凡是在 Cloudflare 侧已经存在为密钥或已配置变量的值,就把产物里烤进去的那份副本删掉。最近一次部署,这一步删掉了 50 个烤进去的密钥值。

有两类值是故意留下的。在 Cloudflare 侧没有对应值的键保留,因为删了会让运行时缺变量。NEXT_PUBLIC_* 的值也保留,因为它们在构建时就已经内联进客户端 JavaScript,本来就是公开的。

而且这一步拒绝猜。如果读不到 Cloudflare 侧的密钥清单(CLI 的输出经过我们的网络代理时,偶尔会回来一个空的),它会重试三次,然后中止整个部署 —— 既不盲删,也不把没清理过的产物照样发出去。CLI 还会在 JSON 之前往标准输出打一行代理提示,所以脚本从第一个 [ 开始解析,而不是信任整段输出。工具的输出,也是输入。


构建吃进了你没寄出去的东西

我们有一次云端构建,测试套件跑过了大约 984 个 ._* 文件。它们是 macOS 的 tar 悄悄以扩展属性载荷的形式塞进上传归档的;Linux 上的构建把它们解包成了真实文件,测试运行器又把 ._*.test.mjs 当成了测试。日志报出 64 个失败,没有一个是真的。而用 Mac 自带的 tar 列出归档内容时,这些文件一个都看不到。

日志忠实地报告了我们不知道自己寄出去的输入。修法是用 COPYFILE_DISABLE=1 打归档,再用另一个读取器核对归档里的原始条目 —— 一个不和写入方共享盲区的读取器。这和前面清理产物是同一个动作:检查产物里实际装了什么,而不是听产出它的工具怎么说。


一份检查清单

  • 在渲染时转义日志输出,链接只放行白名单协议。如果没人需要在日志里点链接,就干脆丢掉 OSC 8。
  • 日志视图放在一个没有会话 Cookie、没有 CSRF 令牌、没有操作按钮的源上,并配一个不含 'unsafe-inline' 的 CSP。
  • 把日志渲染器当成安全代码:每次升级版本都重新检查,并把原文的载荷放进测试夹具。
  • 清理产物里那些运行时本来就能从别处拿到的密钥;清理步骤看不到自己的输入时,宁可失败也不放行(fail closed)。
  • 显式地构建上传归档,并用第二个工具检查其中的原始条目。

构建是一条两头都连着陌生人的管道。进去的东西别信,出来的东西也别信。