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/jobon 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
pathmodule: Usepath.resolve()andpath.normalize()for all user-supplied paths
Key Takeaways
process.argvis untrusted input — treat it with the same skepticism as an HTTP request body. Indiff-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.sepsuffix instartsWith()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 surface —
diff-docx.jsships 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 fromprocess.argvinscripts/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(), andoutputPathis additionally checked to confirm it starts withprocess.cwd() + path.sepbefore 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.