Back to Blog
critical SEVERITY5 min read

How Path Traversal Vulnerabilities Happen in Node.js Development Servers and How to Fix Them

A critical path traversal vulnerability was discovered in the development file server script `serve.mjs`, where arbitrary directory paths from command-line arguments were accepted without validation. This flaw could allow attackers to serve any directory on the filesystem over HTTP, potentially exposing sensitive system files like `/etc/passwd` or application secrets. The fix adds a simple but effective validation check ensuring the serve root stays within the current working directory.

O
By Orbis AppSec
Published August 15, 2026Reviewed August 15, 2026

Answer Summary

Path traversal (CWE-22) in Node.js occurs when file paths from untrusted input aren't validated against a safe base directory. In `serve.mjs`, the `process.argv[2]` argument was passed directly to `resolve()` without checking if the resulting path escaped the project root. The fix validates that the resolved path starts with `process.cwd()`, preventing directory traversal attacks.

Vulnerability at a Glance

cweCWE-22
fixValidate that resolved path starts with current working directory
riskFilesystem exposure via HTTP, sensitive data leakage
languageJavaScript (Node.js)
root causeCommand-line directory argument accepted without boundary validation
vulnerabilityPath Traversal / Arbitrary Directory Serving

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:

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

  2. 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/passwd or 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():

  1. process.cwd() returns the current working directory—the directory from which the script was invoked
  2. root.startsWith(process.cwd()) ensures the resolved serve root is either the current directory or a subdirectory within it
  3. 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.argv for 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] in scripts/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.

References

Frequently Asked Questions

What is path traversal?

Path traversal is a vulnerability where attackers manipulate file paths to access directories outside the intended scope, often using sequences like `../` or absolute paths to escape restricted areas.

How do you prevent path traversal in Node.js?

Validate that resolved paths start with your intended base directory using `path.resolve()` combined with a `startsWith()` check against the allowed root, and never trust user-supplied path components directly.

What CWE is path traversal?

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

Is using path.normalize() enough to prevent path traversal?

No, `normalize()` only cleans up the path syntax but doesn't prevent escaping the intended directory; you must also validate the final resolved path stays within your allowed boundary.

Can static analysis detect path traversal?

Yes, static analysis tools can detect path traversal by tracking tainted data flow from user input to filesystem operations and flagging missing boundary validation.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #4

Related Articles

critical

How Insufficient Input Validation happens in TypeScript and how to fix it

A critical input validation vulnerability was discovered in `src/mcp/presets/commerce/inputs.ts` where the `normalizeCommerceAccountInput` function accepted loosely-typed `Record<string, any>` arguments without verifying field types or object structure. This allowed attackers to inject malicious payloads through MCP tool invocations. The fix adds explicit type guards and structural validation to ensure only properly-typed string values reach downstream consumers.

high

How Command Injection happens in Node.js child_process calls and how to fix it

A high-severity command injection vulnerability was discovered in `src/collectors/git.ts`, where `execSync` was used to build a shell command by interpolating unsanitized arguments into a template string. By replacing `execSync` with `spawnSync`, the fix eliminates shell interpretation entirely, ensuring that git arguments are passed directly to the process without ever touching a shell. This change is especially important for a Node.js library, where downstream consumers may pass user-controlle

critical

How Plaintext Credential Storage happens in JSON Configuration Files and how to fix it

A critical security issue was discovered in `assets/settings/global.json` where a real phone number (PII) was stored in plaintext alongside placeholder patterns for API keys and payment credentials. This design encouraged developers to substitute real credentials directly into a version-controlled file, creating a high risk of credential exposure via repository access or filesystem reads. The fix replaces the hardcoded phone number with a placeholder and reinforces safe configuration patterns.

high

How Quadratic CPU Consumption Vulnerabilities Happen in JavaScript YAML Parsers and How to Fix Them

A high-severity denial-of-service vulnerability in js-yaml versions 3.x and 4.x allowed attackers to trigger quadratic CPU consumption through specially crafted YAML documents using the !!omap tag. This fix upgrades js-yaml from 4.1.1 to 4.3.1 and from 3.14.2 to 3.15.1, eliminating the algorithmic complexity attack vector that could freeze Node.js applications processing untrusted YAML input.

high

How javascript.lang.security.detect-child-process.detect-child-process happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `scripts/build.js` where `execSync` was called with string-interpolated arguments (`sourceDir` and `outputPath`) inside a shell command. By replacing `execSync` with `spawnSync` using an argument array (no shell), the fix eliminates the possibility of shell metacharacter injection while preserving identical build behavior.

critical

How Supply Chain Timing Attacks happen in pnpm Workspaces and how to fix it

The apple-mail-mcp repository was vulnerable to supply chain timing attacks because its pnpm workspace configuration only enforced a 1-day (1440 minute) minimum release age for newly published packages. This allowed a 5-day-old transitive dependency (ip-address@10.5.0) to be installed despite Dependabot's 7-day cooldown, creating a window where malicious or unstable packages could enter the dependency tree. The fix raises minimumReleaseAge to 10080 minutes (7 days) to ensure all packages—includi