Summary
A build-time helper that extracts the expected SHA-256 for a downloaded Zola release passed process.argv[2] straight into fs.readFileSync() with no directory constraint, so any caller able to influence that argument could make the integrity check read an arbitrary file. The fix resolves the requested path and requires it to be a direct child of the tools directory, which is now passed in as an extra argument, and exits with an error otherwise. Because the bytes read become the "expected" checksum for a binary that is later executed by the build, controlling the file content meant controlling the integrity gate.
Introduction
This one is interesting because the vulnerable code is not in a web handler, an API route, or anything that faces the internet. It is an inline Node.js program — a heredoc piped into node - from a shell function called download_and_verify_zola() — whose entire job is to make the build more secure. It fetches the GitHub release-assets page for the Zola static site generator, locates the asset filename inside the HTML, and pulls out the SHA-256 hash published next to it. That hash is then used to verify the downloaded archive before the binary is unpacked and run.
The problem was the very first thing the program did:
const html = fs.readFileSync(process.argv[2], 'utf8');
process.argv[2] is whatever the shell handed over as "$assets_html". There was no check that this path lived anywhere near the build's working area, and no check on what kind of file it was. A helper that exists to establish trust in a downloaded binary was itself reading a fully attacker-influenceable path — and the content it read became the trust anchor.
That combination is what makes this worth writing up. Build scripts routinely get a pass on input validation because "the input comes from us." In CI, "us" includes anyone who can edit a workflow file, anyone who can land a change to a wrapper script, and in some setups anyone who can write to a shared temp directory before the build runs.
Affected Versions
| Affected | not applicable (first-party code) — the inline Node.js hash-extraction helper invoked by the Zola toolchain bootstrap routine |
| Fixed in | not applicable (first-party code) — corrected in the linked security fix commit |
| Ecosystem | N/A (shell + Node.js build tooling) |
| CVE / GHSA | not assigned |
| CWE | CWE-22: Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal") |
There is no published package version to upgrade to. If you maintain a fork or a copy of this bootstrap logic, the check described below has to be applied in your copy.
The Vulnerability Explained
Here is the shape of the code before the fix. The shell function downloads the release-assets HTML to a temporary file and then shells out to Node to parse it:
const fs = require('fs');
const html = fs.readFileSync(process.argv[2], 'utf8');
const asset = process.argv[3];
const pos = html.indexOf(asset);
Line 2 is the defect. readFileSync receives the raw argument with no normalization, no prefix check, and no type check. Everything downstream — indexOf(asset), the hex extraction, and the value assigned to expected_sha256 in the shell — is derived from whatever bytes that call returns.
Why this is more than "reads a file it shouldn't"
There are two distinct consequences, which is why the assessment describes a 2-step chain.
Step 1 — arbitrary read and disclosure. Any path is accepted, so ../../../etc/passwd, a CI runner's credential file, or a .env sitting in a parent directory are all fair game. The read content is not printed wholesale, but the script's parsing behavior is observable: if the asset name is not found, or if no 64-character hex string follows it, the build fails with a different message than when parsing succeeds. That is an oracle. More practically, build logs are verbose and stderr from a failed parse frequently ends up in a public CI log.
Step 2 — subverting the integrity check. This is the serious one. The extracted value becomes expected_sha256, which the script compares against the SHA-256 of the archive it downloaded. If an attacker controls the file that gets read, they control the expected hash. Point the read at a file they authored — one containing the asset name followed by the SHA-256 of their tampered Zola build — and the verification step happily confirms a malicious binary. The build then unpacks and executes it with full access to the source tree, the network, and any secrets the job holds. An integrity check whose reference value comes from an unvalidated path is not an integrity check; it is a formality.
A concrete attack path
The realistic entry points, in rough order of likelihood:
- CI configuration change. A contributor with workflow write access, or a compromised token, adjusts the environment so
assets_htmlresolves to a file they staged in the repository. No change to the inline Node program is required — the heredoc is quoted (<<'NODE'), so the script body is fixed, but its arguments were completely unguarded. - A symlink at the expected temp path. If the assets HTML is written into a shared or predictable temp location, a process that runs earlier on the same runner can plant a symlink there.
readFileSyncfollows symlinks silently. - A wrapper or Makefile target that invokes the bootstrap routine with an overridden path, added in a pull request that looks like a build tweak.
In every case the payoff is the same: bend the checksum source, then ship a binary that the build vouches for.
The Fix
The change does two things: it gives the inline script a notion of where it is allowed to read from, and it enforces that notion before the read.
First, the shell invocation now passes the tools directory alongside the existing arguments:
expected_sha256="$(node - "$assets_html" "$asset_name" "$TOOLS_DIR" <<'NODE'
This matters more than it looks. The allowlist root is supplied from the script's own configuration, not derived from the value being validated — a validator that computes its policy from the untrusted input is no validator at all.
Then the inline program resolves and checks before it reads:
const path = require('path');
const allowedDir = path.resolve(process.argv[4]);
const filePath = path.resolve(process.argv[2]);
if (filePath !== path.join(allowedDir, path.basename(filePath))) {
console.error('ERROR: refusing to read a file outside the expected build directory.');
process.exit(1);
}
const html = fs.readFileSync(filePath, 'utf8');
Walking through why each piece is needed:
path.resolve(process.argv[2])collapses.,.., and relative segments into a single absolute path. Without this, a string comparison against the allowed directory is trivially defeated bytools/../../etc/passwd.path.resolve(process.argv[4])normalizes the policy root the same way, so the two sides of the comparison are in the same form. Comparing a resolved path against an unresolved one is a classic source of bypasses.filePath !== path.join(allowedDir, path.basename(filePath))is the actual gate, and it is deliberately stricter than a prefix test. It reconstructs the only legal path — the allowed directory plus the file's own basename — and demands an exact match. That rejects traversal (..no longer survivesresolve), rejects nested subdirectories, and, importantly, rejects sibling directories with a shared prefix that a naivefilePath.startsWith(allowedDir)would wave through.process.exit(1)with a message on stderr fails the build loudly instead of falling through toreadFileSync. Because the shell captures stdout intoexpected_sha256, an empty-string fallback would have produced a confusing mismatch error rather than a clear refusal; writing the message to stderr and exiting non-zero keeps the failure attributable.readFileSync(filePath, ...)uses the resolved and validated value, not the original argument. Re-readingprocess.argv[2]after validating a derived variable is the check-then-use-a-different-value bug that undoes many otherwise-correct path validations.
Behavior for the legitimate case is unchanged: the assets HTML is written directly into the tools directory, so the reconstructed path matches exactly and the hash extraction proceeds as before.
What is still worth hardening
path.resolve() is purely lexical — it does not consult the filesystem. A symlink placed inside the allowed directory still resolves to an in-policy path and will be followed. If your threat model includes other processes writing to that directory, add an fs.realpathSync() on the resolved path (and compare again), or reject symlinks outright with fs.lstatSync(filePath).isSymbolicLink().
Key Takeaways
- An inline
node -heredoc is hardened code with unhardened inputs. Quoting the heredoc (<<'NODE') protects the program text from shell expansion, but says nothing aboutprocess.argv. Validate the arguments, not just the script body. - A checksum is only as trustworthy as the path it was read from. Because the extracted hex became
expected_sha256for a binary the build later executes, an arbitrary-read bug in the hash extractor was really a supply-chain bug. - Pass the allowlist root in as a separate argument. The fix added
"$TOOLS_DIR"asprocess.argv[4]precisely so the policy does not come from the same untrusted string being checked. - Prefer
resolved === join(allowedDir, basename(resolved))overstartsWith(allowedDir). The exact-match form rejects traversal, nesting, and prefix-sibling directories in one comparison. - Read from the validated variable. The corrected code calls
readFileSync(filePath, 'utf8'), notreadFileSync(process.argv[2], 'utf8')— otherwise the check and the read can disagree.
How Orbis AppSec Detected This
- Source: the second positional argument to the inline Node.js hash-extraction program,
process.argv[2], supplied by the shell caller as the downloaded release-assets HTML path. - Sink:
fs.readFileSync(process.argv[2], 'utf8'), whose returned content is parsed into the expected SHA-256 and returned to the shell asexpected_sha256. - Missing control: no normalization of the argument and no containment check against an expected build directory; the path was neither resolved nor constrained, and symlink status was never inspected.
- CWE: CWE-22, Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal"). The originating scanner rule flagged the pattern of a shell script handing untrusted arguments into a spawned interpreter; triage confirmed the exploitable primitive is the unconstrained file read.
- Fix: resolve both the requested path and an allowed directory passed as
process.argv[4], require the requested path to equalpath.join(allowedDir, path.basename(filePath)), and exit non-zero with an explicit refusal otherwise.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The bug here was five characters of trust: argv[2] handed directly to readFileSync. What made it high severity was not the arbitrary read on its own but where the read sat in the build — upstream of a SHA-256 comparison that gates whether a downloaded Zola binary gets executed. Control the file, control the expected hash, control the binary.
The remedy is small and worth copying into any build helper that takes a path on the command line: resolve the input, resolve the one directory it is allowed to live in, demand an exact join(allowedDir, basename(path)) match, fail loudly, and then read from the validated variable rather than the original argument. If the directory can be written by anything other than your own build, follow that up with a realpathSync() check so symlinks cannot quietly reintroduce the problem.