Back to Blog
high SEVERITY7 min read

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.

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 script `scripts/diff-docx.js`, where command-line arguments were passed directly to `fs.writeFileSync()` without sanitization. An attacker could invoke the script with `--output ../../../tmp/malicious.md` to write files anywhere on the filesystem. The fix applies `path.resolve()` to all user-supplied paths and enforces a working-directory boundary check, rejecting any resolved path that falls outside `process.cwd()`.

Vulnerability at a Glance

cweCWE-22
fixWrap all paths with path.resolve() and reject outputs outside process.cwd()
riskArbitrary file write to any location accessible by the process
languageJavaScript (Node.js)
root causeUser-supplied CLI arguments used directly in fs.writeFileSync() without path sanitization
vulnerabilityPath Traversal

How Path Traversal Happens in Node.js Scripts and How to Fix It

Introduction

The scripts/diff-docx.js file handles a focused task: comparing two .docx files and writing a diff to a specified output path. It's a utility script — the kind of thing developers write quickly and rarely revisit. But a flaw at line 23 turned a routine fs.writeFileSync() call into a potential arbitrary file write primitive. The root cause? Every path passed via process.argv was used verbatim, with no validation whatsoever.

This is a textbook path traversal vulnerability, and it's especially dangerous in a Node.js library context because the script isn't just run by trusted operators — it's shipped to downstream consumers who may invoke it in automated pipelines, CI/CD systems, or untrusted environments.


The Vulnerability Explained

What the Code Did Before the Fix

Before the patch, the script extracted file paths directly from command-line arguments and handed them straight to file system APIs:

// VULNERABLE — before the fix (diff-docx.js:27-31)
const baselinePath = args[0];
const currentPath = args[1];
const outputIndex = args.indexOf('--output');
const outputPath = outputIndex !== -1 ? args[outputIndex + 1] : null;

Later in the script, outputPath was passed directly to fs.writeFileSync(). There was no call to path.resolve(), no check that the path stayed within a safe directory, and no sanitization of traversal sequences.

Why This Is Dangerous

The --output flag is the critical attack surface. When a caller supplies:

node scripts/diff-docx.js baseline.docx current.docx --output ../../../tmp/malicious.md

Node.js resolves ../../../tmp/malicious.md relative to the current working directory. If the script is run from /app/scripts/, the write lands at /tmp/malicious.md — completely outside the project. With the right traversal depth and target path, an attacker could:

  • Overwrite configuration files (e.g., ../../../etc/cron.d/job on writable systems)
  • Inject content into web-served directories (e.g., ../../public/index.html)
  • Corrupt application state by overwriting data files in sibling directories

The same risk applies to baselinePath and currentPath — a malicious caller could point these at sensitive files to probe the filesystem via error messages or timing side-channels.

Real-World Attack Scenario

Consider a CI/CD pipeline that runs this script automatically after a document change:

# Automated pipeline step — attacker controls the PR that sets DOCX_OUTPUT
node scripts/diff-docx.js $BASELINE $CURRENT --output $DOCX_OUTPUT

If DOCX_OUTPUT is derived from user-controlled input (a PR branch name, a filename in a repository, a webhook payload), an attacker submitting --output ../../.github/workflows/deploy.yml could overwrite a GitHub Actions workflow file with arbitrary content — turning a document diff tool into a supply chain attack vector.


The Fix

The patch makes three targeted changes, all in scripts/diff-docx.js:

1. Canonicalize All Input Paths with path.resolve()

// BEFORE
const baselinePath = args[0];
const currentPath = args[1];
const outputPath = outputIndex !== -1 ? args[outputIndex + 1] : null;

// AFTER
const baselinePath = path.resolve(args[0]);
const currentPath = path.resolve(args[1]);
const outputPath = outputIndex !== -1 ? path.resolve(args[outputIndex + 1]) : null;

path.resolve() converts any relative path — including those containing ../ sequences — into an absolute path. This collapses traversal sequences before they reach any file system call. For example:

path.resolve('/app/scripts', '../../../tmp/malicious.md')
// → '/tmp/malicious.md'

The traversal is neutralized into a plain absolute path, which can then be checked.

2. Enforce a Working-Directory Boundary for Output

// NEW — added after path resolution
if (outputPath && !outputPath.startsWith(process.cwd() + path.sep)) {
  console.error('Output path must be within the current working directory');
  process.exit(1);
}

This is the critical guard. After resolving the path, the script asserts that the result begins with process.cwd() followed by the platform path separator. The path.sep suffix is important — without it, a directory named /app/scripts-evil/ could pass a check against /app/scripts because the string prefix matches. Adding path.sep ensures the boundary is at a real directory boundary, not just a string prefix.

Before vs. After — Side by Side

Aspect Before After
Path resolution Raw string from process.argv path.resolve() applied
Traversal sequences Passed through to fs.writeFileSync() Collapsed by path.resolve()
Boundary enforcement None Must start with process.cwd() + path.sep
Invalid path behavior Silent write to arbitrary location Error message + process.exit(1)

Prevention & Best Practices

Always Resolve Before You Restrict

The pattern used in this fix — resolve first, then check the prefix — is the correct approach for path validation in Node.js. Never try to sanitize traversal sequences by stripping ../ from raw strings; this approach is fragile and bypassable with encoded variants like %2e%2e%2f or null bytes.

// ✅ Correct pattern
const resolved = path.resolve(userInput);
if (!resolved.startsWith(allowedBase + path.sep)) {
  throw new Error('Path outside allowed directory');
}

// ❌ Fragile — don't do this
const sanitized = userInput.replace(/\.\.\//g, '');

Validate CLI Arguments Explicitly

Scripts that accept command-line arguments should validate them with the same rigor as HTTP request parameters. Consider using a CLI argument parsing library like yargs or commander with explicit schema validation, rather than accessing process.argv directly.

Apply the Principle of Least Privilege

If a script only needs to write to one specific directory, encode that constraint in the code. Don't allow "any path under cwd" if the script only ever needs to write to ./output/. The tighter the allowed set, the smaller the attack surface.

Relevant Standards

  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
  • OWASP Path Traversal: OWASP Testing Guide — Path Traversal
  • Node.js path module: Use path.resolve() and path.normalize() for all user-supplied paths

Key Takeaways

  • process.argv is untrusted input — treat it with the same skepticism as an HTTP request body. In diff-docx.js, three arguments were consumed without any validation.
  • path.resolve() alone is not a security control — it canonicalizes the path, but you still need a boundary check. The fix correctly combines both steps.
  • The path.sep suffix in startsWith() prevents prefix-collision bypasses — a subtle but important detail that distinguishes a correct fix from an incomplete one.
  • Utility scripts in libraries are part of the attack surfacediff-docx.js ships with the package, so every consumer who runs it inherits the vulnerability.
  • Fail closed with process.exit(1) — when the path check fails, the script exits immediately rather than falling through to a potentially dangerous default behavior.

How Orbis AppSec Detected This

  • Source: Command-line argument args[outputIndex + 1] derived from process.argv in scripts/diff-docx.js
  • Sink: fs.writeFileSync(outputPath, ...) — a direct file write using the unsanitized path
  • Missing control: No call to path.resolve(), no directory boundary assertion, and no rejection of paths containing traversal sequences before the write operation
  • CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
  • Fix: All three user-supplied paths are now wrapped with path.resolve(), and outputPath is additionally checked to confirm it starts with process.cwd() + path.sep 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 in CLI scripts are easy to introduce and easy to overlook — precisely because scripts feel less "exposed" than web endpoints. But in a Node.js library, a script that accepts user-controlled paths and writes files without boundary checks is a genuine security risk for every downstream consumer.

The fix in diff-docx.js demonstrates the correct pattern: resolve paths to their absolute form with path.resolve(), then assert they fall within the expected directory using a startsWith() check that accounts for path separator boundaries. This two-step approach is robust, idiomatic in Node.js, and leaves valid inputs completely unaffected.

If your codebase has scripts that accept file paths from the command line, now is a good time to audit them with the same lens.


References

Frequently Asked Questions

What is a path traversal vulnerability?

A path traversal vulnerability occurs when user-controlled input is used to construct file system paths without validation, allowing attackers to access or write files outside the intended directory using sequences like `../`.

How do you prevent path traversal in Node.js?

Use `path.resolve()` to canonicalize paths, then verify the resolved path starts with the expected base directory using `startsWith(process.cwd() + path.sep)` before performing any file operations.

What CWE is path traversal?

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

Is input escaping enough to prevent path traversal in Node.js?

No. Escaping alone is insufficient because traversal sequences can be encoded in multiple ways. The reliable fix is to resolve the full absolute path and then assert it falls within an allowed directory prefix.

Can static analysis detect path traversal vulnerabilities?

Yes. Static analysis tools like Semgrep, ESLint security plugins, and AI-powered scanners like Orbis AppSec can trace tainted data from sources like `process.argv` to dangerous sinks like `fs.writeFileSync()` and flag missing boundary checks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #225

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

critical

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.

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

high

How Missing pnpm Trust Policy and Release Age Settings Happen in Node.js Workspaces and How to Fix Them

A pnpm workspace configuration was missing two critical security hardening settings — `trustPolicy` and `minimumReleaseAge` — leaving the project vulnerable to malicious package updates and newly published, potentially compromised package versions. The fix adds `trustPolicy: no-downgrade`, `minimumReleaseAge: 10080`, and `blockExoticSubdeps: true` to `pnpm-workspace.yaml`, raising the security bar against supply chain attacks. These settings, available since pnpm v10.16.0 and v10.21.0 respective