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
Published August 26, 2026Reviewed 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.


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


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 mitigationassertSafePath 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.


References

Frequently Asked Questions

What is a path traversal vulnerability?

Path traversal (CWE-22) occurs when user-supplied input containing sequences like `../` is used to construct a file path without validation, allowing access to files or directories outside the intended scope.

How do you prevent path traversal in Node.js?

Resolve the full absolute path with `path.resolve()`, then verify it starts with your allowed base directory using `startsWith(baseDir + path.sep)`. Reject or sanitize any path that fails this check.

What CWE is path traversal?

Path traversal is classified as CWE-22: Improper Limitation of a Pathname to a Restricted Directory.

Is stripping `../` sequences enough to prevent path traversal?

No. Simple string replacement can be bypassed with encoded sequences (`%2e%2e%2f`), null bytes, or symlinks. Resolving the real absolute path and checking containment is the reliable approach.

Can static analysis detect path traversal?

Yes. Tools like Semgrep, CodeQL, and multi-agent AI scanners can trace tainted data from CLI arguments or HTTP parameters to dangerous file-system sinks like `fs.writeFile` or Playwright's `screenshot()`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #7

Related Articles

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.

critical

How Command Injection happens in Python subprocess calls and how to fix it

A critical command injection vulnerability was discovered in `spider/php/crawler.py` where the `PHPBridge.call()` method passed unvalidated external arguments directly to `subprocess.run()`. An attacker controlling the `spider_path` or `method` parameters could execute arbitrary PHP scripts or inject malicious method names. The fix adds strict input validation — requiring `method` to be a valid Python identifier and `spider_path` to resolve to an existing `.php` file — before any subprocess exec

critical

How Path Traversal in basic-ftp Leads to File Overwrite Attacks and How to Fix It

CVE-2026-27699 is a critical path traversal vulnerability in basic-ftp versions before 5.3.1 that allows attackers to overwrite arbitrary files on the system by crafting malicious file paths. This vulnerability was fixed by upgrading basic-ftp and enforcing strict version constraints across dependent packages. Understanding this attack and its mitigation is essential for developers using FTP libraries in production environments.

critical

How Command Injection Vulnerabilities Happen in Python Subprocess Calls and How to Fix Them

A critical command injection vulnerability was discovered in `src/unused/server/fft.py` where external binaries like `oggenc` and `cocoa_text` were executed with file path parameters that could be manipulated by user input. Although `shell=False` was used, the lack of input validation allowed attackers to potentially trigger processing of arbitrary files or cause denial of service. This fix implements proper path validation to prevent exploitation.

critical

How Path Traversal happens in Node.js Express servers and how to fix it

A path traversal vulnerability in `src/server.js` allowed attackers to escape the intended wiki directory by sending encoded traversal sequences through the `/api/pages/:slug(*)` wildcard endpoint. The flawed `startsWith` boundary check could be bypassed after `decodeURIComponent` processing, potentially exposing arbitrary files on the server. The fix replaces the inline filesystem logic with a dedicated `readWikiPage()` function that enforces proper path validation.