Introduction
The scripts/serve.mjs file in this project implements a lightweight development file server, designed to serve static files during local development. However, a critical flaw on line 5 created a dangerous security gap: the script accepted any directory path from process.argv[2] and passed it directly to resolve() without validating whether that path stayed within the project boundaries.
Here's the vulnerable code pattern:
const root = resolve(process.argv[2] ?? '.');
This single line, while seemingly innocuous, allowed anyone who could influence the command-line arguments—whether through a compromised build script, malicious CI/CD configuration, or social engineering—to serve arbitrary directories from the host filesystem over HTTP.
The Vulnerability Explained
What Made This Code Dangerous
The resolve() function from Node.js's path module converts any path to an absolute path. When combined with unvalidated user input from process.argv[2], it becomes a gateway to the entire filesystem.
Consider what happens when an attacker modifies the invocation:
node scripts/serve.mjs /etc
The server would happily serve the contents of /etc over HTTP on port 4173. An attacker could then access:
/etc/passwd- user account information/etc/shadow(if permissions allow) - password hashes- Application configuration files with database credentials
- SSH keys from home directories
- Environment files containing API tokens
The Attack Chain
This vulnerability requires a 2-step exploitation chain:
-
Gain influence over the command-line arguments: This could happen through:
- Compromising the CI/CD pipeline configuration
- Modifying build scripts in a malicious PR
- Social engineering a developer to run a modified command -
Access the exposed files: Once the server starts with a malicious root directory, the attacker accesses sensitive files via HTTP requests to
http://localhost:4173/passwdor similar paths.
Real-World Impact
In a development environment, this vulnerability is particularly dangerous because:
- Development machines often have broader filesystem access than production servers
- Developers may have SSH keys, cloud credentials, and API tokens accessible
- CI/CD runners might expose secrets mounted as files
- The development server likely runs without authentication
The Fix
The fix adds a critical validation check immediately after resolving the path:
Before (Vulnerable)
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize, resolve } from 'node:path';
const root = resolve(process.argv[2] ?? '.');
const port = Number(process.env.PORT ?? 4173);
After (Secure)
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize, resolve } from 'node:path';
const root = resolve(process.argv[2] ?? '.');
if (!root.startsWith(process.cwd())) {
console.error(`Error: serve root "${root}" is outside the current working directory.`);
process.exit(1);
}
const port = Number(process.env.PORT ?? 4173);
Why This Fix Works
The fix implements a directory boundary check using startsWith():
process.cwd()returns the current working directory—the directory from which the script was invokedroot.startsWith(process.cwd())ensures the resolved serve root is either the current directory or a subdirectory within it- If the check fails, the script exits immediately with a clear error message, preventing the server from starting
This approach follows the principle of allowlisting: instead of trying to block malicious patterns (which attackers can often bypass), it explicitly defines what's allowed and rejects everything else.
Attempting to Exploit the Fixed Code
# Attacker tries to serve /etc
$ node scripts/serve.mjs /etc
Error: serve root "/etc" is outside the current working directory.
# Attacker tries path traversal
$ node scripts/serve.mjs ../../../etc
Error: serve root "/etc" is outside the current working directory.
# Legitimate use still works
$ node scripts/serve.mjs ./dist
# Server starts normally, serving ./dist
Prevention & Best Practices
1. Always Validate Path Boundaries
When accepting paths from any external source, validate they stay within intended boundaries:
function validatePath(userPath, allowedRoot) {
const resolved = path.resolve(allowedRoot, userPath);
if (!resolved.startsWith(path.resolve(allowedRoot))) {
throw new Error('Path escapes allowed directory');
}
return resolved;
}
2. Use Path Comparison Carefully
Be aware of edge cases:
// WRONG: "startsWith" can be fooled
'/home/user'.startsWith('/home/use') // true, but '/home/user' !== '/home/use'
// BETTER: Ensure directory boundary
const safePath = resolved.startsWith(allowedRoot + path.sep) || resolved === allowedRoot;
3. Limit Development Server Scope
- Never run development servers with elevated privileges
- Use containers or VMs to isolate development environments
- Configure firewalls to prevent external access to development ports
4. Secure Your CI/CD Pipeline
- Review all changes to build scripts and CI configurations
- Use signed commits for infrastructure-as-code
- Implement approval requirements for CI/CD configuration changes
Key Takeaways
- Never trust
process.argvfor filesystem paths without validating boundaries against a known-safe root directory - The
resolve()function doesn't provide security—it only normalizes paths; boundary validation is your responsibility - Development tools need security too—attackers often target build systems and development infrastructure
- A 4-line fix prevented potential full filesystem exposure—simple validation checks have outsized security impact
startsWith(process.cwd())is an effective boundary check for development servers that should only serve project files
How Orbis AppSec Detected This
- Source: Command-line argument
process.argv[2]inscripts/serve.mjs:5 - Sink:
resolve()function call used to set the HTTP server's file serving root - Missing control: No validation that the resolved path stays within the project directory
- CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)
- Fix: Added a
startsWith(process.cwd())check that terminates the process if the serve root escapes the current working 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
This path traversal vulnerability in serve.mjs demonstrates how even simple development utilities can introduce critical security risks. The unvalidated process.argv[2] argument allowed potential attackers to expose any directory on the filesystem through the development server.
The fix—a simple four-line boundary check—shows that effective security doesn't always require complex solutions. By validating that the serve root stays within process.cwd(), the script now safely rejects any attempt to serve directories outside the project.
When building development tools, remember: convenience should never come at the cost of security. Always validate paths, always assume inputs are malicious, and always implement the simplest effective control.