Core engineering · Practical technical guide · 1 November 2026
How file paths built from user input or archive entries escape their directory, why string prefix checks and dot-dot filters fail, a resolve-then-contain check in Python and Java, what Python's zipfile and tarfile filters do, and tests for the cases that matter.
One upload, one file outside the folder
The returns service at Belgavi's Book Store lets customers attach photos of damaged books. Attachments are saved under /srv/uploads/<case>/ using the file name the browser sent. A bulk import accepts a ZIP of attachments from the warehouse and extracts it into the same tree. Both features take a name chosen by someone else and turn it into a path on the server, which is the whole of path traversal: CWE-22 describes it as using external input to build a pathname that should stay below a restricted directory, without neutralising elements such as .. that resolve outside it.
When the name comes from an archive entry rather than a form field, the same bug is usually called Zip Slip. The consequences are the same: overwrite a configuration file, a template or a script that runs later, or read a file the service can reach but the user should not.
Why the obvious filters fail
| Filter | Bypass |
|---|---|
| Reject names containing ../ | ..\ on Windows, ....// after a single-pass replace, URL-encoded %2e%2e%2f decoded later |
| Strip leading / | C:/ and \\server\share on Windows; returns/../../ in the middle |
| path.startswith(base) on strings | /srv/uploads2/x starts with /srv/uploads |
| Check, then open later | A symlink created between the check and the open |
| Check the name, extract the archive | A symlink entry pointing outside, followed by an entry written through it |
The common thread is checking text instead of the place the operating system will actually write. The fix is to build the final path, resolve it the way the operating system will, and check that the result is inside the base directory, using path comparison rather than string prefixes.
Resolve, then check containment
Illustrative python, checked for syntax; adapt names and storage to your system.
from pathlib import Path
BASE = Path('/srv/uploads').resolve()
class UnsafePath(ValueError):
pass
def safe_join(base, untrusted):
candidate = (base / untrusted).resolve() # follows symlinks and removes ..
if not candidate.is_relative_to(base):
raise UnsafePath(untrusted)
return candidate
def save_attachment(case_id, filename, data):
folder = safe_join(BASE, case_id)
folder.mkdir(parents=True, exist_ok=True)
target = safe_join(folder, Path(filename).name) # keep only the last component
with open(target, 'xb') as f: # x: never overwrite
f.write(data)
return targetPath.resolve() makes the path absolute, removes .. and follows any symlinks that exist, and is_relative_to compares path components, so /srv/uploads2 is not inside /srv/uploads. Better still, do not use the client's name at all: store under a generated identifier and keep the original name as metadata. That removes the attack surface instead of filtering it. In Java the same check is base.resolve(name).normalize().startsWith(base), where Path.startsWith compares name elements, not characters; use toRealPath() on the parent when symlinks inside the tree are possible.
The same check in Java
Illustrative Java 17+, compiled with JDK 23 for this guide; adapt the exception type to your code.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
final class SafePaths {
private SafePaths() {}
static Path safeJoin(Path base, String untrusted) throws IOException {
Path root = base.toRealPath(); // resolve symlinks in the base once
Path candidate = root.resolve(untrusted).normalize();
if (!candidate.startsWith(root)) { // compares name elements, not text
throw new IOException("path escapes base: " + untrusted);
}
Path parent = candidate.getParent();
if (parent != null && Files.exists(parent) && !parent.toRealPath().startsWith(root)) {
throw new IOException("symlinked parent escapes base: " + untrusted);
}
return candidate;
}
}normalize() removes .. textually, so a symlink inside the base can still point out; checking the real path of the parent closes that case for directories that already exist. For archive extraction, call it for every entry before opening an output stream.
What a local run showed
For this guide the cases above were run on Windows with Python 3.13.5, against a temporary base directory called uploads with a sibling called uploads2. The script and its output are in the article's tooling; these are observations from that one run.
| Name | normpath + startswith | resolve + is_relative_to |
|---|---|---|
| returns/CASE-4101.pdf | accepted | accepted |
| ../escape.txt | rejected | rejected |
| ../uploads2/sibling.txt | accepted (wrong) | rejected |
| returns/../../escape2.txt | rejected | rejected |
| /abs.txt and C:/win.txt | rejected | rejected |
The string check accepted the sibling-directory name, exactly the prefix bug. Two archive results were more interesting. zipfile.ZipFile.extractall kept every entry inside the target: it removed .. components and drive letters, so ../escape.txt became escape.txt, which the Python documentation describes. tarfile without a filter wrote ../tar_escape.txt outside the destination and emitted a DeprecationWarning; with filter='data' it raised OutsideDestinationError for that entry and LinkOutsideDestinationError for a symlink pointing to the parent directory.
Archives: use the library filters, and add limits
PEP 706 added extraction filters to tarfile. The data filter refuses absolute paths, paths outside the destination, links that point outside it, and device files, and it clears dangerous permission bits. The Python documentation states that from Python 3.14 the default filter is data; on earlier versions you must pass it, or set TarFile.extraction_filter, and the docs suggest checking hasattr(tarfile, 'data_filter') rather than the version number, since the filter was also backported to security releases.
Illustrative python, checked for syntax; adapt names and storage to your system.
import tarfile, zipfile
def extract_tar(path, dest):
with tarfile.open(path) as t:
t.extractall(dest, filter='data') # refuses escapes, links out, devices
def extract_zip(path, dest, max_files=2000, max_bytes=500_000_000):
with zipfile.ZipFile(path) as z:
infos = z.infolist()
if len(infos) > max_files or sum(i.file_size for i in infos) > max_bytes:
raise ValueError('archive too large')
for info in infos:
safe_join(dest, info.filename) # reject rather than silently rename
z.extractall(dest)For ZIP files, rejecting a bad name is better than letting the library rename it: an archive that contains ../ entries is either broken or hostile, and extracting it quietly hides that. The declared sizes in a ZIP directory can lie, so also bound the bytes actually written if archives come from untrusted sources. In Java, ZipInputStream does no sanitising at all, so every entry needs the resolve-and-check before a file is opened.
Verification
- Unit-test the join function with the cases in the table, plus Windows separators, drive letters, UNC paths, encoded dots and a symlink inside the base that points out.
- Build hostile archives in the test suite with the standard library, as the probe did, rather than downloading samples.
- Run the service as a user that cannot write outside its data directory, and mount that directory without exec permission. Containment in code and in the operating system fail independently.
- Grep for file operations that take request data, such as open, send_file, Files.newOutputStream and extractall, and confirm each one goes through the shared join function.
Failure modes
- Decoding after checking. A name checked before URL or Unicode normalisation is not the name that is used. Check the final string.
- Time-of-check to time-of-use. If other processes can create symlinks in the tree, open with flags that refuse symlinks, or open relative to a directory file descriptor.
- Case-insensitive file systems. Allow-lists of names compared case-sensitively can be bypassed on Windows and default macOS volumes.
- Read paths too. Download endpoints that take a file name have the same bug in the other direction; use the same join.
Sources and scope
Checked against these sources on 1 November 2026. The path and archive observations were measured on 1 November 2026 with Python 3.13.5 on Windows in a temporary directory; symlink behaviour on Windows depends on privileges, so rerun the probe on your platform. Other examples are illustrative.
- MITRE CWE-22: Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
- Python documentation: tarfile, extraction filters and the 3.14 data default
- Python documentation: zipfile, ZipFile.extract and member name sanitising
- PEP 706: Filter for tarfile.extractall
- Java SE 23 API: java.nio.file.Path (resolve, normalize, startsWith, toRealPath)
Never check path strings. Join, resolve and test containment by path components, or better, store under generated names. Extract tar files with the data filter, reject suspicious ZIP entry names instead of renaming them, bound sizes, and test with hostile archives built in the suite.