Engineering research / security

Path Traversal and Zip Slip in File-Handling Code: Resolve, Then Check Containment, and the Cases That Break Naive Filters

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.

By Sandeep Belgavi · Published 2026-11-01

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

FilterBypass
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 laterA symlink created between the check and the open
Check the name, extract the archiveA 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 target

Path.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.

Namenormpath + startswithresolve + is_relative_to
returns/CASE-4101.pdfacceptedaccepted
../escape.txtrejectedrejected
../uploads2/sibling.txtaccepted (wrong)rejected
returns/../../escape2.txtrejectedrejected
/abs.txt and C:/win.txtrejectedrejected

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

  1. 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.
  2. Build hostile archives in the test suite with the standard library, as the probe did, rather than downloading samples.
  3. 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.
  4. 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.

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.

Views are my own and do not represent any employer, past or present.