your zip extractor rejects ../ and still writes to C:/Windows, because a drive letter is absolute without a leading slash
parsing · case
Symptom
Section titled “Symptom”There is no symptom until someone uses it. A path guard that reads as thorough —
it strips .., it rejects paths starting with / — writes an archive entry
outside its destination on Windows and nowhere else.
The guard looks like this, and it is wrong in two independent ways:
if (name.startsWith("..") || name.startsWith("/")) continue; // "safe"const path = require("node:path");
// 1. a drive-letter entry is absolute, and passes both checksconst a = "C:/Windows/System32/drivers/etc/hosts";a.startsWith("..") || a.startsWith("/"); // false — allowed throughpath.posix.isAbsolute(a); // false — the POSIX check agreespath.win32.isAbsolute(a); // true — only win32 knows
// 2. a nested traversal survives a prefix checkconst b = "safe/../../evil.md";b.startsWith(".."); // false — allowed throughpath.posix.normalize(b); // "../evil.md" — it escapesAnd the drive-relative form, which is stranger still:
path.win32.resolve("C:evil.txt"); // resolves against the CWD *of drive C*, // which is per-drive state, not your cwd“Absolute” is not one concept. On POSIX a path is absolute exactly when it starts
with /, so a single prefix check is a complete test. Windows has three
absolute-ish forms and only one of them starts with a separator:
C:\dir\file— drive-absoluteC:file— drive-RELATIVE, resolved against a per-drive current directory\\server\share\file— UNC
path.posix.isAbsolute is false for all three, and a Node program that normalizes
archive entries with path.posix — the sensible choice, since zip entry names use
forward slashes by spec — inherits that blindness. The file is then written with a
Win32 API that honors the drive letter perfectly well.
The second half is that prefix checks and normalization are different operations.
safe/../../evil.md does not start with ..; it becomes ../evil.md only after
you normalize it. Checking before normalizing tests a string that will not be the
one used.
Workaround
Section titled “Workaround”Normalize first, then reject on the normalized value, and add the Windows forms the POSIX check cannot see:
function safeEntryName(entry) { const raw = String(entry).replace(/\\/g, "/"); const normalized = path.posix.normalize(raw); if ( raw.split("/").includes("..") || // no raw traversal segment at all normalized === "." || normalized === ".." || normalized.startsWith("../") || path.posix.isAbsolute(normalized) || /^[A-Za-z]:(?:\/|$)/.test(normalized) // C:/ and bare C: ) return null; return normalized;}Rejecting any raw .. segment — even one that normalizes away, like
safe/../x — costs you nothing in a context you control and removes a whole
class of normalization-order bugs.
The belt-and-braces version, worth it when the archive is untrusted: resolve the
final path and verify it is still inside the destination with
path.relative(dest, resolved), checking that the result neither starts with
.. nor is absolute. That catches forms nobody enumerated.
path-colon-not-delimiter is the other half of the colon problem: code that
splits a PATH-like string on : cuts drive letters in half. Here the colon is
not being split on but being ignored, and the result is a write outside the
sandbox.