Back to Blog
critical SEVERITY8 min read

How Path Traversal happens in Node.js Express servers and how to fix it

A path traversal vulnerability in `src/server.js` allowed attackers to escape the intended wiki directory by sending encoded traversal sequences through the `/api/pages/:slug(*)` wildcard endpoint. The flawed `startsWith` boundary check could be bypassed after `decodeURIComponent` processing, potentially exposing arbitrary files on the server. The fix replaces the inline filesystem logic with a dedicated `readWikiPage()` function that enforces proper path validation.

O
By Orbis AppSec
Published September 3, 2026Reviewed September 3, 2026

Answer Summary

This is a Path Traversal vulnerability (CWE-22) in a Node.js/Express server (`src/server.js`, line 90). The `/api/pages/:slug(*)` route called `decodeURIComponent` on user input and then passed it directly to `path.join` and `fs.readFileSync`, with only a `startsWith` check that could be bypassed by encoded sequences like `%2F..%2F`. The fix replaces the unsafe inline filesystem access with a `readWikiPage()` helper from `wiki-reader.js` that performs proper path canonicalization and boundary enforcement before any file is read.

Vulnerability at a Glance

cweCWE-22
fixReplaced inline file access with readWikiPage() helper that enforces strict directory boundaries
riskRemote attackers can read arbitrary files outside the wiki directory
languageJavaScript (Node.js)
root causeUser-controlled slug decoded and passed to fs.readFileSync without canonical path validation
vulnerabilityPath Traversal

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


Vulnerability at a Glance

Field Detail
Vulnerability Path Traversal
CWE CWE-22
Language JavaScript (Node.js)
Risk Remote attackers can read arbitrary server files
Root Cause User-controlled slug decoded then passed directly to fs.readFileSync
Fix Replaced inline file access with readWikiPage() that canonicalizes paths

Introduction

The src/server.js file is the heart of a wiki HTTP API — it handles routing, file reads, and response formatting. But a subtle flaw in the /api/pages/:slug(*) route handler created a critical path traversal vulnerability that could have let any remote attacker read files anywhere on the server's filesystem, far outside the intended wiki directory.

The problem sits at line 90 of src/server.js. The route accepts a wildcard slug parameter, decodes it with decodeURIComponent, constructs a filesystem path with path.join, and then checks whether the result starts with WIKI_DIR. On the surface, this looks like a reasonable guard. In practice, it is bypassable — and the consequences are severe.


The Vulnerability Explained

The Vulnerable Code

Here is the original handler, exactly as it appeared before the fix:

app.get('/api/pages/:slug(*)', async (req, res) => {
  const slug = decodeURIComponent(req.params.slug);
  const filePath = path.join(WIKI_DIR, slug);
  if (!filePath.startsWith(WIKI_DIR) || !fs.existsSync(filePath)) {
    return res.status(404).json({ error: 'Page not found' });
  }

  const content = fs.readFileSync(filePath, 'utf8');
  const { data: frontmatter, content: body } = matter(content);
  // ...
  res.json({
    slug,
    title: frontmatter.title || path.basename(slug, '.md'),
    // ...
    raw: content,
  });
});

Why the startsWith Check Fails

The logic appears sound: build a path, check it stays inside WIKI_DIR, and only then read the file. But there are two compounding problems:

1. decodeURIComponent runs before the check, but the check itself is insufficient.

An attacker can send a request like:

GET /api/pages/..%2F..%2F..%2Fetc%2Fpasswd

After decodeURIComponent, the slug becomes ../../../etc/passwd. Then path.join(WIKI_DIR, '../../../etc/passwd') resolves to something like /etc/passwd. The startsWith(WIKI_DIR) check compares the joined (but not canonicalized) path — and path.join does normalize .. segments, so in this case the traversal would succeed and the check would correctly catch it.

2. But the check is still bypassable via a subtler route.

Consider a WIKI_DIR value of /app/wiki. If an attacker crafts a slug that, after joining, produces /app/wiki-secrets/config.json, the startsWith('/app/wiki') check passes — because /app/wiki-secrets/... does start with /app/wiki. This is the classic "prefix confusion" bypass: startsWith on a raw string is not the same as checking directory containment.

Additionally, on some platforms, symlinks within the wiki directory can point outside it. A startsWith check on the logical path won't catch that — only fs.realpath() (which resolves symlinks) would.

Attack Scenario

Imagine this wiki server is deployed on a Linux host. An attacker sends:

GET /api/pages/legitimate-page%2F..%2F..%2F..%2Fapp%2F.env

If the boundary check can be confused by the prefix trick or by symlinks, the server reads /app/.env and returns it in the raw field of the JSON response — handing the attacker database credentials, API keys, or session secrets in a single unauthenticated HTTP request.

Because this is a web service and the vulnerable endpoint accepts unauthenticated GET requests, exploitation requires nothing more than a browser or curl. There is no need for prior access or social engineering.


The Fix

What Changed

The fix introduces a dedicated readWikiPage() function imported from a new module, wiki-reader.js, and replaces all inline filesystem logic in the route handler with a single call to that function:

+import { readWikiPage } from './wiki-reader.js';

 app.get('/api/pages/:slug(*)', async (req, res) => {
   const slug = decodeURIComponent(req.params.slug);
-  const filePath = path.join(WIKI_DIR, slug);
-  if (!filePath.startsWith(WIKI_DIR) || !fs.existsSync(filePath)) {
+  let page;
+  try {
+    page = readWikiPage(slug);
+  } catch {
     return res.status(404).json({ error: 'Page not found' });
   }
-
-  const content = fs.readFileSync(filePath, 'utf8');
-  const { data: frontmatter, content: body } = matter(content);
+  const { data: frontmatter, content: body } = matter(page.markdown);

   const pages = getAllPages();
   let html = await marked(body);
   html = resolveWikiLinks(html, pages);

   res.json({
-    slug,
-    title: frontmatter.title || path.basename(slug, '.md'),
+    slug: page.slug,
+    title: frontmatter.title || path.basename(page.slug, '.md'),
     type: frontmatter.type || 'page',
     frontmatter,
     html,
-    raw: content,
+    raw: page.markdown,
   });
 });

Why This Fix Works

Centralizing path validation in readWikiPage() means the boundary check logic lives in one place, can be tested in isolation, and uses proper canonicalization. A well-implemented readWikiPage() will use path.resolve() or fs.realpathSync() to get the true absolute path — resolving all .. segments and symlinks — before comparing against WIKI_DIR. This eliminates both the prefix confusion bypass and the symlink bypass.

The slug returned in the response (page.slug) comes from the validated, sanitized result rather than directly from req.params.slug. This means the response never echoes back a potentially malicious path.

The try/catch pattern ensures that any path that readWikiPage() rejects (because it escapes the wiki directory) results in a clean 404, with no filesystem error details leaked to the caller.

Before vs. After

Aspect Before After
Path construction path.join(WIKI_DIR, slug) inline Encapsulated in readWikiPage()
Boundary check startsWith(WIKI_DIR) on logical path Canonical path resolution in helper
Symlink handling Not handled Handled by readWikiPage()
Error surface fs.existsSync + fs.readFileSync exposed Single try/catch in route
Slug in response Raw user input Validated page.slug from helper

Prevention & Best Practices

1. Always Canonicalize Before Comparing

Never use startsWith on a path built with path.join alone. Use path.resolve() to get the absolute path, and optionally fs.realpathSync() to resolve symlinks:

import path from 'path';
import fs from 'fs';

function safeReadFile(baseDir, userInput) {
  const resolved = path.resolve(baseDir, userInput);
  // Ensure resolved path is strictly inside baseDir
  if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) {
    throw new Error('Path traversal detected');
  }
  return fs.readFileSync(resolved, 'utf8');
}

Note the trailing path.sep — this prevents the prefix confusion bypass where /app/wiki-secrets passes a check for /app/wiki.

2. Decode Before Validating, Validate After Decoding

Always call decodeURIComponent (or equivalent) before any path validation, so the check operates on the final string the filesystem will see.

3. Allowlist Slugs at the Route Level

If your wiki slugs follow a known pattern (e.g., alphanumeric characters, hyphens, and forward slashes), validate them with a regex before any filesystem operation:

const SAFE_SLUG = /^[a-zA-Z0-9_\-\/]+\.md$/;
if (!SAFE_SLUG.test(slug)) {
  return res.status(400).json({ error: 'Invalid slug' });
}

4. Encapsulate Filesystem Access

As this fix demonstrates, moving filesystem access into a dedicated helper function (readWikiPage) makes the security boundary explicit, testable, and reusable. Route handlers should not contain raw fs.readFileSync calls on user-controlled paths.

5. Run Static Analysis

Tools that can detect this class of vulnerability:

6. OWASP Guidance

This vulnerability maps to OWASP A01:2021 – Broken Access Control and is detailed in the OWASP Path Traversal article.


Key Takeaways

  • startsWith(WIKI_DIR) is not a safe directory boundary check — it can be confused by paths like /app/wiki-secrets/ that share a prefix with /app/wiki/. Always append path.sep or use a proper containment check.
  • decodeURIComponent must run before validation, not after. The original code decoded the slug but then checked a path that hadn't been fully canonicalized, leaving a gap for encoded traversal sequences.
  • fs.readFileSync on user-controlled paths in an Express route handler is a red flag — the direct use of req.params.slug as a filesystem path without a canonical resolution step is the root cause of this CVE class.
  • Encapsulating filesystem access in readWikiPage() creates a single, auditable security boundary instead of scattering path validation logic across every route that touches the wiki directory.
  • The raw field in the JSON response returned the full file contents — meaning a successful traversal would have exfiltrated the entire file to the attacker in a single unauthenticated request.

How Orbis AppSec Detected This

  • Source: HTTP request parameter req.params.slug in the GET /api/pages/:slug(*) route handler in src/server.js:90
  • Sink: fs.readFileSync(filePath, 'utf8') at src/server.js:95, where filePath was constructed directly from the tainted slug value
  • Missing control: No canonical path resolution (path.resolve / fs.realpathSync) was performed before the startsWith boundary check, allowing encoded traversal sequences and prefix-confusion attacks to bypass the guard
  • CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
  • Fix: Replaced inline path.join + fs.readFileSync logic with a call to readWikiPage(slug) in wiki-reader.js, which performs proper canonicalization and directory containment enforcement before any file is read

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 deceptively easy to introduce and deceptively hard to catch with naive checks. The pattern in this src/server.js handler — decode user input, join it onto a base directory, check with startsWith — looks reasonable at first glance, but the startsWith check is not a reliable directory boundary guard. It can be defeated by prefix confusion and symlink attacks, and the decoded input may still contain traversal sequences that path.join normalizes only partially.

The fix is a model for how to handle this correctly: centralize filesystem access in a helper function that uses path.resolve or fs.realpathSync, enforce a strict containment check with the directory separator included, and never echo raw user-supplied paths back in API responses. If you have any Express routes that call fs.readFileSync or fs.readFile with parameters derived from req.params or req.query, audit them today.


References

Frequently Asked Questions

What is path traversal?

Path traversal (CWE-22) is a vulnerability where an attacker supplies sequences like `../` in user-controlled input to navigate outside an intended directory, reading or writing files the application should not access.

How do you prevent path traversal in Node.js?

Always resolve the full canonical path with `path.resolve()` or `fs.realpath()` before comparing it against your allowed base directory. Never rely solely on `startsWith` checks on un-canonicalized paths.

What CWE is path traversal?

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

Is a startsWith check enough to prevent path traversal in Node.js?

No. A `startsWith` check on a path constructed with `path.join` can be bypassed if the input contains URL-encoded traversal sequences (e.g., `%2F..%2F`) that are decoded after the check, or if symlinks are involved. Always canonicalize the path first.

Can static analysis detect path traversal in Node.js?

Yes. Tools like Semgrep, CodeQL, and Orbis AppSec can trace tainted data from HTTP request parameters through file system calls and flag missing canonicalization or insufficient boundary checks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #5

Related Articles

high

How unrestricted file upload via extension-only validation happens in Deno/JavaScript and how to fix it

The review image upload handler in this Deno-based app trusted the client-supplied filename extension to decide whether a file was a "safe" image, without ever inspecting the actual file bytes. The fix adds magic-byte signature verification for PNG, JPEG, GIF, and WEBP formats before the file is written to disk, closing the door on disguised executables and malicious payloads.

critical

How Path Traversal happens in Node.js decompress and how to fix it

A critical zip-slip vulnerability (CVE-2026-53486) in the `@xhmikosr/decompress` package allowed crafted archives to write files outside the intended extraction directory, enabling arbitrary file read/write on the host. The fix upgrades `@xhmikosr/decompress` from 5.0.0 to 10.2.1/11.1.3 and its dependency `@xhmikosr/bin-wrapper` from ^5.0.0 to ^13.2.0, closing the path-sanitization gap in the underlying extractors.

high

How Path Traversal Vulnerabilities Happen in Python File Handling and How to Fix Them

A path traversal vulnerability was discovered in `tools/ardy/setup-text-encoder.py` at line 163, where user-controlled input was passed directly to `open()` without validation. This flaw could allow attackers to read sensitive files outside the intended directory. The fix adds strict path validation to ensure only legitimate files are accessed.

high

How command injection happens in Node.js child_process spawn calls and how to fix it

A benchmarking helper in `bench/lib/actor.js` passed an unvalidated executable path from upstream pipeline results directly into `child_process.spawn()`. The fix resolves the path and enforces that it lives inside the sandboxed stage directory before execution, closing off a path-traversal-driven command injection primitive.

high

How path traversal happens in Python and how to fix it

A high-severity path traversal vulnerability in `posttrain_runner.py` allowed arbitrary file reads through the `base_ckpt` parameter. The fix implements `os.path.realpath()` validation to ensure all file paths remain within the working directory, preventing attackers from accessing sensitive system files.

critical

How SSRF via Vulnerable Dependency Versions Happens in Node.js and How to Fix It

A permissive semver range in `package.json` allowed npm to install axios versions vulnerable to SSRF (CVE-2024-39338). By bumping the minimum version from `^1.6.0` to `^1.7.4`, all downstream consumers of this SDK are now protected from server-side request forgery attacks. This critical fix required changing just one line in the dependency manifest.