Path Traversal and Zip Slip in Node.js and Next.js: Stopping Arbitrary File Read, Write, and Archive Extraction [2026]
Path Traversal Is a One-Line Bug With Root-Level Impact
Most file handling bugs in a Node.js backend are annoyances. Path traversal is not. A single user-controlled string concatenated into a filesystem path turns a "download my invoice" endpoint into an arbitrary file read — /etc/passwd, .env, ~/.aws/credentials, an SSH private key, your SQLite database — and the same class of bug on the write side turns a "rename my file" endpoint into arbitrary file overwrite, which is remote code execution the moment an attacker can hit a path that gets executed or loaded.
It survives code review because the code looks careful. Teams reach for path.join, assume it sanitizes ../, ship it, and move on.
The attack surface is wider than people think too: Zip Slip — the archive-extraction cousin — is the same bug wearing a different hat. Every startup that lets users upload a ZIP, a .tar.gz backup, an export bundle, or a dataset and then extracts it server-side has a path traversal sink. Pulling ../../../../etc/cron.d/backdoor out of a malicious archive is arbitrary file write, and it is one exec or one cron interval away from full compromise.
This article covers the read case, the write case, and the archive case, with the exact code that closes each one.
Why path.join Does Not Save You
path.join normalizes separators and collapses . and .. lexically. It has no idea what is "inside the directory" and what is not — it just produces a string. All of these are attacker inputs that escape your intended root:
// Attacker-controlled value from a query param, body field, or multipart filename
path.join('/var/app/uploads', '../../../../etc/passwd');
// => '/etc/passwd' — escaped the root, no error was raised
path.join('/var/app/uploads', '..%2f..%2fetc/passwd');
// => '/var/app/uploads/..%2f..%2fetc/passwd' — still escapes if you decode after joining
And the more dangerous sibling:
path.resolve('/var/app/uploads', '/etc/passwd');
// => '/etc/passwd' — an absolute segment resets the whole path
path.resolve is what people reach for as "the fix", and that is exactly where the second bug lives: if the user path is absolute, resolve discards the root entirely. On Windows the same applies to C:\..., \\server\share, and \\?\ paths, plus ..\..\ with backslashes and 8.3 short names like PROGRA~1.
The encoding variants matter too, because decoding happens at your boundary, not the filesystem's:
..%2f..%2f— URL-encoded separators, decoded by frameworks that decode after routing.%252e%252e%252f— double-encoded, decoded once by the proxy and once by your code.....//and..././— reconstructed by naive string replaces that strip../only once.- Overlong UTF-8 (
%c0%ae) and full-width characters, rejected by Node'spathbut sometimes accepted by other runtimes or by the OS layer.
Rule one: decode exactly once, at the edge, before validation — never after. If your framework already decoded the route param or body field, that is the one decode. Do not call decodeURIComponent again later, and never validate the raw form and then decode.
The Correct Pattern: Resolve, Then Prove Containment
Do not validate the string. Validate the resolved result against the root, and do it on real paths:
import path from 'node:path';
import fs from 'node:fs/promises';
const ROOT = await fs.realpath(process.env.UPLOAD_DIR!);
export async function safeResolve(userPath: string): Promise<string> {
// 1. Lexical containment: resolve against the root, then prove it stayed inside.
const candidate = path.resolve(ROOT, userPath);
const rel = path.relative(ROOT, candidate);
if (rel === '') return candidate; // the root itself
if (rel.startsWith('..' + path.sep) || rel === '..' || path.isAbsolute(rel)) {
throw Object.assign(new Error('path traversal blocked'), { status: 400 });
}
// 2. Symlink defence: resolve the real path and re-check containment.
const real = await fs.realpath(candidate);
const relReal = path.relative(ROOT, real);
if (relReal.startsWith('..' + path.sep) || relReal === '..' || path.isAbsolute(relReal)) {
throw Object.assign(new Error('symlink escape blocked'), { status: 400 });
}
return real;
}
Three details that are easy to miss:
path.relativeis the right primitive, notstartsWith(ROOT). The path/var/app/uploads-evilstarts with/var/app/uploadsas a string and is not inside it.path.relativereturns something beginning with..whenever the target is outside the root.- Use
path.resolve, notpath.join— you want the absolute-path reset to be visible so the containment check catches it, rather than silently producing a normalized path you trust. - The second
realpathcheck is what stops a symlink planted inside the upload directory (ln -s /etc/passwd pwn) from turning your own root into an exit. Resolve the root itself withrealpathonce at startup too, or a symlinked root defeats the comparison.
The Fix You Should Ship First: Stop Trusting User Filenames
Everything above is defence in depth. The structural fix is to never let a user-supplied string reach path at all:
- On upload, generate a random ID (
crypto.randomUUID()), use it as the on-disk filename, and store the original (display-only) name in the database next to the user's ownership row. - Serve files by ID:
GET /api/files/:idloads the row, authorizes against the session (file.ownerId === session.userId), builds the path from the stored ID plus a fixed extension, and streams it. - Never accept a path, filename, or extension from the client on the read side. The client sends an opaque ID, not a name.
That deletes the vulnerability class instead of filtering it. See our secure file upload guide for the upload side (magic-byte validation, extension allowlists, storage outside the web root) and the IDOR guide for the object-level authorization that must accompany every one of these lookups.
Zip Slip: The Same Bug Inside Every Archive
Zip Slip is path traversal through archive entries. The format lets every entry declare its own name, and a malicious archive declares ../../../../etc/cron.d/backdoor — or an absolute path, or a Windows drive path, or a path that writes through a symlink created by an earlier entry. It was disclosed across the whole ecosystem in 2018 and has been patched, re-broken, and re-patched in individual libraries ever since. node-tar alone needed a 2021 fix series for arbitrary file write via symlink (CVE-2021-32803), arbitrary file creation via absolute paths (CVE-2021-32804), and Windows drive-relative path escapes (CVE-2021-37701, CVE-2021-37712, CVE-2021-37713); comparable issues landed in adm-zip, unzip-stream, decompress, and extract-zip.
The lesson is not "upgrade the library" — it is that your extraction code must enforce containment itself, per entry, because you cannot know what the next archive library will get wrong.
Vulnerable shape — a direct sink:
import AdmZip from 'adm-zip';
const zip = new AdmZip(buffer);
zip.extractAllTo('/var/app/extracted', /* overwrite */ true);
// Every entry name is attacker-controlled. '../../../../' escapes the root.
Safe shape — validate each entry before it touches the disk:
import path from 'node:path';
import fs from 'node:fs/promises';
import tar from 'tar';
const DEST = await fs.realpath('/var/app/extracted');
function entryTarget(entryName: string): string {
if (path.isAbsolute(entryName)) throw new Error(`zip slip blocked: ${entryName}`);
const target = path.resolve(DEST, entryName);
const rel = path.relative(DEST, target);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`zip slip blocked: ${entryName}`);
}
return target;
}
await tar.x({
file: archivePath,
cwd: DEST,
// Refuse links and device entries outright — they are the second-stage attack.
filter: (entryPath, entry) => {
if (entry.type === 'SymbolicLink' || entry.type === 'Link') return false;
entryTarget(entryPath);
return true;
},
});
Points worth internalizing:
- Validate the entry name and the entry type. Symlinks and hardlinks (
entry.type === 'SymbolicLink' | 'Link') are how the second stage works: extract a symlink pointing at/etc, then extract a regular file "inside" it and you have written to/etc. Never extract links from untrusted archives; if you truly must, resolve the link target and containment-check that too. - Reject absolute and drive-relative entries — a leading
/, aC:\, or a\\?\is an immediate reject, not a normalization problem to solve. - Extract into a fresh, empty, ephemeral directory created with
fs.mkdtemp(outside the web root), then move validated files into place. An extraction target should never be a path an attacker can name in advance. - Cap the archive: entry count, total uncompressed size, and per-entry size, checking declared sizes and enforcing limits while streaming (the zip bomb case). Our API rate limiting guide covers the request-level budget; the archive needs its own.
- Never extract into a served directory. If extraction lands under
public/or any path the web server maps, one allowed entry with a.js,.html, or.phpextension becomes stored XSS or worse.
The Check-Then-Open Race (TOCTOU)
safeResolve validates a path, and then you open it. Between the check and the open, an attacker can swap a path component for a symlink — a time-of-check to time-of-use (TOCTOU, condición de carrera de comprobación-uso) race. On Linux the standard mitigation is to open with O_NOFOLLOW and operate on the returned file descriptor, so the file you validated is the file you use:
import { constants } from 'node:fs';
import fs from 'node:fs/promises';
// Fails with ELOOP if the final path component is a symlink.
const fh = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
const stream = fh.createReadStream();
For writes, open with O_CREAT | O_EXCL (flag: 'wx') so you can never silently overwrite an existing file or follow an existing symlink. Keep extraction and any follow-up processing in the same ephemeral directory so an attacker has no window to plant a symlink in a shared location.
Instrument It
Every traversal attempt you block is a real attacker, not a typo. Log the attempt as a security event — user ID, session, sanitized path, request ID — and alert on bursts, because a scan for ../../ across your endpoints is exactly the reconnaissance that precedes a larger hit. Sanitize the logged path (strip newlines and control characters): an attacker who injects \n into a logged filename can forge log entries (log injection) and mislead whoever reads them later.
The Startup Checklist
- [ ] No user-supplied string reaches
fsorpathunvalidated; the read side addresses files by opaque ID with a database-backed authorization check. - [ ] Where a path must come from the client, it is validated by
path.resolve+path.relativecontainment and then re-validated withrealpath. - [ ] The storage root itself is resolved with
realpathat startup, and it lives outside the web root. - [ ] Input is decoded exactly once, at the boundary, before validation — never validated raw and decoded afterwards.
- [ ] Archive extraction validates every entry name with the containment check and rejects
SymbolicLink/Linkentries plus absolute or drive-relative paths. - [ ] Archives are extracted into a fresh
mkdtempdirectory with entry-count, total-size, and per-entry size limits enforced during streaming. - [ ] File opens use
O_NOFOLLOW; writes useO_CREAT | O_EXCL; there is no check-then-open gap on attacker-influenced paths. - [ ] Blocked traversal attempts are logged as security events with the path sanitized, and alerts fire on bursts.
- [ ] Uploaded content is never extracted or stored inside a directory the web server serves.
- [ ] Archive and zip libraries are pinned and tracked by Dependabot — but containment is enforced by your code, not trusted to the library.
What to Do This Week
Grep is your ally: search the codebase for every call to fs.readFile, fs.writeFile, createReadStream, sendFile, res.download, extractAllTo, and tar.x/tar.extract, then trace each one back to where its path argument comes from. Any path that originates in a query param, a JSON body, a multipart filename, or an archive entry is a finding. Fix them in this order: opaque IDs and DB-backed authorization first (that kills the read and write cases), per-entry validation in extraction code second, TOCTOU hardening and telemetry third. Path traversal is a one-line bug, but it is not a one-line fix — the containment check, not the string filter, is what holds.
Want a professional review of your file handling and archive processing? Schedule a security audit — we test every file path, upload flow, and extraction endpoint the way an attacker would.
JS Security Audit
Audits led by a senior JavaScript security engineer with 10+ years of experience.