In August 2021, the maintainers of node-tar — a package downloaded over 60 million times a week as a dependency of npm itself — shipped an emergency fix for CVE-2021-32803 and CVE-2021-32804. The bug wasn't exotic: a crafted tarball could write files outside the intended extraction directory because the library's path-safety checks didn't hold up against how Node.js actually resolves paths across operating systems. Four months later, three more related CVEs (2021-37701, 2021-37712, 2021-37713) forced a second round of patches. Every one of these traced back to the same misconception that trips up thousands of Node.js developers: assuming path.normalize() is a security function. It isn't. It's a formatting function that happens to remove .. segments under specific conditions — and attackers have made a career out of finding the conditions where it doesn't. This post breaks down what path.normalize() actually does, where it fails, and how to build path handling that survives contact with real input.
Does path.normalize() actually prevent path traversal?
No — not on its own, and treating it as a security boundary is one of the most common mistakes in Node.js file-handling code. path.normalize() resolves . and .. segments and collapses redundant slashes within a string, so path.normalize('/uploads/../../etc/passwd') returns /etc/passwd. That looks like a fix, but normalize has no concept of a "safe root" — it just does string arithmetic on whatever path you hand it. If your code does path.normalize(userInput) and then reads that path without checking it against a base directory, an attacker who supplies ../../../../etc/passwd gets a normalized, perfectly valid absolute path straight to /etc/passwd. Normalize will happily clean up the traversal syntax; it will not stop the traversal from landing outside your intended directory. That containment check is a separate step developers routinely skip, which is exactly why CWE-22 (Path Traversal) has remained a fixture of OWASP's Top 10 for over a decade, folded into the Broken Access Control category that OWASP's 2021 report ranked as the single most prevalent risk class across the applications it analyzed.
What happened when node-tar trusted path.normalize's cross-platform behavior?
It got bypassed twice in the same year. The original node-tar fix for CVE-2021-32803/32804 added checks that rejected paths containing .. segments before extraction. That held up against forward-slash traversal (../../etc/passwd) but not against the second wave: CVE-2021-37701 and CVE-2021-37712 showed that a tarball built with backslash-separated paths, or with a symlink pointing at a directory that had already been extracted, could route the extraction outside the target folder anyway — because the sanitization logic checked path segments using one separator convention while the filesystem doing the actual writing accepted another. A check written for one path grammar quietly failed against another. Node-tar 6.1.9, released in January 2022, closed that gap by validating the resolved absolute path against the extraction root directly instead of pattern-matching on path segments — the same fix pattern security engineers had been recommending for years, arrived at only after three CVEs forced the issue.
What does a safe path-resolution pattern look like in Node.js?
It's resolve-then-compare, not normalize-then-trust. The pattern that actually holds up looks like this:
const path = require('path');
function safeResolve(baseDir, userPath) {
const base = path.resolve(baseDir);
const target = path.resolve(base, userPath);
if (target !== base && !target.startsWith(base + path.sep)) {
throw new Error('Path traversal attempt blocked');
}
return target;
}
Three details make this different from a normalize-only check. First, path.resolve() is used instead of path.normalize(), because resolve anchors the path against an absolute base and produces a canonical absolute path rather than just tidying a string — it inherently applies normalization but also fixes the reference point. Second, the comparison uses path.sep as a boundary so that a sibling directory with a matching prefix (/uploads-backup against a base of /uploads) can't slip through a naive startsWith('/uploads') check, a variant of this bug that has shown up repeatedly in home-grown static-file-serving code. Third, the check happens after resolution, not on the raw user input — checking req.query.file for .. substrings before resolving it is trivially defeated by URL-encoding (%2e%2e%2f) or double-encoding, which Express and raw http servers will happily decode for you downstream.
Which edge cases still break "fixed" path validation code?
Null bytes, symlinks, and Windows drive letters are the three that keep resurfacing in bug bounty reports. Null-byte injection (file.txt\0.jpg) historically let attackers truncate a path at the C-library level after a JavaScript-level extension check had already passed it; Node.js closed the most direct version of this hole years ago by having fs and path functions throw on strings containing embedded null bytes, but the underlying lesson — validate after every transformation, not just once at the entry point — still applies to encoding-based variants. Symlink-based traversal is subtler and still active: a directory that's fully contained by your path check can itself be a symlink pointing outside the sandbox, so fs.realpathSync() — not path.resolve() — is the only way to confirm where a path actually leads on disk before you trust it, since resolve never touches the filesystem. And on Windows, path.normalize() treats both / and \ as separators and can produce drive-letter-relative paths like C:folder\file, which behave differently from C:\folder\file in ways that trip up containment checks written and tested only on Linux CI runners — a gap that's shown up repeatedly in cross-platform tooling, node-tar's 2021 incidents included.
How do you catch this before it ships instead of after a CVE?
You catch it the same way node-tar's maintainers eventually fixed it — by validating resolved output against a filesystem boundary in code review and CI, not by trusting that a path.normalize() call somewhere upstream already handled it. That means static analysis rules that flag any fs.readFile, fs.writeFile, res.sendFile, or child_process call fed by unsanitized user input without a subsequent boundary check, and dependency scanning that catches known-vulnerable versions of packages like tar, adm-zip, extract-zip, and unzipper — all of which have shipped path traversal CVEs stemming from the same zip-slip pattern that Snyk's security research team documented across more than 20 libraries and multiple ecosystems in 2018. A one-time audit doesn't hold, either, because these bugs regress: node-tar shipped a compliant fix for months before the second wave of CVEs found a new bypass in the same code path.
How Safeguard Helps
Safeguard treats path traversal the way this history suggests it should be treated: as a class of bug that reappears in dependencies you don't control as often as it appears in code you write. Our software supply chain security platform continuously scans your Node.js dependency tree for packages with known path traversal advisories — including the tar, decompress, and archive-extraction libraries that have repeatedly carried CWE-22 CVEs — and flags vulnerable version ranges before they reach production, not after a security researcher finds them. For first-party code, Safeguard's SAST scanning identifies unsanitized path construction patterns, including path.normalize() calls used as the sole line of defense against user-controlled file paths, and surfaces them with the resolved-path-comparison pattern shown above as a direct remediation. Because path traversal fixes have a track record of getting bypassed by a slightly different input grammar — as node-tar's own CVE history shows — Safeguard also tracks re-introduction: if a previously flagged pattern reappears in a later commit, or a new CVE is published against a package version already in your SBOM, you get notified the same day it's disclosed rather than discovering it during your next scheduled audit. For teams building or shipping software that other companies depend on, that continuous, dependency-aware view of path handling is what turns "we patched it once" into "we'd catch the next bypass too."