...
Back

A Build Log Is User Input

A CI log is built from bytes others control, so a log viewer is an HTML sink like any comment box. The same pipe carries secrets out. Distrust both ways.

A Build Log Is User Input

A Build Log Is User Input 📜

This week Arusekk published SourceHut account takeover via build logs, tracked as CVE-2026-92973. The bug sat in ansi2html, the Python library that builds.sr.ht, SourceHut's CI, used to turn ANSI escape codes in job output into coloured HTML. Anyone who could get a few bytes into a build log could run script in the browser of whoever opened it.

The lesson is broader than one library: a build log is a document written largely by other people, and most of us render it as if we wrote it ourselves.


How the bytes reached the page

Besides colours, ansi2html turns URLs into links and supports OSC 8, the escape sequence that attaches a hyperlink to a span of terminal text. The URL inside that sequence landed in an href attribute without being made safe. A double quote closed the attribute early, and the rest became attributes of the link. The author's proof of concept came out as:

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

Browsers parse that as an anchor with autofocus, tabindex and an onfocus handler. A plain javascript: URL passed straight through too.

Submitting a job needs a paid account on the flagship instance, but an attacker did not need one. Per the writeup, the bytes could reach a log "without even having an account, by sending a patch to a public mailing list with continuous integration turned on, or by controlling any remote resource that happens to be printed to the log." The result is a job page under someone else's name that runs the payload for every visitor.

The author calls the impact speculation, but it is plausible. The log page carries a CSRF token and a "Resubmit build" form, so script on it can submit build jobs as whoever is looking, with access to deploy keys; on builds.sr.ht that includes deploy keys for sr.ht itself. The author's CVSS vector marks it wormable.

SourceHut mitigated it on 2026-08-04, three days after the report, by sanitizing ansi2html's output inside builds.sr.ht. The upstream fix shipped in ansi2html 1.9.4 on 2026-09-02. By the author's count builds.sr.ht had been vulnerable for almost exactly four and a half years. The sharpest line is about dependencies: "even carefully auditing ansi2html would not save SourceHut, unless redone on every bump."


Everything in a log came from somebody

Consider who actually writes a CI log:

  • Branch names and commit messages: whoever pushed or mailed the patch.
  • Test names: strings in code a contributor wrote.
  • Dependency install output: every install script is someone else's program writing to your stdout.
  • Anything the build fetches and prints: the writeup's "remote resource" channel.
  • ANSI escape sequences: not decoration but a small control language, hyperlinks included.

A log viewer turns all of that into HTML on a page holding the viewer's session. That is an HTML sink, like a comment box, and needs the same treatment:

  • Escape before render. Parse escape sequences into text runs plus style, then escape every text node and attribute value on output. Give link targets a scheme allowlist.
  • Render on an origin that holds no session. If the log view lives on a separate, cookieless origin with no forms, an escaping miss reaches nothing worth taking.
  • Strict CSP. No 'unsafe-inline'. The writeup notes this was not a quick fix for SourceHut, because the log page uses inline script for its own scrolling. A page's conveniences decide which defences you can turn on.

The pipe runs the other way too

Logs and artifacts are also how secrets leave: a build that dumps its environment, a bundler that inlines configuration.

Our own example. Our production site is a Next.js app built with OpenNext for Cloudflare Workers. OpenNext bundles the build-time environment into the deployable Worker artifact as plain text: a generated module containing an object like export const production = { ... } with every value from the build-time .env. At runtime the two sources are merged in a fixed order:

// Worker startup, simplified
for (const [key, value] of Object.entries(cloudflareEnv)) {
  process.env[key] = value;     // Cloudflare-side secrets and variables, first
}
for (const [key, value] of Object.entries(production)) {
  process.env[key] ??= value;   // baked build-time copy only fills gaps
}

So a baked copy of a key that also exists as a secret is never read. It is pure exposure. Our deploy script runs a step between build and upload that removes the baked copy of every value that also exists as a Cloudflare-side secret or configured variable. On a recent deploy it removed 50 baked secret values.

Two kinds of value stay on purpose. Keys with no Cloudflare-side value are left, because removing them would make the runtime miss a variable. NEXT_PUBLIC_* values are left, because they are already inlined into client-side JavaScript at build time and are public by definition.

And the step refuses to guess. If it cannot read the list of Cloudflare-side secrets (the CLI's output through our network proxy occasionally comes back empty), it retries three times and then aborts the whole deploy, rather than stripping blindly or shipping the artifact unscrubbed. The CLI also prints a proxy notice line on standard output before its JSON, so the script parses from the first [ instead of trusting the whole output. Tool output is input too.


Your build eats things you didn't send

A cloud build of ours once ran its test suite over about 984 ._* files. macOS's tar had silently embedded them in the upload archive as extended-attribute payloads; the Linux build unpacked them as real files, and the test runner treated ._*.test.mjs as tests. The log showed 64 failures, none of them real. Listing the archive with the Mac's own tar showed none of those files.

The log was faithfully reporting inputs we did not know we had sent. The fix was to build the archive with COPYFILE_DISABLE=1 and to verify its raw entries with a different reader, one that does not share the writer's blind spot. Same move as the scrub step: check what the artifact contains, not what its maker tells you.


A checklist

  • Escape log output at render time and allowlist link schemes. If nobody needs clickable links in logs, drop OSC 8.
  • Serve log views from an origin with no session cookies, no CSRF tokens and no action buttons, under a CSP without 'unsafe-inline'.
  • Treat the log renderer as security code: re-check it on every version bump, with the writeup's payload as a test fixture.
  • Scrub artifacts of secrets the runtime already gets elsewhere, and fail closed when the scrub cannot see its inputs.
  • Build upload archives explicitly and inspect their raw entries with a second tool.

A build is a pipe with strangers at both ends. Distrust what goes in, and what comes out.