How Path Traversal Vulnerabilities Happen in Node.js Build Scripts and How to Fix It
Introduction
In the scripts/build-all.js file, a seemingly innocent command-line argument handler created a critical security gap. The script accepted an --output parameter to specify where built artifacts should be written, but it never verified that the resolved path actually stayed within the intended output directory. This allowed attackers to escape the sandbox and access arbitrary locations on the filesystem—a classic path traversal vulnerability.
Consider this attack scenario: An attacker could execute:
npm run build:paper -- --output ../../../../etc/passwd
Or on Windows:
npm run build:paper -- --output C:\Windows\System32\config\SAM
The vulnerable code would happily resolve these paths and attempt file operations outside the repository, potentially overwriting critical system files or exfiltrating sensitive data.
The Vulnerability Explained
Let's examine the vulnerable code from scripts/build-all.js:
const outputArg = process.argv.includes('--output') ? process.argv[process.argv.indexOf('--output') + 1] : 'site/papers';
const outputRoot = path.resolve(ROOT, outputArg);
// ... rest of script uses outputRoot for file operations
The problem: The code extracts the --output argument directly from process.argv and resolves it relative to ROOT. However, path.resolve() has a subtle behavior: if the second argument is an absolute path, it ignores the first argument entirely.
Here's how the attack works:
- User provides:
--output /etc/passwd - Code calls:
path.resolve(ROOT, '/etc/passwd') - Result:
path.resolve()returns/etc/passwd(the absolute path wins) - Impact: The script now writes to a system file instead of the repository
Even with relative paths, the traversal works:
- User provides:
--output ../../../../etc/passwd - Code calls:
path.resolve(ROOT, '../../../../etc/passwd') - Result:
path.resolve()normalizes the path and escapes ROOT - Impact: Files are written outside the intended directory
The real-world impact is severe: in a CI/CD pipeline, this could allow an attacker to:
- Overwrite application code
- Inject malicious build artifacts
- Exfiltrate source code or secrets
- Corrupt the build environment
- Escalate privileges by modifying system files (if running with elevated permissions)
The Fix
The security team added a simple but critical validation check immediately after path resolution:
const outputArg = process.argv.includes('--output') ? process.argv[process.argv.indexOf('--output') + 1] : 'site/papers';
const outputRoot = path.resolve(ROOT, outputArg);
// NEW: Validate that the output path stays within ROOT
if (outputRoot !== ROOT && !outputRoot.startsWith(ROOT + path.sep)) {
throw new Error(`--output path must be within the repository root: ${outputRoot}`);
}
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
How this fix works:
outputRoot !== ROOT: Allows the output directory to be ROOT itself (valid use case)!outputRoot.startsWith(ROOT + path.sep): Ensures the resolved path starts with the ROOT directory followed by a path separator (preventing partial string matches like/repo-evilmatching/repo)- Throws an error: If validation fails, the script terminates immediately with a clear error message
The path.sep is crucial—without it, a check like outputRoot.startsWith(ROOT) would incorrectly allow /repo-evil/output if ROOT was /repo.
Why this specific approach:
- Simple and fast: No filesystem calls or complex logic
- Cross-platform: Works on Windows (
\) and Unix (/) due topath.sep - Fail-secure: Rejects suspicious paths rather than trying to sanitize them
- Clear error message: Developers immediately understand what went wrong
Prevention & Best Practices
To avoid path traversal vulnerabilities in your Node.js applications:
1. Always Validate User-Supplied Paths
// ❌ BAD: No validation
const userPath = getUserInput();
fs.readFile(userPath, callback);
// ✅ GOOD: Validate boundaries
const basePath = path.resolve(__dirname, 'uploads');
const userPath = path.resolve(basePath, userInput);
if (!userPath.startsWith(basePath + path.sep)) {
throw new Error('Invalid path');
}
fs.readFile(userPath, callback);
2. Use path.relative() for Verification
An alternative approach uses path.relative() to ensure the path doesn't escape:
const basePath = path.resolve(__dirname, 'uploads');
const userPath = path.resolve(basePath, userInput);
const relative = path.relative(basePath, userPath);
// If relative path starts with '..', it escaped the base
if (relative.startsWith('..')) {
throw new Error('Path traversal detected');
}
3. Implement Whitelist-Based Approach
For maximum security, use a whitelist of allowed paths:
const ALLOWED_OUTPUTS = ['site/papers', 'dist', 'build'];
if (!ALLOWED_OUTPUTS.includes(outputArg)) {
throw new Error(`Output must be one of: ${ALLOWED_OUTPUTS.join(', ')}`);
}
4. Use Static Analysis Tools
Enable security-focused linters and SAST tools:
- ESLint with
eslint-plugin-security - Semgrep with path traversal rules
- Snyk for dependency and code scanning
- SonarQube for comprehensive SAST analysis
5. Principle of Least Privilege
- Run build scripts with minimal file system permissions
- Use containerization to isolate build environments
- Avoid running builds as root or with admin privileges
6. Input Validation Standards
Follow OWASP guidelines:
- Reject rather than sanitize (blacklists are incomplete)
- Use allowlists when possible
- Validate type, length, format, and range
- Encode output appropriately for the context
Key Takeaways
- Never trust
path.resolve()alone: It normalizes paths but doesn't enforce boundaries—absolute paths bypass the base directory entirely. - Always validate resolved paths: Check that
path.startsWith(basePath + path.sep)to prevent partial string matches and directory escape. - The
--outputargument in build scripts is high-risk: Build automation is a prime attack target; validate all command-line arguments that affect file operations. - Use
path.sepin boundary checks: Prevents edge cases where/repocould match/repo-evil; always include the separator. - Fail-secure, not fail-open: When path validation fails, throw an error immediately rather than attempting to sanitize or guess the user's intent.
How Orbis AppSec Detected This
Source: The --output command-line argument extracted from process.argv in scripts/build-all.js:9
Sink: The path.resolve(ROOT, outputArg) call that creates outputRoot without validating its boundaries
Missing control: No validation that the resolved outputRoot path remains within the ROOT directory; no check for absolute paths or path traversal sequences
CWE: CWE-22 - Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Fix: Added a conditional check immediately after path resolution to verify that outputRoot either equals ROOT or starts with ROOT + path.sep, throwing an error if the path escapes the intended directory.
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 among the most exploitable file system issues because they're often overlooked during code review. The fix in build-all.js demonstrates that robust security requires just a few lines of validation code—but those lines must be present before any file operations.
The lesson here extends beyond this specific script: any user-controlled input that influences file paths must be validated. Whether it's a command-line argument, HTTP request parameter, or configuration file, the same principle applies: resolve the path, validate the boundary, and fail securely if the path escapes.
By adopting these practices—boundary validation, fail-secure error handling, and continuous static analysis—you can eliminate an entire class of vulnerabilities from your Node.js applications.