Test Python tarfile data filters before extracting deployment artifacts

John Burns

An archive-extraction step is often hidden inside a deployment helper: download a release bundle, unpack it, then move the expected files into place. That makes the archive a filesystem input, not just a transport format. A member name, symlink, hard link, device entry, ownership field, or permission can change what extraction tries to create.

Python’s tarfile module now has extraction filters for this boundary. Python 3.14 changed the default filter to data, but Python 3.13 and earlier have a less restrictive default. Passing filter="data" explicitly is therefore useful in code that supports more than one Python release. It is not a substitute for patching: Ubuntu’s September Python advisory includes CVE-2026-4224, involving how the tarfile filter parameter was applied to hard links. Update the interpreter or distribution package before relying on any filter behavior.

This guide adds a small extraction wrapper and a disposable test fixture. The validation run used Python 3.13.5 on Linux. A synthetic archive with one ordinary file and an absolute hard-link target was accepted by an explicit fully_trusted extraction attempt. The same archive was rejected by filter="data" with AbsoluteLinkError. Nothing was extracted into a deployment directory; the test ran in a temporary directory and was removed after capture.

Start with the interpreter and the archive boundary

Check the interpreter that will execute the deployment tool, rather than the version on an administrator’s workstation:

python3 --version
python3 -c 'import tarfile; print(tarfile.__file__)'

The location matters on systems with several Pythons, virtual environments, or a vendor-supplied runtime. Record the package version in the deployment log or release evidence. If the code runs in a container, inspect the interpreter in the built image. Updating the host package does not update a copied virtual environment or an application image.

tarfile supports regular files, directories, symbolic links, hard links, FIFOs, and device entries. A safe deployment bundle may legitimately need only directories and regular files. Do not assume that a .tar.gz suffix tells you which member types it contains. The archive producer, its signing and checksum policy, the downloader, and the extractor all form part of the trust boundary.

The Python documentation warns against extracting an untrusted archive without inspection. The data filter reduces several dangerous behaviors: it rejects links to absolute paths and links that resolve outside the destination, rejects special files, and adjusts or ignores some ownership and permission metadata. It does not establish that the release bundle is authentic, that the contents are suitable to execute, or that resource usage is bounded. Verify the release artifact before extraction and use a dedicated destination that the deployment account can write without elevated privileges.

Make the filter explicit in deployment code

Use a named function so the security decision is visible in review. This example extracts into a destination that has already been created by the caller. It intentionally leaves archive signature verification, available-space checks, and the later atomic deployment move to the surrounding release process.

from pathlib import Path
import tarfile


def extract_release(archive: Path, destination: Path) -> None:
    destination.mkdir(parents=True, exist_ok=True)
    with tarfile.open(archive, mode="r:*") as bundle:
        bundle.extractall(path=destination, filter="data")

mode="r:*" lets tarfile detect the supported compression format. filter="data" is the important setting: it makes the desired policy independent of the Python default. Do not replace it with fully_trusted merely because an older release bundle contains an unusual link. First identify why that link is necessary, whether it remains inside the intended tree, and whether a regular file or a deployment-time link creation step would be clearer.

Treat a filter rejection as a release failure. A broad exception handler that logs the error and continues can leave a partly populated directory that a later step mistakes for a complete deployment. Extract into a new per-release directory, reject on error, remove that failed directory through the existing cleanup policy, and only then make a verified release current. Do not extract directly over the live application path.

The filter does not automatically provide a transaction. extractall() works through the archive in order. A member that was safe and extracted before a later rejected member can remain in the destination. A fresh destination gives the caller a simple rollback boundary: discard the whole candidate release after a rejection rather than trying to determine which files are safe to retain.

Build a disposable negative fixture

Test both the desired regular-file path and an archive shape the filter must reject. The following script builds a temporary archive. It contains a normal payload, then a hard-link member whose target is absolute. The fixture is synthetic; do not test with a production archive or an operating-system directory.

import io
import tarfile
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as work:
    root = Path(work)
    archive = root / "fixture.tar"

    with tarfile.open(archive, "w") as bundle:
        payload = b"known payload\n"
        member = tarfile.TarInfo("release/payload.txt")
        member.size = len(payload)
        bundle.addfile(member, io.BytesIO(payload))

        member = tarfile.TarInfo("release/unsafe-link")
        member.type = tarfile.LNKTYPE
        member.linkname = "/absolute-target"
        bundle.addfile(member)

    destination = root / "candidate-release"
    try:
        with tarfile.open(archive) as bundle:
            bundle.extractall(destination, filter="data")
    except tarfile.TarError as exc:
        print(f"REJECT: {type(exc).__name__}: {exc}")
    else:
        raise SystemExit("expected the data filter to reject the fixture")

An absolute target is sufficient for a portable negative test because data is documented to reject it. The fixture should not point to a real sensitive path. Its purpose is to prove that the caller handles a policy rejection, not to demonstrate access to a host file.

The validation run printed the following result with Python 3.13.5:

REJECT: AbsoluteLinkError: 'release/unsafe-link' is a link to an absolute path

For comparison, the same archive was processed with filter="fully_trusted" in an isolated temporary directory. That attempt returned successfully. This is the useful paired observation: the difference was the extraction policy, not an archive download or a deployment service. Do not put a fully_trusted test in a normal CI job when its fixture can refer to a real host path; use a disposable directory and a deliberately nonexistent target if a compatibility test is needed.

Check the expected release path separately

A negative fixture proves that the filter rejects one class of unsafe member. It does not prove that the files a deployment needs were extracted or that the application can use them. Follow the extraction with a small, release-specific check against the candidate directory.

For example, a static application release may require an executable entry point and a configuration template. Keep the list local to that application rather than accepting every file in an archive because it shares a prefix:

required = ("bin/start", "config/app.example.toml")
missing = [name for name in required if not (destination / name).is_file()]
if missing:
    raise RuntimeError("release is missing: " + ", ".join(missing))

This check is illustrative. It should run before a symlink switch, service restart, image promotion, or other operation that exposes the release. A web application may also need a configuration schema check, an offline migration check, or a process startup probe. Those are application controls, not tarfile controls.

If a release format genuinely needs symbolic links, hard links, ownership preservation, or special files, do not silently relax the filter. Define the exact required members, inspect them before extraction, and use a dedicated builder and deployment account. A custom filter can be appropriate, but it must be treated as application security code and tested with positive and negative archives. The default data policy is a better starting point than recreating path checks with string operations.

Patch, test, and keep the failure visible

First, apply the vendor’s current Python update to every runtime that extracts release artifacts. CVE notices describe a specific fixed issue; they are not a reason to leave an old interpreter in place because a local fixture happened to reject a different unsafe link. Then add an explicit data filter to supported application code so a future base-image or interpreter change does not weaken the intended policy.

Run the synthetic positive and negative tests in the same pipeline that builds the deployment tool. Preserve the interpreter version and the concise rejection message, but do not publish raw archive listings, deployment paths, artifact URLs, credentials, or environment details. A rejected archive should stop the release and leave the current deployment unchanged.

The practical result is a narrow, testable boundary: a deployment helper accepts the expected archive structure only after tarfile applies the data policy, and a synthetic hard-link fixture demonstrates that a policy violation stops the candidate release before activation.

Sources