FlawOpen

flawopen.com/path-traversal/Javascript

● CWE-22 / CWE-59 / CWE-400 / CWE-789 · High
FlawOpen Security Research

Path Traversal in JavaScript (Node.js path, Express)

path.join() quietly turns ../../etc/hostname into /etc/hostname, a check on the raw URL misses %2e%2e%2f that is decoded later, and archive entry names can point outside the extraction folder (Zip Slip). How each mistake lets a request read or write files outside your Node.js upload folder, and how to enforce containment without confusing lexical path checks with filesystem-level defenses.[1], [2], [3], [4], [5]

💡 Plain English Explainer (ELI5)

Think of a paper street map at a warehouse gate. The guard must decode the delivery address before checking it; otherwise encoded directions like %2e%2e stay hidden until later. path.join() can clean ../ into a plausible address, but path.relative() compares names on the map, not the filesystem’s actual route: a symlink can lead outside. Express res.sendFile(name, { root }) blocks lexical escapes, and O_NOFOLLOW blocks a symlink at the final file component. For ZIPs, use a fresh private staging directory, check every entry, and keep adm-zip at 0.6.1 or newer for the documented extraction-through-symlink fix.[2], [3], [8], [9]

Core Concepts & Subsystem Terms

path.join() removes the evidence
path.join("/srv/uploads", "../../etc/hostname") returns /etc/hostname: the .. segments are applied and then disappear. res.sendFile without a root only rejects paths that still contain .., so the cleaned-up path is served.[2], [3], [1]
Checking before decoding
Express leaves req.path percent-encoded, so ..%2f..%2fetc%2fhostname contains no ../ when checked and becomes ../../etc/hostname after decodeURIComponent. Validate the final decoded and resolved path, never the raw string.[11], [12], [13], [14], [15]
res.sendFile() with a root
res.sendFile(name, { root }) joins name under root, answers 403 when the result climbs above it, and with dotfiles: "deny" refuses names such as .env. express.static applies the same rules.[3]
path.resolve() and path.relative()
path.resolve(base, name) produces an absolute, normalized path; path.relative(base, target) then starts with .. or is absolute whenever the target is outside base, which is a component-wise containment check. [2]
Zip Slip (CVE-2018-1002204)
Archive entries named ../../app/config.js are written outside the target folder by code that joins them onto the destination. Snyk's 2018 research found this in many libraries, including adm-zip before 0.4.9 (CVE-2018-1002204).[4], [5], [1]
adm-zip advisories and versions
CVE-2026-76845 affects adm-zip versions 0.5.9 through 0.6.0 when extraction writes through a pre-existing destination symlink; the maintainer released 0.6.1 on 2026-09-11 with a fix blocking extraction through symlinks. CVE-2026-39244 separately covered unbounded allocation from a declared ZIP size and was fixed in 0.6.0. Pin at least 0.6.1, keep extraction in a newly created private staging directory, and enforce upload and total expanded-size limits. A lexical path.relative() check alone does not prevent symlink escapes or resource exhaustion. These are adjacent library weaknesses, not CWE-22: the symlink issue is CWE-59 and requires a pre-existing symlink in a shared/reused/predictable extraction directory plus overwrite; the size-allocation issue is CWE-400/CWE-789.[8], [9], [10], [16], [17], [18]

Framework mappings

Step-by-Step Attack Flow

Step 1

Request value reaches a file API

A route passes req.query.file, part of req.path or the entry names of an uploaded ZIP to res.sendFile, fs.createReadStream or fs.writeFileSync.[1], [2], [3], [4]

Step 2

The value leaves the intended folder

It contains ../ segments, the same segments percent-encoded, or an absolute path. The benign test value ../../etc/hostname illustrates the read case.[11], [12], [13], [14], [15], [1]

Step 3

Node opens the resolved path

path.join normalizes the segments away, or decodeURIComponent restores them after the check, and the file system opens whatever the final path names. In CVE-2026-76845, the archive needed a pre-existing symlink in a shared, reused, or predictable extraction directory and overwrite enabled; the issue was separate from archive-name traversal.[2], [3], [8], [9]

Step 4

Impact depends on process permissions

The impact depends on process permissions: reads expose only files the process can access, and an archive write can replace a file only when extraction can write there; code execution additionally requires that the replaced file later be loaded.[7], [1], [8]

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// files.js: each handler lets the request pick a path outside UPLOAD_DIR
const express = require("express");
const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");

const app = express();
const UPLOAD_DIR = "/srv/uploads";

// join() resolves ../ first, so sendFile sees a clean /etc/... path
app.get("/download", (req, res) => {
  res.sendFile(path.join(UPLOAD_DIR, req.query.file));
});

// ..%2f passes the check and becomes ../ after decoding
app.get("/static/*", (req, res) => {
  const raw = req.path.slice("/static/".length);
  if (raw.includes("../")) return res.sendStatus(400);
  fs.createReadStream(path.join(UPLOAD_DIR, decodeURIComponent(raw))).pipe(res);
});

// an entry named ../../app/config.js is written outside dest
function importArchive(zipPath, dest) {
  for (const entry of new AdmZip(zipPath).getEntries()) {
    if (entry.isDirectory) continue;
    const target = path.join(dest, entry.entryName);
    fs.mkdirSync(path.dirname(target), { recursive: true });
    fs.writeFileSync(target, entry.getData());
  }
}
HARDENED SECURE PATCH
// files.js: decode before validation; keep uploads as flat generated-name files.
const express = require("express");
const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");

const app = express();
const UPLOAD_DIR = path.resolve("/srv/uploads"); // service-owned, not writable by uploaders

// path.relative() detects lexical escapes; it does not resolve filesystem symlinks.
function insideDir(base, name) {
  const target = path.resolve(base, name);
  const rel = path.relative(base, target);
  if (rel === "" || rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)) {
    throw new Error("path escapes base directory");
  }
  return target;
}

// root rejects lexical traversal; dotfiles are denied.
app.get("/download/:id", (req, res) => {
  const id = String(req.params.id);
  if (!/^[0-9a-f-]{36}$/i.test(id)) return res.sendStatus(404);
  res.sendFile(id, { root: UPLOAD_DIR, dotfiles: "deny" });
});

// POSIX O_NOFOLLOW rejects a symlink at the final path component.
app.get("/static/:id", (req, res) => {
  const id = String(req.params.id);
  if (!/^[0-9a-f-]{36}$/i.test(id)) return res.sendStatus(404);
  let fd;
  try {
    fd = fs.openSync(path.join(UPLOAD_DIR, id), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
    if (!fs.fstatSync(fd).isFile()) throw new Error("not a regular file");
  } catch {
    if (fd !== undefined) fs.closeSync(fd);
    return res.sendStatus(404);
  }
  fs.createReadStream(null, { fd, autoClose: true }).on("error", () => res.sendStatus(404)).pipe(res);
});

// Extract only into a fresh private staging directory; promote after validation.
function importArchive(zipPath) {
  const stat = fs.statSync(zipPath);
  if (!stat.isFile() || stat.size > 25 * 1024 * 1024) throw new Error("archive too large");
  const root = fs.mkdtempSync(path.join("/srv/private-staging", "import-"));
  try {
    const zip = new AdmZip(zipPath); // pin adm-zip >= 0.6.1
    const entries = zip.getEntries();
    const expandedBytes = entries.reduce((total, entry) => total + entry.header.size, 0);
    if (entries.length > 1000 || expandedBytes > 100 * 1024 * 1024) throw new Error("archive limits exceeded");
    for (const entry of entries) insideDir(root, entry.entryName);
    zip.extractAllTo(root, false);
    return root; // validate content before promotion
  } catch (error) {
    fs.rmSync(root, { recursive: true, force: true });
    throw error;
  }
}

Engineering & System Hardening Checklist

Sources