Safeguard
Application Security

Preventing path traversal in Node.js file upload and serving code

path.join() doesn't stop ../../etc/passwd — CVE-2024-12905 and Zip Slip show why Node.js needs explicit containment checks, not just path normalization.

Safeguard Research Team
Research
6 min read

On June 5, 2018, Snyk publicly disclosed Zip Slip, a directory-traversal vulnerability class found in archive-extraction libraries across Java, .NET, Ruby, Go, and JavaScript, affecting projects maintained by HP, Amazon, Apache, and Pivotal, among others. The root cause was simple: extraction code trusted the file names stored inside a zip or tar entry, so an archive containing a path like ../../../etc/cron.d/malicious could write files anywhere the process had permission to write, including outside the intended output folder. Six years later, the pattern repeated almost exactly: CVE-2024-12905, disclosed against the popular tar-fs npm package, let a maliciously crafted tar file's symlink and hardlink entries escape the extraction directory, and the first patch attempt reportedly missed a callback path (onsymlink), leaving a bypass in place even after a fix shipped. Both cases sit under CWE-22, and both trace back to the same mistake: treating a resolved file path as safe because it was built with a "correct" path-joining function. In Node.js, path.join() and path.resolve() normalize .. segments — they do not confine the result to any directory. This post walks through why that distinction breaks upload and file-serving code, and what containment actually requires.

Why doesn't path.join() protect against traversal?

path.join() and path.resolve() are string-normalization functions, not security boundaries — they collapse redundant separators and resolve .. segments syntactically, but they have no concept of an intended root directory to enforce. Calling path.join("/srv/uploads", "../../etc/passwd") returns /etc/passwd cleanly resolved, not an error. The function did exactly what it promises: it produced a normalized absolute path. Whether that path is acceptable depends entirely on what the calling code does next. A common but broken defense is checking whether the unresolved input string contains ".." before joining — but that check can be bypassed with URL-encoded sequences (%2e%2e%2f), double-encoding, or platform-specific separators, and it does nothing once symlinks are involved. The only reliable pattern is to resolve the full path first, then compare the resolved result against a resolved root directory, rejecting anything that falls outside it.

What does a real containment check look like in Node.js?

A correct check resolves both the target and the root to absolute, symlink-free paths, then verifies the target is a descendant of the root using path semantics, not substring matching. A minimal pattern: const root = path.resolve("/srv/uploads"); const target = path.resolve(root, userInput); if (target !== root && !target.startsWith(root + path.sep)) throw new Error("invalid path"); The path.sep suffix matters — without it, a root of /srv/uploads would incorrectly accept /srv/uploads-evil, since startsWith is a plain string comparison. For file-serving code that follows symlinks (static file servers, extracted archive contents), the check should run against fs.realpathSync output, since a symlink inside the served directory can point anywhere on disk and a pre-symlink path check will pass while the actual read escapes containment. Express's built-in express.static and Node's serve-static package implement this class of check internally, which is one reason hand-rolled file-serving routes are a recurring source of traversal bugs — they skip protections the framework middleware already had.

How did Zip Slip turn traversal into remote code execution?

Zip Slip escalated a filesystem write primitive into remote code execution by exploiting the gap between "extracting a file" and "trusting where it lands." According to Snyk's 2018 disclosure, vulnerable extraction code across multiple ecosystems read each archive entry's stored name and passed it directly into a path-join call before writing the entry's contents to disk, without checking whether the resulting path stayed inside the target extraction directory. An archive entry named ../../../../home/user/.ssh/authorized_keys or a web-accessible startup script directory let an attacker overwrite files the application never intended to touch, in some configurations leading directly to code execution once the server next ran the modified script. The fix pattern Snyk documented is the same containment check described above, applied per-entry during extraction, plus rejecting any entry whose resolved destination path is not a strict descendant of the extraction root before any bytes are written.

What went wrong with CVE-2024-12905 even after a first patch?

CVE-2024-12905 in the tar-fs npm package showed that partial fixes to traversal bugs can leave adjacent code paths exposed. The vulnerability affected versions before 1.16.4, 2.1.2, and 3.0.7, and involved symlink and hardlink entries in a crafted tar archive that could cause files to be written or overwritten outside the intended extraction directory, per the GitHub Security Advisory (GHSA-pq67-2wwv-3xjx). Public reporting on the fix noted that an earlier patch addressed the primary extraction path but missed the onsymlink callback route through which the same escape was still reachable — meaning a scanner or developer who saw "tar-fs patched for path traversal" and stopped checking their version pin could still ship a vulnerable dependency. The practical lesson: a link-following extraction routine needs containment checks applied to every entry type (regular file, symlink, hardlink) and every code path that writes to disk, not just the one exercised by the original proof-of-concept.

What upload-handling practices reduce traversal risk beyond path checks?

Containment checks are necessary but not sufficient on their own; upload handling should also avoid trusting user-supplied file names at all where possible. Generating a random identifier (a UUID or hash) as the on-disk filename, and storing the original user-supplied name only as metadata in a database, removes the traversal vector entirely for the write path, since the attacker never controls the string used to build the destination path. Where original filenames must be preserved (for downloads), extension allowlisting combined with content-type verification limits what a traversal-adjacent bug can do even if a normalization gap exists. Running extraction or upload-processing code with a dedicated, low-privilege OS user and a directory the process cannot write outside of — a real filesystem boundary, not just an application-level check — adds defense in depth for exactly the scenario Zip Slip and CVE-2024-12905 both exploited: application logic that assumed a resolved path was automatically a safe one.

How does Safeguard help?

Traversal bugs like CVE-2024-12905 typically enter a codebase the same way most dependency vulnerabilities do: through a transitive package pulled in for a routine task like tar extraction, long before anyone audits its extraction logic. Safeguard's software composition analysis continuously matches your package-lock.json and transitive dependency tree against known-vulnerable versions, so a pinned tar-fs release affected by a disclosed advisory surfaces as a finding tied to the specific CVE and fixed version, rather than requiring a manual audit of every archive-handling dependency in your tree. For custom upload and file-serving routes written in-house, that same reachability context — whether the vulnerable extraction call actually sits on a path reachable from an HTTP request handler — is what separates a finding worth a sprint from one that can wait.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.