In 2018, researchers at Snyk disclosed Zip Slip, a single path traversal pattern that turned up in the archive-extraction code of more than 30 popular Java libraries and applications, including Apache Ant, Apache Commons Compress, Plexus Archiver, and products from Amazon, LinkedIn, and Pivotal. The root cause was the same in almost every case: code that concatenated a ZIP entry name onto a destination directory without checking whether the resulting path escaped that directory. Eight years later, the same class of bug — CWE-22, Path Traversal — is still one of the most common findings in Java application security assessments, and Path.normalize() is still the method developers reach for first, and still the method that gets misapplied. This post explains what normalize() actually does, where it fails, and what a correct fix looks like in modern Java.
Does Path.normalize() actually prevent path traversal?
No, not by itself — Path.normalize() only removes redundant . and .. elements from a path string; it does not verify that the result stays inside an intended directory. Introduced in java.nio.file.Path as part of NIO.2 in Java 7 (JSR 203, released July 2011), normalize() is a purely syntactic, lexical operation. Given Paths.get("/data/uploads/../../etc/passwd").normalize(), it correctly collapses the .. segments and returns /etc/passwd. That is genuinely useful — but notice what happened: the traversal succeeded. Normalization resolved the path to its true destination; it did not stop the destination from being outside /data/uploads. Developers who call normalize() and then simply use the result, assuming the "cleaning" step made the input safe, are shipping a false sense of security. The Java 7 javadoc for the method is explicit that it "does not access the file system" and doesn't resolve symbolic links — a detail that matters enormously for this vulnerability class, as the next section shows.
What does normalize() miss that attackers exploit?
It misses two things attackers rely on constantly: symbolic links and absolute-path overrides, and both have shown up in real advisories. Because normalize() is a string operation with no filesystem awareness, a symlink inside an "allowed" directory can point anywhere on disk, and normalize() will never notice — you need Path.toRealPath(), which does hit the filesystem and resolves links, to catch that case. Second, if an attacker-controlled segment is itself an absolute path (for example /etc/shadow passed where a relative filename like report.pdf was expected), Paths.get(baseDir, userInput) in Java simply discards baseDir and returns the absolute path unchanged — normalization never even gets a chance to help, because the join itself already escaped the base. This exact pattern was central to the Zip Slip disclosure: several of the 2018 vulnerable libraries used File(File, String) constructors that silently honored absolute or traversal-laden entry names from untrusted ZIP archives, extracting files to arbitrary locations like ../../../../home/user/.ssh/authorized_keys. Apache Tomcat's own CVE-2020-9484 (patched in Tomcat 9.0.35, 8.5.55, and 7.0.104, disclosed May 2020) combined a related path-handling weakness in the session persistence manager with deserialization to reach remote code execution — a reminder that path traversal is rarely "just" a file-read bug; it's frequently the first domino.
What happened in the Zip Slip case, and what does it teach Java teams?
What happened is that a single unsafe idiom — extracting an archive entry to new File(outputDir, zipEntry.getName()) without validation — was copy-pasted across the Java ecosystem for years before anyone tested it against a malicious archive. Snyk's June 2018 disclosure covered libraries with a combined install base in the tens of millions, and fixes landed within weeks across Apache Commons Compress, Apache Ant (1.9.13/1.10.6), Codehaus Plexus Archiver, and others. The lesson for Java teams isn't "archives are special" — it's that path traversal is a supply-chain-scale problem, because a single vulnerable pattern inside a shared dependency propagates into every application that calls it. A team can write flawless validation in its own controller code and still ship a path traversal vulnerability because a transitive dependency three levels down extracts a config bundle or a plugin package with the same unsafe File join. That's precisely why path traversal now sits under OWASP's A01:2021 – Broken Access Control category, the single largest category in the 2021 OWASP Top 10, appearing in 94% of the applications OWASP tested for that release.
How should Java applications validate file paths correctly?
The correct pattern combines three steps, not one: resolve the input against the base directory, canonicalize with filesystem resolution, then explicitly verify containment. In practice:
Path base = Paths.get("/data/uploads").toRealPath();
Path target = base.resolve(userSuppliedName).normalize();
if (!target.startsWith(base)) {
throw new SecurityException("Path traversal attempt: " + userSuppliedName);
}
Three details make this version safe where the naive version wasn't. First, toRealPath() on the base resolves any symlinks in the trusted root once, up front. Second, resolve() followed by normalize() collapses the untrusted segment against that trusted, already-real base. Third — the step most code skips — startsWith(base) is checked after normalization, as an explicit gate, rather than assuming normalization alone was the control. For extra safety against symlinks planted inside the upload directory itself (not just in the base path), resolve target.toRealPath() too, when the file may already exist, and compare that against the real base. It's also worth rejecting filenames containing raw .. or %2e%2e sequences before you ever touch the filesystem, since defense in depth costs little and catches encoding tricks that reach your code before URL-decoding is fully applied upstream.
Why do path traversal bugs still pass code review in 2026?
They still pass because the vulnerable line looks almost identical to the fixed line, and most reviewers — and most static analyzers configured with default rule sets — don't flag Path.normalize() usage as incomplete unless it's specifically checking for a missing containment comparison afterward. A diff that adds .normalize() to previously raw path concatenation reads, to a human reviewer, as "this developer fixed the path traversal issue." Semgrep, CodeQL, and SpotBugs all ship rules for detecting unvalidated path construction (CWE-22 patterns), but a call to normalize() frequently satisfies those rules' pattern match even when no startsWith() containment check exists afterward, producing a false negative. In our own review of Java repositories flagged for path-handling issues, the single most common finding wasn't the complete absence of normalize() — it was normalize() present with no subsequent boundary check, exactly the Zip Slip pattern in modern clothing. This is a case where the fix and the placebo look nearly identical in a code diff, which is exactly the kind of vulnerability that benefits from automated, dependency-aware scanning rather than relying on reviewer memory of a specific javadoc caveat.
How Safeguard Helps
Safeguard is built to catch exactly this gap between "looks fixed" and "is fixed" across your Java supply chain. Rather than treating a Path.normalize() call as sufficient evidence of remediation, Safeguard's SAST analysis traces the full data flow from untrusted input through path construction to file-system access, flagging cases where normalization occurs without an enforced containment check — the pattern behind Zip Slip and its many descendants. Because path traversal is disproportionately a transitive-dependency problem, as the 2018 disclosure proved at scale, Safeguard also maps your Java project's dependency tree against known vulnerable archive-handling and file-path patterns, so a vulnerable extraction routine buried in a third-party JAR gets surfaced before it ships, not after an incident report. For teams enforcing SOC 2 or supply-chain provenance requirements, Safeguard ties these findings directly to your build and release pipeline, giving you a verifiable, auditable record that path-handling code was checked — not just that a normalize() call exists somewhere in the diff. If your team is auditing file upload, archive extraction, or template-rendering code paths, Safeguard can show you exactly where "normalized" and "safe" have quietly diverged.