Back to Blog
high SEVERITY9 min read

fs.readFileSync(process.argv[2]) Path Traversal in Zola Build

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" che

O
By Orbis AppSec
Published September 17, 2026Reviewed September 17, 2026

Answer Summary

The affected code is first-party: an inline Node.js helper, invoked from a shell build script via `node -`, that reads a downloaded release-assets HTML page with `fs.readFileSync(process.argv[2], 'utf8')` to extract the expected SHA-256 of a Zola binary. An attacker able to influence that argument — through CI configuration, a wrapper script, or a symlink planted at the expected temp path — could make the build read arbitrary files such as `.env` or supply an attacker-chosen checksum, causing a tampered Zola binary to pass verification. The fix resolves both the requested path and an allowed directory (now passed as a fourth argument) and refuses to read anything that is not a direct child of that directory; there is no package version, only the fix commit. The issue is tracked as CWE-22, Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal").

Vulnerability at a Glance

cweCWE-22
fixResolve the path and require it to equal `path.join(allowedDir, path.basename(filePath))`, else exit 1
riskArbitrary file read at build time and subversion of the SHA-256 verification for a downloaded binary
languageJavaScript (Node.js) embedded in a POSIX shell script
root cause`process.argv[2]` passed directly to `fs.readFileSync()` with no allowed-directory constraint
vulnerabilityPath traversal / unvalidated file read in a build-time integrity check

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:

  1. CI configuration change. A contributor with workflow write access, or a compromised token, adjusts the environment so assets_html resolves 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.
  2. 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. readFileSync follows symlinks silently.
  3. 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 by tools/../../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 survives resolve), rejects nested subdirectories, and, importantly, rejects sibling directories with a shared prefix that a naive filePath.startsWith(allowedDir) would wave through.
  • process.exit(1) with a message on stderr fails the build loudly instead of falling through to readFileSync. Because the shell captures stdout into expected_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-reading process.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 about process.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_sha256 for 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" as process.argv[4] precisely so the policy does not come from the same untrusted string being checked.
  • Prefer resolved === join(allowedDir, basename(resolved)) over startsWith(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'), not readFileSync(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 as expected_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 equal path.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.

Prevention and further reading

Frequently Asked Questions

Why does the fixed check compare against `path.join(allowedDir, path.basename(filePath))` instead of using `startsWith(allowedDir)`?

The `basename` form requires the target to be a *direct child* of the allowed directory, so it rejects both `../` traversal and nested paths like `allowedDir/sub/dir/file`. A `startsWith()` check would also accept sibling directories whose names share a prefix, such as a `tools-backup` directory next to `tools`.

What does the fourth argument added to the `node -` invocation do?

The shell now passes the tools directory (`"$TOOLS_DIR"`) as a third positional argument, which the inline script reads as `process.argv[4]` and resolves into `allowedDir`. The allowlist root therefore comes from the script's own configuration rather than from the same untrusted value being validated.

Does the path check stop a symlink planted where the release-assets HTML is written?

Not entirely. `path.resolve()` performs lexical normalization only, so a symlink inside the allowed directory still resolves to an in-policy path; hardening that case requires `fs.realpathSync()` on the resolved path (or `fs.lstatSync()` plus an `isSymbolicLink()` rejection) before the read.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #242

Related Articles

high

write_page_jobs(): Unvalidated page_dir Escapes the Run Dir

The deck preparation runtime built per-page working directories by joining the `page_dir` string from a deck state document directly onto the run directory, with no containment check and no schema validation. A crafted or tampered deck record could therefore steer `page_request.json` writes anywhere on the filesystem the process could reach, including outside the run sandbox entirely. The fix routes both call sites through a single `page_dir_for(run_dir, page)` helper so the untrusted `page_dir`

high

modelExporter.js Path Traversal via Unsanitized Directory Concatenation

A path traversal vulnerability in `modelExporter.js` allowed attackers to read arbitrary files by injecting traversal sequences into directory and relative path parameters. The `readSourceFile` function concatenated these unsanitized inputs directly into file URLs passed to `fetch()`. The fix introduces strict path normalization that rejects attempts to escape the intended directory.

critical

How path traversal happens in PHP virtual filesystem adapters and how to fix it

A critical path traversal flaw in `VirtualAdapter.php`'s `resolveMount()` method allowed attackers to escape mounted directory boundaries using sequences like `../../../etc/passwd`. The fix introduces `PathPolicy::normalizeRelative()` to sanitize the remaining path segment before it ever reaches the underlying storage adapter.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

high

How Trust-Prefix Bypass via Path Traversal Happens in Python Copier and How to Fix It

CVE-2026-53951 is a high-severity path traversal vulnerability in Copier 9.15.0 that allowed attackers to bypass trust-prefix checks and execute tasks without user confirmation. Upgrading to Copier 9.15.2 eliminates this attack vector by properly validating file paths before task execution.

high

package_abridge.js Command Injection via Unsanitized CLI Arguments

A high-severity command injection vulnerability in a build script allowed attackers who control CLI arguments to execute arbitrary shell commands by injecting metacharacters into an unvalidated parameter. The fix validates incoming CLI arguments and rejects those containing dangerous shell metacharacters before they reach command execution.