Last reviewed: 2026-10-09
Direct answer
Treat every path and link stored in an archive as untrusted input, even when a coding agent wrote the extraction code or the archive normally comes from your own build system. Extract into a newly created staging directory, reject any entry that can resolve outside it, permit only the member types the application actually needs, impose resource limits, and promote the staged result only after the whole archive passes validation. A rejected member should fail the operation; it should not be skipped while the rest of an apparently successful result is published.
The central review question is not “did the code remove ../?” It is “can any filesystem operation caused by this archive reach outside the extraction root?” Absolute paths, platform-specific roots, symbolic links, hard links, pre-existing destination links, duplicate names, case-folding collisions, and a check-then-open race can all defeat a narrow string replacement. GitHub’s CodeQL query help for archive extraction
documents the basic flaw: an archive entry path is unrestricted, so combining it with a destination without containment validation can write somewhere unexpected. The page recommends normalizing the output path and checking containment by path segments, or accepting only an expected allowlist of files.
Prefer a runtime facility that enforces the destination boundary during file operations. The Go project’s traversal-resistant filesystem guidance
uses archive extraction as a direct example for os.Root: methods receive names relative to an opened root and reject escapes through .. or symbolic links. When a rooted API is unavailable, canonicalize before validation, compare path components rather than raw string prefixes, and account for links and concurrent filesystem changes in the threat model. A lexical check alone is not a complete defense when another actor can modify the destination while extraction runs.
For Python tar archives, use the current tarfile extraction-filter interface
rather than relying on historical defaults. The data filter rejects several dangerous path, link, and special-file cases, ignores ownership metadata, and limits how permission bits are applied, but the documentation explicitly says no built-in filter blocks every dangerous feature. It also warns that a failed extraction can leave partial output and recommends a fresh temporary directory, link restrictions, resource limits, and entry-level checks. Those residual controls belong in the patch contract, not in a follow-up ticket.
This is a merge gate for the extraction boundary. Pair it with the broader security scan gate for coding-agent pull requests , but do not substitute a generic scanner result for adversarial extraction tests.
Who this is for
This guide is for maintainers reviewing agent-written code that unpacks build artifacts, source bundles, model assets, test fixtures, plugins, themes, backups, or uploaded archives. It is especially relevant when the code runs in CI, a developer tool, an update service, or a privileged worker, because an unexpected write can affect later commands even if extraction itself appears to finish normally.
It applies to tar, zip, and similar container formats. The exact safe API differs by language and archive library, but the evidence contract is portable: define the destination root, enumerate accepted member types, bound resource use, exercise escape attempts, and prove that failure leaves no promoted result.
This workflow does not claim that one library call makes arbitrary archives safe. Archive parsers can have their own defects, decompression can consume excessive resources, and extracted content may be dangerous when opened or executed. The gate here proves a narrower but important property: the reviewed extractor keeps writes inside its intended staging root and handles rejected archives as failed transactions.
Key takeaways
- Resolve archive entries under a dedicated extraction root; never form output names by blindly appending an entry name to a directory string.
- Reject absolute names, escaping parent components, unsafe link targets, and platform-specific special paths before any affected write occurs.
- Prefer rooted filesystem operations that enforce containment while opening files. Separate validation followed by an ordinary open can leave a time-of-check/time-of-use gap.
- Default to regular files and directories. Admit symbolic links, hard links, devices, FIFOs, permission bits, or ownership metadata only when the product contract requires them and dedicated tests cover them.
- Extract into a new, private staging directory. Do not extract over a live application tree, shared cache, source checkout, or previously populated destination.
- Limit member count, total expanded bytes, individual member size, path depth, filename length, and processing time. Path safety does not prevent archive bombs or enormous sparse files.
- Treat duplicate names and case-insensitive collisions as policy decisions. Reject ambiguity unless replacement order is an explicit, tested requirement.
- Fail the entire operation on a rejected member, retain a small sanitized reason record, and discard or quarantine partial staging output.
- Test every supported operating system. Drive-relative names, reserved device names, separators, case rules, and link semantics differ.
- Promote only a completely validated staging tree, and keep the promotion step separate from parsing and extraction.
Sources checked
- Python 3.14
tarfileextraction filters — current Python documentation fetched on 2026-10-09. It documents thedata,tar, andfully_trustedpolicies; path, link, device, and metadata handling; partial-extraction behavior; and additional verification and resource limits. - Traversal-resistant file APIs — The Go Blog
— published 2025-03-12 and fetched on 2026-10-09. It explains traversal through relative paths, symbolic links, and check-then-open races; introduces
os.Rootin Go 1.24; uses archive extraction as an example; and states platform caveats. - Arbitrary file access during archive extraction — GitHub CodeQL query help — current query documentation fetched on 2026-10-09. It describes unrestricted archive-entry paths, the resulting unexpected file access, path-segment containment validation, and file allowlisting.
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory — stable MITRE weakness taxonomy fetched on 2026-10-09. It defines relative and absolute path traversal and records consequences including unauthorized creation, overwrite, disclosure, and deletion of files.
The sources establish library and weakness behavior, not the safety of a particular repository. Statements about a candidate patch must come from its diff, runtime versions, fixtures, and observed test results.
Contract details to verify
Write the extraction policy before reviewing implementation details. At minimum, record these fields in the pull request or test manifest:
| Contract field | Decision to record | Required evidence |
|---|---|---|
| Input trust | Which archives are treated as untrusted | Data-flow note from upload, download, cache, or build boundary to extractor |
| Destination | Newly created staging root and promotion target | Test-visible paths or opaque identifiers; no sensitive host path in public logs |
| Member types | Regular files, directories, and any explicit exceptions | Allowlist in code plus one accepted and one rejected fixture per type |
| Path rules | Absolute, parent, separator, drive, device-name, depth, and length handling | Cross-platform fixture table and containment assertions |
| Link rules | Symbolic and hard links accepted or rejected | Link-before-file and link-after-file adversarial fixtures |
| Collision rules | Duplicate, Unicode-equivalent, and case-folded names | Deterministic rejection or replacement test on relevant filesystems |
| Resource budget | Entry count, expanded bytes, member bytes, CPU or time, and storage | Boundary tests plus external runner limits |
| Failure semantics | Abort, cleanup or quarantine, and retry behavior | Deliberate late failure proving no result is promoted |
| Promotion | How validated output becomes visible | Separate step tied to the exact staged tree and successful validation record |
The policy should be stricter than the archive format. Tar can represent devices and ownership metadata; that does not mean an application importing a data bundle needs either. Zip can contain paths with separators and parent components; that does not make those names valid application inputs. Format capability is not product authorization.
Build a hostile fixture matrix
Keep fixtures small and synthetic. Each should have one purpose and an expected result. Useful cases include a normal nested file; an absolute path; a parent escape; a deeply nested name; a platform-specific rooted or reserved name; a symbolic link followed by a file beneath that link; a hard link to an outside target; a special member; duplicate names; names that collide after case folding; and an archive that crosses one resource limit near the end.
Do not generate these fixtures in a production directory. Run them inside a disposable worker with an outside sentinel file and a fresh extraction root. The sentinel should begin with known content and permissions. After each rejected archive, assert that the sentinel is unchanged, no unexpected sibling exists, the operation reports failure, and no staging tree was promoted.
A compact result record can look like this:
fixture: symlink_then_child
platform: linux
runtime: recorded-by-ci
expected: reject
outside_sentinel_changed: false
promoted_output: false
staging_state: removed
reason_code: link_escape
result: pass
Keep actual host paths, archive contents, repository secrets, and environment values out of the record. A fixture identifier, platform, runtime version, booleans, counts, reason code, and commit are normally sufficient for review.
Happy-path operator workflow
- Trace the archive from its origin to the extraction call. Confirm which wrapper and library method actually perform writes, including any helper hidden behind a package-manager or plugin API.
- Freeze the supported runtime and library versions for the test. Defaults change: the current Python documentation, for example, records a safer default in 3.14 than earlier behavior.
- Create a new private staging directory on the same trust boundary as the worker. Ensure it is empty and is not reachable through a path supplied by the archive.
- Enumerate entries before promotion and enforce the contract for name, type, link target, collision, count, and size. Use the runtime’s safe extraction policy or rooted filesystem API where available.
- Extract without executing, importing, rendering, or otherwise consuming the result. Content activation is a separate security boundary.
- Verify the staged tree after extraction. Count entries and bytes, confirm every resolved object remains under the staging root, and confirm only allowed types and metadata remain.
- Promote the exact verified tree. Record the archive digest, policy version, runtime, result, counts, and promotion decision without logging file contents.
- Run the ordinary functional test that consumes a benign archive, then run the hostile fixture matrix. Require both results before merge.
If the patch also changes build isolation, use the hermetic-build verification workflow to keep extraction evidence separate from undeclared build inputs.
Error-path operator workflow
When any entry violates policy, stop processing and mark the whole import failed. Record the member index or a non-sensitive fixture identifier, a stable reason code, the policy and runtime versions, whether any bytes were staged, whether cleanup completed, and whether promotion occurred. Do not include an attacker-controlled pathname verbatim in a broadly visible log; encode or truncate it for a restricted diagnostic if an operator truly needs it.
If extraction may have written partial files, never continue with the same staging directory on retry. Remove it through a controlled cleanup path or quarantine it for restricted inspection, then start from a fresh directory. The Python documentation states that extractall() does not clean up partial output after an abort, so a thrown exception is not itself cleanup evidence.
If a safe rooted API is missing on one supported platform, classify that as an implementation gap. Do not silently fall back to an unchecked join. Either implement a platform-appropriate containment strategy with adversarial tests, constrain the feature to supported platforms, or block the patch pending a reviewed design.
Failure modes
The patch deletes literal ../ and declares victory. Encodings, alternate separators, absolute paths, drive-relative forms, link traversal, and normalization can still change the destination. Validate a parsed path under the rules of the target platform, then enforce containment at file-open time where the runtime supports it.
A raw string prefix is used for containment. A path such as /stage-other/file can share the characters of /stage without being its child. Use path-aware component comparison, and normalize or canonicalize before the check as the chosen API requires. The CodeQL guidance specifically favors Path.startsWith over String.startsWith in Java because it compares path segments.
Validation and writing are separate, raceable steps. Another actor may replace a checked directory with a link before the file is opened. The Go guidance shows why resolving links and then calling an ordinary open leaves a check-then-use window. Prefer rooted, descriptor-relative operations and keep the staging directory private.
Links are checked individually but not as a sequence. An archive can create a link in one member and write through it in a later member. Exercise ordering variants, and reject links entirely when the application does not need them.
The library filter is treated as a complete sandbox. Python’s documentation explicitly says its available filters do not block every dangerous archive feature. Add fresh-directory isolation, resource limits, collision policy, cleanup, and post-extraction verification.
A rejected entry is skipped and the import is labeled successful. That can produce an incomplete or attacker-shaped tree. Fail the complete operation unless partial import is an explicit product feature with its own integrity model.
Partial output reaches a consumer. A watcher, plugin loader, or subsequent build step may observe files before validation completes. Extract in a non-live location and make promotion the only visibility transition.
Resource exhaustion is outside the test plan. A path-safe archive can still contain too many entries or expand beyond available storage. Enforce application limits and outer process or container limits; verify both with a bounded synthetic archive.
The tests run on only one filesystem. A fixture rejected on Linux may be interpreted differently on Windows or on a case-insensitive volume. Run the path matrix for every supported target and record explicit skips as gaps, not passes.
The agent weakens the gate to make its patch pass. Watch for a change from reject to warn, an exception catch that continues, a broader member allowlist, removal of an outside-sentinel assertion, or a fallback to a fully trusted mode. Review policy changes separately from extractor changes.
FAQ
Is checking for .. enough?
No. It misses absolute names, platform-specific roots and devices, link traversal, collision behavior, and races between checking and opening. The invariant must be that every resulting filesystem operation remains within the extraction root.
Can we safely allow symbolic links?
Only when links are part of the product contract and the implementation can prove that both the link and every later operation through it remain inside the root. For ordinary data imports, rejecting symbolic and hard links is simpler and easier to verify.
Should extraction happen directly in the final directory?
No for untrusted archives. Use a fresh staging directory, validate the complete result, and then promote it. This prevents consumers from observing a half-extracted tree and makes cleanup or quarantine tractable.
Does a passing static-analysis query prove the extractor is safe?
No. Static analysis can identify known data-flow and path-construction patterns, but repository wrappers, runtime defaults, links, races, resource budgets, and promotion semantics still need tests and review. Treat scanning and hostile fixtures as complementary evidence.
What should the pull request retain?
Retain the policy version, archive or fixture digest, runtime and operating-system versions, result per fixture, staged entry and byte counts, reason codes, outside-sentinel result, cleanup result, and promotion decision. Avoid raw archive contents and sensitive host paths.
Reader next step
Choose one extractor changed or introduced by a coding agent and write its contract table before editing the implementation. Add three fixtures first: one valid nested file, one parent-directory escape, and one link followed by a child write. Run them in a disposable directory with an outside sentinel and assert both containment and non-promotion. Then add platform and resource-limit cases until the matrix covers every supported deployment target.
If the archive is a reusable agent extension or plugin bundle, continue with pre-install test gates for coding-agent skills . Keep the extraction gate as its own required check so a broader package review cannot hide a failed filesystem boundary.