An extractor can write outside its destination and still raise the expected rejection error. A symlink-containment regression must check both the error and the filesystem it leaves behind.
For example, create a destination symlink pointing to a separate temporary directory, then extract a one-entry archive containing link/escaped.txt:
workspace/
├── destination/
│ └── link -> ../outside
├── outside/
└── payload.zip # contains link/escaped.txt
The entry contains no ... Its destination still escapes because the filesystem resolves link before creating escaped.txt.
The merge decision is concrete: if an extractor leaves outside/escaped.txt behind, or silently ignores the entry instead of reporting rejection, it fails this contract. Raising the expected error after leaving that file also fails.
An exception alone does not prove that no write occurred.
Filename filtering misses existing symlinks
Rejecting absolute paths and parent-directory components is useful, but it checks the archive entry's spelling. Containment also depends on where that path resolves in the destination filesystem:
| Check | Result for link/escaped.txt |
|---|---|
| Is the entry absolute? | No |
Does it contain a .. component? |
No |
Does its resolved destination stay under destination/? |
No |
| Should this extraction contract accept it? | No |
I would make this a blocking regression test whenever extraction can encounter an existing destination tree. Inspecting entry names alone misses the symlink in this fixture.
The filesystem state is part of the test input.
A self-contained regression test
Save the following as test_archive_containment.py. It uses the Python standard library and targets Python 3.10 or later on a symlink-capable Linux runner.
The file includes three implementations:
unsafe_extractrejects absolute paths and.., but follows an existing destination symlink.write_then_reject_extractwrites the escaped file before checking containment and raisingUnsafeArchiveError.stable_tree_extractresolves each destination and rejects paths outside the resolved extraction root before writing.
The negative test requires the documented error and independently checks that the outside directory is empty afterward. This is a final-state check, not proof that no outside write ever occurred: an extractor that writes and then deletes the escaped file before raising would pass it.
stable_tree_extract separates its containment check from its write. Do not use it as a complete extractor where another process can modify the destination tree during extraction.
import os
import shutil
import tempfile
import unittest
import zipfile
from pathlib import Path, PurePosixPath
class UnsafeArchiveError(ValueError):
pass
def entry_path(root, name):
# ZIP member paths use forward slashes. This fixture targets Linux.
member = PurePosixPath(name)
if member.is_absolute() or ".." in member.parts:
raise UnsafeArchiveError(f"Disallowed archive path: {name!r}")
return root.joinpath(*member.parts)
def write_member(archive, info, target):
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
return
target.parent.mkdir(parents=True, exist_ok=True)
# This example deliberately does not overwrite existing files.
with archive.open(info) as source, target.open("xb") as output:
shutil.copyfileobj(source, output)
def unsafe_extract(archive_path, destination):
root = Path(destination).resolve()
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
target = entry_path(root, info.filename)
write_member(archive, info, target)
def write_then_reject_extract(archive_path, destination):
root = Path(destination).resolve()
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
target = entry_path(root, info.filename)
write_member(archive, info, target)
# Deliberately broken: containment is checked after writing.
if not target.resolve().is_relative_to(root):
raise UnsafeArchiveError(
f"Archive path escapes destination: {info.filename!r}"
)
def stable_tree_extract(archive_path, destination):
root = Path(destination).resolve()
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
target = entry_path(root, info.filename).resolve()
if not target.is_relative_to(root):
raise UnsafeArchiveError(
f"Archive path escapes destination: {info.filename!r}"
)
write_member(archive, info, target)
EXTRACT = {
"contained": stable_tree_extract,
"unsafe": unsafe_extract,
"write_then_reject": write_then_reject_extract,
}[os.environ.get("EXTRACTOR", "contained")]
class ArchiveContainmentTests(unittest.TestCase):
def setUp(self):
workspace = tempfile.TemporaryDirectory()
self.addCleanup(workspace.cleanup)
self.root = Path(workspace.name)
self.destination = self.root / "destination"
self.outside = self.root / "outside"
self.archive = self.root / "payload.zip"
self.destination.mkdir()
self.outside.mkdir()
(self.destination / "link").symlink_to(
self.outside, target_is_directory=True
)
def make_archive(self, name, contents):
with zipfile.ZipFile(self.archive, "w") as archive:
archive.writestr(name, contents)
def test_rejects_existing_symlink_escape_without_outside_files_remaining(self):
self.make_archive("link/escaped.txt", b"must stay contained")
try:
with self.assertRaises(UnsafeArchiveError):
EXTRACT(self.archive, self.destination)
finally:
# Check the outside directory directly, without using the
# extractor's path-validation helper.
self.assertEqual(
list(self.outside.iterdir()),
[],
"Extraction left entries outside the destination",
)
def test_extracts_an_ordinary_nested_file(self):
self.make_archive("nested/ok.txt", b"expected contents")
EXTRACT(self.archive, self.destination)
self.assertEqual(
(self.destination / "nested" / "ok.txt").read_bytes(),
b"expected contents",
)
self.assertEqual(list(self.outside.iterdir()), [])
if __name__ == "__main__":
unittest.main()
The positive test matters: an implementation that rejects every archive would satisfy the rejection test alone.
The negative test checks two separate obligations. It requires UnsafeArchiveError, and its finally block inspects the outside directory even if the rejection assertion fails. That inspection does not reuse the extractor's path-validation helper.
Symlink escape checklist
Use these five signals when reviewing or porting the fixture:
| Signal | What the test must establish |
|---|---|
| Controlled filesystem | The destination already contains a real symlink to a separate temporary directory. |
| Minimal archive | One entry, link/escaped.txt, isolates the symlink behavior without literal parent traversal. |
| Explicit rejection | The extractor reports the documented containment error. |
| Independent final-state check | The outside directory is empty afterward, regardless of whether rejection occurs. |
| Ordinary-file positive control | A nested file extracts successfully with the expected contents. |
Keep existing absolute-path and .. regression tests too: this fixture adds a missing case rather than replacing those checks.
Concurrent changes need a different containment design
The stable-tree implementation resolves the destination path, checks it, and then performs filesystem operations. Those are separate steps.
Another process with permission to modify the destination tree could replace a directory with a symlink between the check and the write. Passing this fixture is not evidence of resistance to concurrent symlink replacement.
If untrusted processes can mutate that tree, use a design with operating-system-enforced traversal constraints, or change the workflow so those processes cannot modify the extraction tree during extraction. That needs a separate review and test strategy.
The example also handles directories and file contents only. It does not recreate archived symlink metadata, preserve permissions, impose decompression limits, or roll back earlier entries when a later entry is rejected. Those are separate policy decisions.
Use the unsafe implementations to check the test
Run the default implementation:
python -m unittest -v test_archive_containment
Then deliberately select each broken implementation:
EXTRACTOR=unsafe python -m unittest -v test_archive_containment
EXTRACTOR=write_then_reject python -m unittest -v test_archive_containment
The default implementation should pass both tests. Both broken implementations should pass the ordinary-file test and fail the escape test, but for different reasons:
| Implementation | Rejection assertion | Independent filesystem assertion |
|---|---|---|
unsafe_extract |
Fails: no rejection error is raised. | Fails: outside/escaped.txt remains. |
write_then_reject_extract |
Passes: the expected error is raised. | Fails: outside/escaped.txt remains. |
The second broken implementation demonstrates why the exception assertion is insufficient. Its error looks correct; its final filesystem state is not.
These commands check the test itself. If either deliberately broken implementation passes, investigate the fixture or test invocation before trusting the gate.
For CI, use the default command as the blocking check. Keep the broken runs as local diagnostics, unless your CI explicitly supports jobs whose failure is expected. A failed symlink setup should remain visible rather than silently skipping the security regression.
To try this today, save the file and run the commands above. For a real extractor, replace EXTRACT with a small adapter that calls its public extraction function, and change the expected exception to its documented rejection type. Preserve the independent outside-directory assertion. Do not catch arbitrary exceptions and count them as successful rejection: a broken archive reader is not evidence of containment.