Back to Blog
critical SEVERITY8 min read

How Path Traversal happens in Node.js CLI tools and how to fix it

A path traversal vulnerability in `tools/shot.mjs` allowed attackers to supply a malicious file path as a CLI argument, causing Playwright's `screenshot()` method to write files to arbitrary filesystem locations — including sensitive system directories. The fix introduces a new `safepath.mjs` module that resolves and validates every output path against the project root before any file is written.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed August 26, 2026

Answer Summary

This is a path traversal vulnerability (CWE-22) in the Node.js CLI tool `tools/shot.mjs`, where the output file path was taken directly from `process.argv` without validation and passed to Playwright's `screenshot()` method. An attacker invoking the tool with a path like `../../etc/cron.d/pwned` could write arbitrary files anywhere on the filesystem. The fix adds a new `tools/safepath.mjs` module with an `assertSafePath()` function that resolves the absolute path, walks up to the nearest real ancestor, and rejects any path that does not start with the project root — preventing writes outside the project directory entirely.

Vulnerability at a Glance

cweCWE-22
fixNew safepath.mjs module validates all output paths are confined to PROJECT_ROOT before use
riskAttacker can write arbitrary files to sensitive filesystem locations
languageJavaScript (Node.js)
root causeCLI argument passed directly to Playwright screenshot() with no path validation
vulnerabilityPath Traversal

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

  1. resolve(filePath) — converts relative paths and collapses ../ sequences into an absolute canonical form. This defeats simple ../ injection.

  2. 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 realpathSync until 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.

  3. startsWith(PROJECT_ROOT + sep) — the containment check appends path.sep (a / on POSIX) before comparing. This is critical: without the separator, a project root of /home/user/myproject would incorrectly allow /home/user/myproject-evil/output.png.

  4. 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-creates shots/output.png as 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.


Key Takeaways

  • process.argv is untrusted input — the outputPath variable in shot.mjs came 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 a startsWith(root + sep) check.
  • Symlink attacks require explicit mitigation — assertSafePath specifically 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.png example would have broken with the new validation; updating it ensures documented usage stays consistent with security constraints.
  • Separating validation into safepath.mjs makes 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]) in tools/shot.mjs — fully attacker-controlled when the tool is invoked from a shell or build script.
  • Sink: Playwright's page.screenshot({ path: outputPath }) call in tools/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.mjs module exports assertSafePath(), which resolves the absolute path, walks up to the nearest real ancestor, verifies containment within PROJECT_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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #7

Related Articles

high

clean_path() Path Traversal: rel_path Escapes USERDATA Dir

A path traversal flaw in the `clean_path()` helper let a user-controlled `rel_path` value escape the intended USERDATA directory and reach arbitrary files on disk. The function joined path segments without checking the final result, so `../` sequences in route parameters or query strings could be used to read or write files outside the sandboxed storage area. The fix normalizes the path and verifies it still resolves inside the USERDATA root before returning it, raising an error otherwise.

critical

path.resolve() Path Traversal in Node CLI's Dynamic import()

A Node.js CLI script for validating expression definitions took a file path from `process.argv[2]`, resolved it with `path.resolve()`, and passed the result straight into a dynamic `import()` — with no check that the resolved path stayed inside the working directory. An attacker (or a malicious skill/plugin invocation) could supply traversal sequences to load and execute arbitrary `.js` files from anywhere on disk. The fix adds a boundary check with `path.relative()` and an extension allowlist b

high

bookDir() Path Traversal via Unsanitized bookId Parameter

The `bookDir()` function accepted unsanitized `bookId` values derived from user-created book titles, enabling path traversal attacks through `../` sequences. A fix was applied that validates the identifier using `path.basename()` and throws on mismatch, ensuring all resolved paths remain within `LIBRARY_DIR`.

high

Express `app.get('*')` Wildcard Handler Path Traversal in watch.js

A first-party Express server's wildcard route handler used `req.url.indexOf('font.woff2')` to gate access to a font file, allowing attackers to bypass the substring check with crafted paths. The fix replaces the catch-all handler with explicit route registration.

high

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

high

markitdown_bridge.py Path Traversal: Arbitrary File Read via sys.argv

The markitdown_bridge.py script, used by MDView for DOCX-to-Markdown conversion, accepted file paths directly from command-line arguments without validating they stayed within intended directories. An attacker could exploit this to read arbitrary files from the filesystem by passing path traversal sequences in the source_path parameter.