---
key: "archive-path-safety"
title: "Extract archives without escaping the destination"
url: "https://softwaresecurity.com/domains/appsec/secure-features/archive-path-safety/"
api: "https://softwaresecurity.com/api/v1/articles/archive-path-safety.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-22","CWE-23","CWE-36","CWE-59","CWE-409","A01:2025","API4:2023","V5.2.3","V5.2.5","V5.3.3","CAPEC-126","CAPEC-139","CAPEC-597"]
tags: ["File uploads and archives"]
stages: ["design","build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Extract archives without escaping the destination

Prevent traversal, link tricks, overwrites and decompression exhaustion while preserving legitimate file extraction.

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-features/archive-path-safety/

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

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Python `tarfile` extraction | `filter='data'` on Python 3.11.17, 3.12.15, 3.13.16, 3.14.8, 3.15.0 or later | Earlier releases are affected by CVE-2026-19672 and CVE-2026-11940, 3.11 to 3.13 releases also by CVE-2026-82049 and CVE-2026-87910, and releases before 3.11.13, 3.12.11 and 3.13.4 by CVE-2025-4517. The CVE-2026-87910 fix (custom filters only) is in the 3.14 branch but in no 3.14 release on 11 October 2026. Python 3.10 gets no further fixes. | CVE-2025-4517, CVE-2026-11940, CVE-2026-19672, CVE-2026-82049 and CVE-2026-87910 records and the Python 3.14.8 release notes (Python Software Foundation) |
| Member types for a document import | Regular files only; reject symlinks, hard links, devices and FIFOs | — | OWASP ASVS v5.0.0 V5.2.5 |
| Resource limits | Member count, actual bytes per file and actual total bytes; 3, 1,024 and 2,048 in this example | Count while streaming, not from header sizes. | OWASP ASVS v5.0.0 V5.2.3 |
| Created files | Mode 0600 for files and 0700 for directories; ignore archive ownership and mode bits | — | — |
| Go | `os.OpenRoot` (Go 1.24+) with `Root.MkdirAll` (Go 1.25+), plus `filepath.IsLocal` (Go 1.20+) | — | Go os and path/filepath documentation |

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

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

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

Before you start:

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

Time: About 27 minutes: 8 to read and 19 for the exercise

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

```python
"""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.

```go
// 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")
		}
	}
}

```

| Platform | Containment | Still your job |
| --- | --- | --- |
| Go | `os.OpenRoot` and `Root.OpenFile` (1.24+); `filepath.IsLocal` (1.20+) | Member types, byte limits and publishing |
| Python | `tarfile` with `filter='data'` on a patched interpreter; `zipfile` `extract` strips absolute prefixes and `..` | Links and special files, limits, duplicates and staging |
| Java | `dest.resolve(entry.getName()).normalize()` followed by `startsWith(dest)` on `Path` objects, which compares components | Symlinks in tar formats, limits and duplicates |
| .NET | `ZipFile.ExtractToDirectory` refuses entries outside the destination; manual loops compare `Path.GetFullPath` with the destination plus a separator | Limits, 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 descriptor | Every policy decision above the system call |

## 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 material

```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. Accept the useful archive

Compute A's aggregate bytes and final published files.

Worked answer: 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. Reject path and link instructions

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

Worked answer: 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. Calculate exact resource boundaries

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

Worked answer: 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. Resolve duplicate destinations

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

Worked answer: 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.

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

Scope: 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.

## Executable practice

- [Path traversal lab: Keep archive entries inside their extraction directory](https://softwaresecurity.com/labs/archive-paths/)
- [Path traversal lab: Serve an uploaded file without path traversal](https://softwaresecurity.com/labs/path-traversal-python/)
- [Keep extracted files inside their boundary: project ZIP](https://softwaresecurity.com/downloads/projects/archive-extraction-e31e8b17b78e9134.zip) — Python 3.11+ · standard library
- [Convert untrusted documents in a locked-down container: project ZIP](https://softwaresecurity.com/downloads/projects/document-conversion-sandbox-62d2ff3fa1f9ec09.zip) — Python 3.11+ offline · Docker for the live tests

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP File Upload](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html)
- [Python tarfile extraction filters](https://docs.python.org/3/library/tarfile.html#extraction-filters). Python 3.15.
- [MITRE CWE-22 path traversal](https://cwe.mitre.org/data/definitions/22.html). CWE 4.20.
- [CVE-2025-4517: tarfile realpath overflow (CVE record)](https://www.cve.org/CVERecord?id=CVE-2025-4517)
- [CVE-2026-11940: tarfile extraction filter bypass (CVE record)](https://www.cve.org/CVERecord?id=CVE-2026-11940)
- [CVE-2026-19672: tarfile extraction filter bypass creates directories outside the destination (CVE record)](https://www.cve.org/CVERecord?id=CVE-2026-19672)
- [CVE-2026-82049: tarfile extraction filters and a hard link to a symlink (CVE record)](https://www.cve.org/CVERecord?id=CVE-2026-82049)
- [CVE-2026-87910: tarfile link fallback ignores a custom filter's rejection (CVE record)](https://www.cve.org/CVERecord?id=CVE-2026-87910)
- [Python 3.14.8, an expedited security release](https://www.python.org/downloads/release/python-3148/). 30 September 2026.
- [Python 3.11.17 release notes (the same tarfile and zipfile fixes for 3.11)](https://www.python.org/downloads/release/python-31117/). 1 October 2026.
- [Go: os.Root and traversal-resistant file APIs](https://go.dev/blog/osroot). 12 March 2025.
- [Snyk: Zip Slip vulnerability](https://security.snyk.io/research/zip-slip-vulnerability)
- [OWASP ASVS 5.0.0: V5 File Handling](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x14-V5-File-Handling.md)