flawopen.com/path-traversal/Javascript
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]
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 evidencepath.join("/srv/uploads", "../../etc/hostname")returns/etc/hostname: the..segments are applied and then disappear.res.sendFilewithout arootonly rejects paths that still contain.., so the cleaned-up path is served.[2], [3], [1]Checking before decoding- Express leaves
req.pathpercent-encoded, so..%2f..%2fetc%2fhostnamecontains no../when checked and becomes../../etc/hostnameafterdecodeURIComponent. Validate the final decoded and resolved path, never the raw string.[11], [12], [13], [14], [15] res.sendFile() with a rootres.sendFile(name, { root })joinsnameunderroot, answers 403 when the result climbs above it, and withdotfiles: "deny"refuses names such as.env.express.staticapplies 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 outsidebase, which is a component-wise containment check. [2]Zip Slip (CVE-2018-1002204)- Archive entries named
../../app/config.jsare 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 versionsCVE-2026-76845affects 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-39244separately 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 lexicalpath.relative()check alone does not prevent symlink escapes or resource exhaustion. These are adjacent library weaknesses, notCWE-22: the symlink issue isCWE-59and requires a pre-existing symlink in a shared/reused/predictable extraction directory plus overwrite; the size-allocation issue isCWE-400/CWE-789.[8], [9], [10], [16], [17], [18]
Framework mappings
Step-by-Step Attack Flow
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]
Source Code: Flaw vs. Secure Implementation
// 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());
}
}
// 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
- ✓Enable eslint-plugin-security
detect-non-literal-fs-filenameand a Semgrep rule that flagspath.joinorpath.resolvewithreq.*data reachingfsorsendFile.[6], [1] - ✓Serve files with
res.sendFile(name, { root, dotfiles: "deny" })orexpress.static, never with an absolute path built from request data.[3] - ✓Decode first, then
path.resolveand require thatpath.relative(base, target)neither starts with..nor is absolute.[2], [11], [14] - ✓Extract each archive only into a new private staging directory, enforce entry-count and total expanded-size limits, use adm-zip 0.6.1 or newer, and treat
path.relative()as a lexical check only—not the symlink defense.[8], [9], [10], [16], [17], [18] - ✓Store uploads under generated IDs, not client-supplied names, and run the process with write access only to its upload folder.[7], [1]
Sources
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
- Node.js docs — path.relative
- Express 4 API — res.sendFile
- Snyk — Zip Slip vulnerability
- NVD — CVE-2018-1002204 (adm-zip)
- eslint-plugin-security — detect-non-literal-fs-filename
- OWASP — Path Traversal
- GitHub Advisory — CVE-2026-76845 (adm-zip destination symlink writes)
- adm-zip v0.6.1 release — symlink extraction fix (2026-09-11)
- adm-zip v0.6.0 release — CVE-2026-39244 fix
- Express 4 API — req.path
- Express 4 source — req.path implementation
- parseurl 1.3.3 source — request pathname parsing
- Node.js docs — decodeURIComponent
- PillarJS resolve-path — decode before resolving a request path
- CWE-59: Improper Link Resolution Before File Access
- CWE-400: Uncontrolled Resource Consumption
- CWE-789: Memory Allocation with Excessive Size Value