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 PostCSS Source Map Auto-Loading and how to fix it

A high-severity path traversal vulnerability (GHSA-r28c-9q8g-f849) in PostCSS versions prior to 8.5.18 allowed attackers to abuse the `sourceMappingURL` comment auto-loading mechanism to read arbitrary `.map` files outside the intended directory. The fix upgrades PostCSS from 8.5.15 to 8.5.18 in `frontend/package-lock.json` and pins the version via an `overrides` block in `frontend/package.json`. This closes a file disclosure primitive that, while not independently exploitable in all configurati

high

How path traversal happens in Python file handling and how to fix it

A path traversal vulnerability in `scripts/merge_m3u.py` allowed user-influenced file paths returned by `glob.glob()` to escape the intended `custom/` directory boundary, potentially exposing arbitrary files on the system. The fix adds a `os.path.realpath()` check that filters out any resolved path that falls outside the expected directory. This is a proactive hardening measure that removes an exploit primitive before it can be chained with other weaknesses.

high

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

A path traversal vulnerability in `scripts/diff-docx.js` allowed attackers to supply crafted `--output` arguments containing `../` sequences, enabling arbitrary file writes outside the intended working directory. The fix uses `path.resolve()` combined with a working-directory boundary check to ensure all output paths stay within safe bounds. This matters because the script is part of a Node.js library, meaning every downstream consumer was exposed to the same risk.

high

How Path Traversal happens in PostCSS Source Map Loading and how to fix it

A path traversal vulnerability in PostCSS versions before 8.5.18 allowed malicious `sourceMappingURL` comments in CSS files to trick PostCSS into loading arbitrary `.map` files from the filesystem. The fix upgrades PostCSS from 8.5.15 to 8.5.18 in `frontend/package-lock.json` and pins the version via an override in `frontend/package.json`, closing the file disclosure vector before it could be chained with other weaknesses.

high

How Path Traversal happens in Node.js temporary file creation and how to fix it

CVE-2026-44705 is a high-severity path traversal vulnerability in the Node.js `tmp` package where unsanitized `prefix` and `postfix` options allow attackers to escape the intended temporary directory. Three separate nested copies of `tmp` — versions `0.0.28` and `0.2.7` pinned under `can-symlink`, `broccoli`, and `ember-template-recast` — were removed from `package-lock.json` and replaced by a single patched `0.2.6` resolution. The fix eliminates the directory-escape attack surface while leaving

critical

How Missing Rate Limiting happens in Express.js and how to fix it

Two public API endpoints in `server.js` — `/api/health` and `/api/contact` — were exposed without any rate limiting middleware, allowing attackers to exhaust server resources or spam an SMTP server with unlimited requests. The fix adds rate limiting to both endpoints, with stricter controls on the resource-intensive `/api/contact` route that triggers email sending operations. This change closes a directly exploitable denial-of-service vector in a production web service.