Path.normalize removes redundant name elements lexically; it does not read the filesystem or prove that a path stays inside a directory after symlinks are followed.
Java Path.normalize: reject lexical escapes without promising symlink safety
Check the requested name
A caller-supplied relative name can contain .. segments that walk above an intended root. Resolve it against an absolute normalized root, normalize the result, then check startsWith using Path components rather than a string prefix. The fixture accepts a child path and rejects a path that moves to the root's parent.
Reject an absolute requested path before resolve: an absolute argument replaces the root instead of appending to it. Archive entry validation needs the same lexical rule before extraction.
Do not confuse a name check with an open-file guarantee
A symlink inside the root can point outside it. normalize does not inspect that target. toRealPath follows links for an existing path, but a separate check followed by an open still has a race if an attacker can change directories or links between those operations. Use a platform-aware directory-handle or secure-directory-stream design for a hostile writable tree; a lexical predicate alone is only a first filter.
This teaching program returns a candidate Path and never opens it. It therefore verifies the lexical rule, not filesystem containment under adversarial mutation. Atomic publication addresses a different file-operation boundary.
Working program
import java.nio.file.Path;
import java.nio.file.Paths;
public class ReceiptPathFilter {
static Path lexicalCandidate(Path root, String requestedName) {
Path relative = Paths.get(requestedName);
if (relative.isAbsolute()) throw new IllegalArgumentException("absolute name");
Path candidate = root.resolve(relative).normalize();
if (!candidate.startsWith(root)) throw new IllegalArgumentException("outside root");
return candidate;
}
public static void main(String[] args) {
Path root = Paths.get("workspace", "receipts").toAbsolutePath().normalize();
Path accepted = lexicalCandidate(root, "oct/../receipt-47.txt");
System.out.println(accepted.startsWith(root));
try { lexicalCandidate(root, "../private.txt"); }
catch (IllegalArgumentException rejected) { System.out.println("escape rejected"); }
}
}Output
true
escape rejectedCost and ownership
Lexical normalization visits a bounded number of path components and allocates a Path representation; the precise cost is provider-dependent. No filesystem I/O occurs here. Real-path resolution and opening add I/O and a race analysis that this fixture intentionally does not claim to solve.
Common Mistakes
- Do not compare path prefixes as strings; sibling names can share a textual prefix.
- Do not expect normalize to resolve a symlink or prove physical containment.
- Do not check and later open a hostile path without a race-resistant design.
Read next
Java ZIP inputs: bounded staging and rejected path traversal, Java file I/O: UTF-8, streaming reads, and path ownership, Java Files.move: atomic publication is a filesystem contract, Java file channels: buffer positions and partial transfers.
Continue with: Java symbolic links: inspect attributes without claiming race safety.
