Archive extraction looks simple: open a ZIP or TAR file, iterate over its entries, and write each entry under a destination directory. The dangerous detail is that archive entries carry names and, in some formats, filesystem object types. Those fields come from the archive creator.
If extraction code joins an untrusted entry name to a trusted destination without enforcing containment, an entry such as ../../app/config.json can escape the intended directory. A crafted archive can then overwrite files that the application account is permitted to modify.
A robust extractor treats the archive as a description of proposed filesystem changes, not as a trusted directory tree.
Start with a precise extraction boundary
Assume an application imports an archive into this directory:
/srv/imports/job-42/The intended rule is stronger than “prefix each name with the destination.” The rule is:
Every created filesystem object must remain inside the designated extraction root, and every entry type must be explicitly permitted.
This distinction matters because string concatenation does not enforce a filesystem boundary.
Consider these entry names:
report.csv
images/logo.png
../../app/config.json
/var/tmp/payload
images/../../../keys/service.keyThe first two can fit a normal import policy. The remaining entries attempt to address locations outside the extraction root.
An extractor should reject unsafe entries before creating them.
Normalize paths before testing containment
A useful validation sequence is:
- Reject absolute entry paths.
- Combine the entry name with the extraction root.
- Normalize the combined path according to the target platform.
- Verify that the normalized result is a descendant of the extraction root.
- Create the object only after the containment check succeeds.
The containment test should use path-aware operations rather than a raw string-prefix test. A prefix check can confuse neighboring paths such as /srv/imports/job-42-safe with /srv/imports/job-42.
In Go, filepath.Rel can express the relationship between the trusted root and a candidate path:
package archiveguard
import (
"fmt"
"path/filepath"
"strings"
)
func safeTarget(root, entryName string) (string, error) {
if filepath.IsAbs(entryName) {
return "", fmt.Errorf("absolute archive path rejected")
}
target := filepath.Join(root, entryName)
rel, err := filepath.Rel(root, target)
if err != nil {
return "", fmt.Errorf("resolve archive path: %w", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("archive path escapes extraction root")
}
return target, nil
}This code establishes a useful lexical boundary, but path names are only one part of the problem.
Treat links as a separate security decision
TAR archives can represent symbolic links and hard links. If an extractor creates a link and later writes through a path that traverses it, the final write can land outside the intended directory even when the later entry name appears harmless.
For example, an archive can propose this sequence:
entry 1: cache -> /etc
entry 2: cache/example.confIf cache becomes a symbolic link to /etc, writing the second entry can modify /etc/example.conf.
A simple policy for application imports is to reject symbolic links, hard links, device nodes, FIFOs, sockets, and any other entry type the feature does not require. Permit only regular files and directories when that is sufficient for the product.
switch header.Typeflag {
case tar.TypeReg, tar.TypeRegA:
// regular file accepted
case tar.TypeDir:
// directory accepted
default:
return fmt.Errorf("archive entry type rejected: %d", header.Typeflag)
}If a product genuinely requires links, link targets need their own validation model and the implementation must account for filesystem resolution during extraction. That is substantially harder than handling regular files and directories.
Do not trust archive permissions
Archive metadata can carry permission bits. Replaying those bits without a local policy can create executable files or directories with access modes that do not fit the service.
Choose permissions from application policy instead:
const (
fileMode = 0o600
dirMode = 0o700
)The exact modes depend on the deployment. The key principle is that an untrusted archive should not decide the effective permissions of extracted content.
Ownership metadata deserves the same treatment. A service normally should not attempt to reproduce arbitrary user or group identifiers from an uploaded archive.
Bound decompression work
Path containment prevents writes to unintended locations, but it does not prevent resource exhaustion. A small compressed input can expand into a much larger amount of data. An archive can also contain a huge number of tiny entries, deeply nested paths, or files whose declared sizes are misleading.
Set limits before extraction begins and enforce them while streaming:
maximum archive input: 50 MiB
maximum extracted bytes: 500 MiB
maximum entries: 10,000
maximum single file: 100 MiB
maximum path depth: application-defined
maximum path length: platform-aware limitTreat these values as examples, not universal defaults. Select limits from the feature’s expected workload and available capacity.
Do not rely only on metadata that claims an uncompressed size. Count bytes actually written and stop when the extraction budget is exhausted.
A bounded copy in Go can reserve one extra byte to detect an oversized entry:
limited := io.LimitReader(reader, maxFileBytes+1)
written, err := io.Copy(dst, limited)
if err != nil {
return fmt.Errorf("write extracted file: %w", err)
}
if written > maxFileBytes {
return fmt.Errorf("archive entry exceeds file limit")
}A total extraction counter should also cover all regular-file bytes across the archive.
Extract into a fresh isolated directory
Avoid extracting directly over an application’s live configuration, web root, plugin directory, or other sensitive tree. Use a newly created directory dedicated to the import operation.
A safer flow looks like this:
receive archive
|
v
create fresh job directory
|
v
inspect and extract with limits
|
v
validate resulting content
|
v
move or import approved dataThis structure separates archive parsing from the later operation that makes imported data active.
A fresh directory also reduces interaction with pre-existing symbolic links or files placed by another process. The directory should not be writable by unrelated principals.
Avoid partial imports after a rejected entry
Suppose an archive contains 200 valid files followed by one traversal entry. If the extractor rejects the final entry but leaves the first 200 files in a location consumed by the application, the failed import still changed system state.
Use a staging directory and treat extraction as a transaction at the application level:
create staging directory
extract all accepted entries
validate complete result
publish accepted result
remove staging directory on any errorCleanup should run for parsing errors, size-limit violations, rejected entry types, and failed post-extraction validation.
For workflows that publish a directory tree, an atomic rename on the same filesystem can be useful after all checks pass. Confirm the required semantics for the target operating system and deployment.
Keep filename handling separate from content validation
Safe path handling does not establish that extracted bytes are suitable for the feature. A .png entry can still contain data that is not an accepted image, and a JSON import can still contain invalid or dangerous application-level values.
Use two distinct gates:
filesystem gate:
path containment
entry type
permissions
byte and entry limits
content gate:
expected format
schema
semantic constraints
application authorizationThis separation makes reviews easier. Filesystem controls decide where and how objects may be created. Content controls decide whether those objects are valid inputs for the business operation.
Test hostile archive structures directly
Security tests should include archives that exercise boundary conditions, not only ordinary samples.
Useful cases include:
../outside.txt
a/../../outside.txt
/absolute/path.txt
many/levels/of/nesting/file.txt
a symbolic link followed by a child path
a hard link to an unexpected target
an unsupported device entry
a file larger than the per-file limit
many files exceeding the entry limit
files exceeding the total extracted-byte limitAlso test platform-specific path syntax if archives can be processed on more than one operating system. Path separators, drive letters, case behavior, and reserved names can differ.
The expected result for each hostile case should be explicit: extraction fails, no object appears outside staging, and failed staging data is removed.
Review the whole filesystem effect
A secure archive extractor is not merely a loop around a decompression library. It is a policy enforcement point for filesystem changes proposed by untrusted data.
A compact review checklist is:
- extraction starts in a fresh, controlled directory;
- absolute paths are rejected;
- normalized paths must remain beneath the extraction root;
- only required entry types are accepted;
- link behavior is rejected or handled with a dedicated safe design;
- permissions and ownership come from local policy;
- entry count, individual size, and total output are bounded;
- actual streamed bytes are counted;
- partial extraction is discarded after any failure;
- extracted content passes format and application checks before activation.
Archive formats are useful because they can describe complex directory trees in one file. That same capability means extraction deserves the same care as any other operation that turns untrusted input into filesystem state.