In short

Treat each archive member as an instruction. Accept only the member types the feature needs, usually regular files; reject absolute names, .. components and duplicates; and write below a fresh private directory with an API that cannot escape it, such as Go's os.Root. Count the bytes actually written against per-file and total limits, publish only after every member succeeds, and delete the staging directory on any failure.

  • Member names, types and link targets are filesystem instructions: one ../ or symlink can redirect a write, the flaw known as Zip Slip.
  • Compare path components, not strings: /work/import-other starts with /work/import but is outside it.
  • Reject symlinks, hard links and special files unless the feature needs them; most importers need only regular files.
  • Python's tarfile filters were bypassed in 2025 and several times in 2026: run a patched interpreter and still enforce your own type policy.
  • Count decompressed bytes and members while extracting; header sizes and compression ratios can mislead.
  • Stage privately, publish atomically, and clean up only the directory the job created.

Verify it

  1. Extract archives containing ../, an absolute path, a symlink followed by a child entry, a hard link and a duplicate name; each must fail with an outside sentinel unchanged.
  2. Plant a symlink inside the staging directory before extraction and confirm no file appears at its target.
  3. Test exact limits: 1,024 and 1,025 bytes per file, 2,048 and 2,049 in total, three and four members.
  4. After every failure, confirm no staging or partial directory remains and nothing was published.
  5. On every host that extracts with tarfile, compare python3 --version with the patched minimums.
Applies to
  • Python tarfile filters 3.11.17, 3.12.15, 3.13.16, 3.14.8, 3.15.0 and later; 3.10 reached end of life on 1 October 2026
  • Go os.Root Go 1.24 and later; Root.MkdirAll from Go 1.25

Archive extraction and untrusted filenames. OS path rules, filesystem races and decompressor behavior require local tests.

Part of
Workflows: Fix a finding, Secure a feature

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 27 minutes: 8 to read and 19 for the exercise

What you will learn

  • Treat member names and types as filesystem instructions.
  • Enforce containment and actual resource budgets.
  • Verify no outside or partial public writes remain after failure.

Before you start

Developers who extract uploaded or downloaded archives in services, desktop apps or CI jobs, and reviewers of path-traversal findings.

  • Understand relative and absolute paths, symbolic links and archive expansion.

Refresh first: Design a safer file-upload pipeline

Go to the guided practice

Read archive members as instructions to a filesystem

An archive supplies more than compressed bytes. Each member can name a path, a type, permissions, link target and advertised size. A naive extractor joins its destination with the member name and writes there. For destination /work/import and member ../outside.txt, path resolution leaves the intended root. An absolute member name can ignore the destination entirely. A symlink member can redirect later writes even when every later member name looks relative. Snyk named the pattern Zip Slip in 2018 after finding it in extraction code across many ecosystems.

Define the feature before selecting extraction APIs. The example importer accepts regular files only, with at most three members, at most 1024 bytes per file and 2048 total uncompressed bytes. It does not need symlinks, hard links, devices or archive-preserved ownership. Rejecting unnecessary member types removes complex semantics. Python's tarfile extraction filters provide useful safeguards, and Python 3.14 made filter='data' the default, but the filters themselves have been bypassed: CVE-2025-4517 (CVSS 9.4) and four related CVEs allowed writes and links outside the destination until 3.9.23, 3.10.18, 3.11.13, 3.12.11 and 3.13.4, and CVE-2026-11940, an incomplete fix, until 3.10.21, 3.11.16, 3.12.14, 3.13.15 and 3.14.7. Three more followed in August and September 2026, all fixed in 3.11.17, 3.12.15 and 3.13.16 (30 September and 1 October 2026): CVE-2026-19672 let both filters create empty directories outside the destination for names such as ../evil/../dest/sub/file on POSIX systems (also fixed in 3.14.8); CVE-2026-82049, a hard link to a symlink, let extraction change the permissions or timestamps of a file outside the destination or expose its contents (3.14 is not affected); and CVE-2026-87910 ignored a custom filter's None when a link is extracted as a copy on a system that cannot create it, a fix that is in the 3.14 branch but in no 3.14 release on 11 October 2026. The CVE-2026-19672 record notes that extraction into a randomly named directory is not affected, because the name must contain the destination's own final component, which is one more reason to extract into a fresh tempfile.mkdtemp() directory. Python 3.10 reached end of life on 1 October 2026 with 3.10.22. No filter is a substitute for the application's own file-type and resource policy, and code that relies on the 3.14 default inherits the same bugs. Configure the intended behavior explicitly.

Keep all writes inside a private destination

Use a newly created private extraction directory that untrusted local actors cannot modify. Reject absolute paths and parent traversal according to the platform's path rules; normalize names consistently before checking duplicates. A simple string prefix is wrong because /work/import-other starts with /work/import but is not inside it. Use path-component containment or directory-relative filesystem operations designed for this purpose; the fix cards at /fix/path-traversal/ show the component check in seven frameworks.

Checking a resolved path and opening it later can be unsafe if another actor can replace a directory with a symlink between those operations. Restricting the workspace and rejecting links reduces that race; stronger extractors use safe directory handles and no-follow semantics, such as Go's os.Root or Linux openat2 with RESOLVE_BENEATH. Do not overwrite existing files, and reject duplicate normalized destinations rather than allowing an archive's later entry to replace an earlier validated one. Consider platform aliases such as drive paths, separators, case folding and reserved names when moving archives across operating systems. The POSIX-only fixture makes its narrower assumptions explicit.

The Python program below implements the exercise contract with the standard library. It reads tar headers one at a time, rejects any member that is not a regular file before writing it, refuses absolute names, .. components, backslashes and duplicates, and copies each member with its own byte counters into a private staging directory created inside the publish root, opening every file with exclusive create. It never calls extractall, so the filter bugs above cannot affect it. Only when every member succeeds does one os.rename publish the directory under a server-generated import id; any failure removes the staging directory. A test that builds the seven exercise archives plus an absolute path and a hard link publishes A and D, rejects the other seven, leaves the outside sentinel unchanged and leaves no staging directory behind on Python 3.11.17 and 3.14.8.

Fixedsafe_extract.pyPython
"""safe_extract.py: import regular files from an untrusted tar archive (Python 3.11+)."""
import os
import shutil
import tarfile
import tempfile
from pathlib import Path, PurePosixPath

MAX_FILES, MAX_FILE_BYTES, MAX_TOTAL_BYTES = 3, 1024, 2048


class Rejected(Exception):
    pass


def member_path(name: str) -> PurePosixPath:
    path = PurePosixPath(name)
    if path.is_absolute() or not path.parts or ".." in path.parts or "\\" in name:
        raise Rejected(f"unsafe name {name!r}")
    return path


def import_archive(archive: Path, publish_root: Path, import_id: str) -> Path:
    """Publishes publish_root/import_id only if every member passes; otherwise writes nothing."""
    staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=publish_root))  # private, 0700
    try:
        seen, total = set(), 0
        with tarfile.open(archive, "r:*") as tar:
            for count, member in enumerate(tar, start=1):  # headers are read one at a time
                if count > MAX_FILES:
                    raise Rejected("too many members")
                if not member.isreg():  # no symlinks, hard links, devices or directories
                    raise Rejected(f"{member.name!r} is not a regular file")
                path = member_path(member.name)
                if path in seen:
                    raise Rejected(f"duplicate {path}")
                seen.add(path)
                target = staging.joinpath(*path.parts)
                target.parent.mkdir(parents=True, exist_ok=True)
                written = 0
                with tar.extractfile(member) as src, open(target, "xb") as dst:  # never overwrite
                    while chunk := src.read(64 * 1024):  # count bytes actually produced
                        written += len(chunk)
                        total += len(chunk)
                        if written > MAX_FILE_BYTES or total > MAX_TOTAL_BYTES:
                            raise Rejected("size limit exceeded")
                        dst.write(chunk)
        final = publish_root / import_id  # a server-generated id, never a caller-supplied path
        os.rename(staging, final)  # publish only after every member succeeded
        return final
    except BaseException:
        shutil.rmtree(staging, ignore_errors=True)
        raise

Use the containment API your platform provides

Go 1.24 added os.Root, which opens a directory once and refuses any path that resolves outside it, including through symbolic links that appear later. The function below combines it with filepath.IsLocal, a regular-file check, exclusive create and byte limits. Its tests, run with Go 1.27, accept A and D, reject the other exercise archives and an absolute path, and prove the containment: when another process plants a symlink inside the staging directory, the extractor fails, while an ordinary os.WriteFile through the same path writes outside.

Use the containment API your platform provides
PlatformContainmentStill your job
Goos.OpenRoot and Root.OpenFile (1.24+); filepath.IsLocal (1.20+)Member types, byte limits and publishing
Pythontarfile with filter='data' on a patched interpreter; zipfile extract strips absolute prefixes and ..Links and special files, limits, duplicates and staging
Javadest.resolve(entry.getName()).normalize() followed by startsWith(dest) on Path objects, which compares componentsSymlinks in tar formats, limits and duplicates
.NETZipFile.ExtractToDirectory refuses entries outside the destination; manual loops compare Path.GetFullPath with the destination plus a separatorLimits, staging and cleanup: an error part-way leaves a partial extraction
Linux C and C++openat2 with RESOLVE_BENEATH (Linux 5.6+), relative to a directory descriptorEvery policy decision above the system call
Fixedextract.goGo
// Package archive extracts regular files from an untrusted tar stream into one directory.
package archive

import (
	"archive/tar"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
)

const maxFiles, maxFileBytes, maxTotalBytes = 3, 1024, 2048

// Extract writes the archive's regular files under dir, a fresh private staging directory.
// os.Root (Go 1.24+; MkdirAll needs 1.25) refuses any path that resolves outside dir,
// including through symlinks, so a checked name cannot be redirected later.
func Extract(r io.Reader, dir string) error {
	root, err := os.OpenRoot(dir)
	if err != nil {
		return err
	}
	defer root.Close()
	tr := tar.NewReader(r)
	var total int64
	for n := 1; ; n++ {
		hdr, err := tr.Next()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
		if n > maxFiles {
			return errors.New("too many members")
		}
		if hdr.Typeflag != tar.TypeReg {
			return fmt.Errorf("%q: only regular files are accepted", hdr.Name)
		}
		if !filepath.IsLocal(hdr.Name) { // absolute, "..", empty or reserved names
			return fmt.Errorf("%q: not a local path", hdr.Name)
		}
		name := filepath.Clean(filepath.FromSlash(hdr.Name))
		if err := root.MkdirAll(filepath.Dir(name), 0o700); err != nil {
			return err
		}
		f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) // no overwrite
		if err != nil {
			return err
		}
		written, err := io.Copy(f, io.LimitReader(tr, maxFileBytes+1))
		f.Close()
		total += written
		if err != nil {
			return err
		}
		if written > maxFileBytes || total > maxTotalBytes {
			return errors.New("size limit exceeded")
		}
	}
}

Account for actual work and partial extraction

An archive header's claimed size is useful for early rejection, but the extractor must count bytes actually produced. Compression ratios can turn a small incoming object into large output; nested archives can multiply that work again if the application recursively opens them. Bound incoming bytes, member count, path depth, decoded output, CPU and time, and avoid automatic recursion unless it is a separately budgeted feature. DOCX, XLSX and ODF files are ZIP archives too: /domains/appsec/secure-features/untrusted-document-conversion/ applies these checks before a document converter opens them.

Define failure semantics before the first write. This exercise treats extraction as an atomic import: any invalid member invalidates the whole archive and private partial output is removed. That prevents callers from receiving a partially accepted directory whose contents depend on member order. If a product intentionally supports partial extraction, it needs an explicit per-member report and must still guarantee that rejected entries caused no outside writes. Neither an exception nor a failed HTTP status proves the filesystem was unchanged.

Verify names, types, bytes and cleanup independently

Keep an outside sentinel in a controlled test workspace and compare its content before and after extraction. Inspect all files created under the destination, including temporary output. The normal archive should publish the expected bytes. A traversal entry, link or duplicate should leave no published output and no changed outside sentinel. A count or byte-limit boundary should fail exactly according to the documented inclusive limit.

The exercise supplies member metadata and actual output sizes, so its decisions require no archive tool or filesystem access. For execution practice, the browser lab at /labs/archive-paths/ and the archive-extraction project below exercise the same rules. Real integrations should test their actual archive library, runtime version and target filesystem because tar, ZIP and platform path rules differ. Diagnose failures using the member index and policy category, with bounded names; do not log unbounded attacker-supplied filenames or retain rejected archives indefinitely.

Review publication and cleanup as separate authority changes

Extracting into a private directory prevents immediate exposure, but publication can still introduce a path bug. Suppose the caller supplies the final destination name and the importer renames its safe temporary directory to that unchecked location. All member checks pass, yet publication escapes the permitted namespace. Derive the final destination from an authorized import identity and a controlled storage root, then make the publish transition atomic where the filesystem and product contract permit it.

Cleanup must operate only on the private directory the job owns. An error handler that recursively removes a caller-supplied path or follows links can turn a rejection into a destructive action. Retain a trusted directory handle or server-generated path identity throughout processing, and avoid re-resolving untrusted names to decide what to delete. The exercise's cleanup result assumes ownership of its isolated root; it is not permission to recursively delete arbitrary import paths.

Metadata also deserves a policy. Preserving executable bits, ownership or device types may grant capabilities the file-import feature never needed. For a regular-document importer, choose safe permissions for created files and directories and ignore archive-supplied ownership. If executable archives are a legitimate product feature, that requires a separate trust and execution model. A successful path-containment check answers where bytes went; it does not establish that their eventual execution is authorized.

Guided practice

Evaluate independent archives using a complete POSIX regular-file policy.

Supplied scenario material
Plain text
Root=/work/import (private, initially empty). Outside sentinel=/work/sentinel contains KEEP. Limits: <=3 files; <=1024 actual bytes/file; <=2048 total. Any violation aborts and deletes private partial output.
A: docs/a.txt regular 800 bytes; docs/b.txt regular 1000 bytes.
B: ok.txt regular, 10 bytes; ../sentinel regular, 4 bytes.
C: link named jump -> /work; jump/sentinel regular, 4 bytes.
D: a.txt regular, 1024 bytes; b.txt regular, 1024 bytes.
E: a.txt regular, 1024 bytes; b.txt regular, 1025 bytes.
F: a.txt regular, 10 bytes; a.txt regular, 20 bytes.
G: four distinct regular files of 1 byte each.
All archives are independent; reset root before each. Names are already decoded; POSIX separators apply.
  1. 1. Accept the useful archive

    Compute A's aggregate bytes and final published files.

    Expected result and reasoning

    A has two regular files and a total of 1800 bytes, both below 1024 per file. Publish docs/a.txt and docs/b.txt with the supplied lengths. Sentinel remains KEEP.

  2. 2. Reject path and link instructions

    Trace B and C, including any file written before discovering a bad member.

    Expected result and reasoning

    B rejects ../sentinel. Its earlier private ok.txt is removed; sentinel stays KEEP. C rejects the link before processing its dependent path. No outside write or public partial directory is permitted.

  3. 3. Calculate exact resource boundaries

    Evaluate D, E and G using actual output, not compressed size.

    Expected result and reasoning

    D totals 2048 and each file is 1024, so it succeeds. E's second file exceeds its limit by 1 and the total would be 2049; abort and clean up. G exceeds the member count despite using only 4 bytes.

  4. 4. Resolve duplicate destinations

    Evaluate F and explain why last-entry-wins is undesirable.

    Expected result and reasoning

    F aborts on the second normalized a.txt; no output is published. Otherwise a file could be validated in its first form and silently replaced by later content. Duplicate policy is part of the import contract.

Your deliverable: A seven-archive decision table with final filesystem state and exact byte/count arithmetic.

Paper exercise: member metadata and actual sizes are supplied. safe_extract.py and extract.go above run the same seven archives against a real filesystem.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

Path traversal lab: Keep archive entries inside their extraction directory

Path traversal lab: Serve an uploaded file without path traversal

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Python 3.11+ · standard library · 30–45 min

Keep extracted files inside their boundary

Inspect actual ZIP entries and repair a path policy. Test traversal, links, duplicate entries, expansion limits and ordinary nested files.

In the extracted folder, run:

Shell
python3 -m unittest test_archive.py

7 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.

Temporary directories, generated ZIPs and an independent containment guard keep deliberately broken policy code inside the exercise.

Python 3.11+ offline · Docker for the live tests · 45–75 min

Convert untrusted documents in a locked-down container

Repair a document worker that drives LibreOffice and Ghostscript in Docker, until a zip bomb and DTD payloads stop at preflight, the container has no network or writable root, and a hung conversion is killed with its container.

Needs: The live tests need Docker with Linux containers, and internet access once to build the converter image (about 200 MB of Debian packages). The offline tests need only Python.

In the extracted folder, run:

Shell
python3 -m unittest -v test_worker.py
docker build -t doc-converter .
python3 -m unittest -v test_live.py

12 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.

The offline tests use a stand-in for the docker CLI. The 8 live tests ran against LibreOffice 25.2.3 and Ghostscript 10.05.1 in Docker 29.4.3: zip bomb, XXE, network and timeout cases. It is not content disarm and reconstruction, and output checks do not inspect PDF content.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP File Upload
  2. Python tarfile extraction filters. Python 3.15.
  3. MITRE CWE-22 path traversal. CWE 4.20.
  4. CVE-2025-4517: tarfile realpath overflow (CVE record)
  5. CVE-2026-11940: tarfile extraction filter bypass (CVE record)
  6. CVE-2026-19672: tarfile extraction filter bypass creates directories outside the destination (CVE record)
  7. CVE-2026-82049: tarfile extraction filters and a hard link to a symlink (CVE record)
  8. CVE-2026-87910: tarfile link fallback ignores a custom filter's rejection (CVE record)
  9. Python 3.14.8, an expedited security release. 30 September 2026.
  10. Python 3.11.17 release notes (the same tarfile and zipfile fixes for 3.11). 1 October 2026.
  11. Go: os.Root and traversal-resistant file APIs. 12 March 2025.
  12. Snyk: Zip Slip vulnerability
  13. OWASP ASVS 5.0.0: V5 File Handling

Reference information

ASVS 5.0.0 requirements
v5.0.0-5.2.3 · v5.0.0-5.2.5 · v5.0.0-5.3.3

Download this guide as Markdown