Introduction
The tools/shot.mjs file is a lightweight Node.js CLI utility that uses Playwright to capture screenshots of rendered scenes. Its job is simple: accept a destination path as a command-line argument and write a PNG file there. That simplicity, however, concealed a critical security flaw — the output path was taken verbatim from process.argv[1] (the first CLI argument) and handed directly to Playwright's screenshot() method with zero validation.
This pattern — tainted input flows straight to a filesystem sink — is the textbook definition of a path traversal vulnerability (CWE-22). Because the tool is distributed as part of a Node.js library, every downstream consumer who invokes it is exposed.
The Vulnerability Explained
What the code did
Before the fix, tools/shot.mjs accepted the output file path directly from the command line and passed it, unmodified, to Playwright:
// tools/shot.mjs (before fix — simplified)
const outputPath = process.argv[2]; // ← raw, unvalidated user input
await page.screenshot({ path: outputPath }); // ← arbitrary write to filesystem
There was no call to path.resolve(), no containment check, and no sanitization of ../ sequences. Playwright dutifully followed whatever path it was given.
How an attacker exploits this
The PR's own evidence section describes the attack precisely:
node tools/shot.mjs ../../etc/cron.d/pwned
On a Linux system where the tool runs with sufficient privileges, this single command writes a Playwright screenshot (a valid PNG binary) to /etc/cron.d/pwned. Because cron reads files in that directory as job definitions, a crafted PNG whose early bytes contain valid cron syntax could schedule arbitrary commands to execute as root.
Other high-value targets include:
| Malicious path | Effect |
|---|---|
../../.ssh/authorized_keys |
Inject an attacker's SSH public key |
../../etc/cron.d/backdoor |
Schedule a reverse shell |
../../var/www/html/shell.php |
Drop a web shell on a co-hosted web server |
../../home/user/.bashrc |
Persist a payload in shell startup |
Why this matters for a library
Because tools/shot.mjs ships as part of a reusable package, the vulnerable path is not just the package maintainer's infrastructure — it is every CI pipeline, developer workstation, and build server that installs and invokes this package. A supply-chain attacker who can influence the argument passed to npm run shot (for example, through a malicious config file or a compromised build script) can leverage this to escape the project directory entirely.
The Fix
The fix introduces a brand-new module, tools/safepath.mjs, and updates tools/shot.mjs to call assertSafePath() before any screenshot is written. It also updates the README example to use a project-relative path instead of /tmp/liquid-glass.png.
The new safepath.mjs module
// tools/safepath.mjs (new file)
import { resolve, dirname, sep, basename, join } from 'path';
import { fileURLToPath } from 'url';
import { realpathSync, lstatSync } from 'fs';
export const PROJECT_ROOT = resolve(
dirname(fileURLToPath(import.meta.url)),
'..'
);
function reject(filePath) {
console.error(`Output path must be inside the project directory: ${filePath}`);
process.exit(1);
}
export function assertSafePath(filePath) {
const abs = resolve(filePath);
// Walk up to the nearest existing ancestor without creating anything
let ancestor = dirname(abs);
const suffix = [basename(abs)];
while (true) {
try { realpathSync(ancestor); break; } catch (_) {}
const parent = dirname(ancestor);
if (parent === ancestor) break; // reached filesystem root
suffix.unshift(basename(ancestor));
ancestor = parent;
}
const realOut = join(realpathSync(ancestor), ...suffix);
// Containment check before any mutation
if (!realOut.startsWith(PROJECT_ROOT + sep)) reject(filePath);
// Reject an existing output file that is itself a symlink
try {
if (lstatSync(abs).isSymbolicLink()) reject(filePath);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
Before vs. after
Before — raw argument passed to Playwright:
const outputPath = process.argv[2]; // no validation
await page.screenshot({ path: outputPath });
After — path validated before use:
import { assertSafePath } from './safepath.mjs';
const outputPath = process.argv[2];
assertSafePath(outputPath); // throws/exits if path escapes project root
await page.screenshot({ path: outputPath });
Why each part of assertSafePath matters
-
resolve(filePath)— converts relative paths and collapses../sequences into an absolute canonical form. This defeats simple../injection. -
Walking up to the nearest real ancestor — the function does not create directories to check the real path. Instead it walks up the directory tree using
realpathSyncuntil it finds an ancestor that actually exists, then reconstructs the canonical absolute path by appending the non-existent suffix components. This prevents TOCTOU (time-of-check/time-of-use) races where a directory is created between the check and the write. -
startsWith(PROJECT_ROOT + sep)— the containment check appendspath.sep(a/on POSIX) before comparing. This is critical: without the separator, a project root of/home/user/myprojectwould incorrectly allow/home/user/myproject-evil/output.png. -
Symlink rejection via
lstatSync— if the output file already exists and is a symlink, the write is rejected. This closes the symlink-based bypass where an attacker pre-createsshots/output.pngas a symlink pointing to/etc/cron.d/pwned.
README example update
-npm run shot /tmp/liquid-glass.png -- --scene 0 --size 1200x720 --no-panel
+npm run shot shots/liquid-glass.png -- --scene 0 --size 1200x720 --no-panel
This is not just cosmetic. The old example used an absolute path outside the project root (/tmp/), which would now be rejected by assertSafePath. Updating the example ensures the documented usage works correctly with the new validation.
Prevention & Best Practices
1. Always resolve and contain filesystem paths from user input
Any time user-supplied data reaches a filesystem API, apply the resolve-and-contain pattern:
import { resolve, sep } from 'path';
const ALLOWED_ROOT = resolve('/var/app/uploads');
function safePath(userInput) {
const abs = resolve(ALLOWED_ROOT, userInput);
if (!abs.startsWith(ALLOWED_ROOT + sep)) {
throw new Error('Path traversal detected');
}
return abs;
}
2. Use lstatSync (not statSync) to detect symlinks
fs.statSync follows symlinks and reports on the target. fs.lstatSync reports on the link itself. Always use lstatSync when checking whether a path is safe to write to.
3. Validate early, at the entry point
assertSafePath is called before any directory is created and before Playwright is invoked. This fail-fast approach prevents partial state (e.g., a partially created directory tree) from being left on disk after a rejected path.
4. Treat CLI arguments as untrusted input
CLI arguments are as untrusted as HTTP query parameters. Any tool that accepts a path argument and writes to it should apply the same rigor as a web application file upload handler.
5. Reference standards
- OWASP Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- OWASP Input Validation Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
Key Takeaways
process.argvis untrusted input — theoutputPathvariable inshot.mjscame directly from the command line and should have been treated with the same suspicion as an HTTP request parameter.path.resolve()alone is not enough — resolving the path collapses../sequences, but you must also verify the result is inside your allowed base directory with astartsWith(root + sep)check.- Symlink attacks require explicit mitigation —
assertSafePathspecifically rejects existing symlinks at the output path, closing a bypass that pure string-based checks miss. - The README example matters — the old
/tmp/liquid-glass.pngexample would have broken with the new validation; updating it ensures documented usage stays consistent with security constraints. - Separating validation into
safepath.mjsmakes the logic reusable, testable in isolation, and easy to audit — a single source of truth for path safety across the entire toolchain.
How Orbis AppSec Detected This
- Source: The first CLI argument (
process.argv[2]) intools/shot.mjs— fully attacker-controlled when the tool is invoked from a shell or build script. - Sink: Playwright's
page.screenshot({ path: outputPath })call intools/shot.mjs:5— a filesystem write that follows the supplied path without restriction. - Missing control: No call to
path.resolve(), no containment check against a base directory, and no symlink detection before the write. - CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal").
- Fix: A new
tools/safepath.mjsmodule exportsassertSafePath(), which resolves the absolute path, walks up to the nearest real ancestor, verifies containment withinPROJECT_ROOT, and rejects symlinks before any file operation proceeds.
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
Path traversal vulnerabilities are deceptively simple — one unvalidated variable, one filesystem write, and an attacker can reach far outside the intended sandbox. The tools/shot.mjs case is a perfect illustration: a utility that exists purely to save a screenshot became a potential vector for writing to /etc/cron.d or overwriting SSH keys, simply because process.argv[2] was trusted without question.
The fix is equally instructive. Rather than a quick replace('../', '') patch, the team built a robust, reusable safepath.mjs module that handles real-path resolution, non-existent ancestor traversal, containment checking with the correct separator, and symlink rejection. Each of those layers closes a specific bypass. Together, they make the safe path the only path.
The next time you write a CLI tool that accepts a filename argument, treat it like you would a filename uploaded through a web form — because from a security standpoint, it is exactly the same problem.