sorge-it / safe-unzip
Extracts standard ZIP files with Info-ZIP's unzip and zipinfo, behind the interface of PHP's ZipArchive: an allow list of what a ZIP file may contain, exceptions instead of false, checks before and after extraction, no ext-zip. Built for coding agents.
Requires
- php: ^8.4
Requires (Dev)
- ext-zip: *
- laravel/pint: ^1.29
- pestphp/pest: ^4.7|^5.0
- pestphp/pest-plugin-type-coverage: ^4.0|^5.0
- phpstan/phpstan: ^2.2
- rector/rector: ^2.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-31 16:52:05 UTC
README
\ZipArchive drops the executable bit and turns 202 KB into 200 MB. SafeUnzip extracts with
Info-ZIP's unzip and zipinfo, behind the interface of PHP's ZipArchive. An allow list defines
what a ZIP file may contain. Anything else throws an exception before a single byte is written.
Made for code that AI coding agents write and continuous deployment ships, where no human looks at
the archive.
use SorgeIt\SafeUnzip\ZipArchive; // instead of \ZipArchive, for these calls $zip = new ZipArchive; $zip->open('/path/to/archive.zip', ZipArchive::RDONLY | ZipArchive::CHECKCONS); $zip->extractTo('/path/to/target'); $zip->close();
SafeUnzip does one thing: it extracts a whole archive, safely. It is built for archives that a
program downloads, such as a GitHub zipball for Composer or chrome-headless-shell from Chrome for
Testing. It implements four methods of ZipArchive: open(), extractTo(), close() and
count(). No other method exists, so PHPStan reports any call to one and PHP throws an Error.
It is not a drop-in replacement for \ZipArchive; see what breaks.
What it refuses by design. The allow list is strict, so test it against your archives first:
- Entry names with characters outside
A-Z a-z 0-9 . _ - /: no spaces, no umlauts, no backslashes. An entry nameddocs/My Report.pdfis refused. - Symbolic links, hard links and devices. This includes a GitHub zipball of a repository that contains a symbolic link.
- Encryption, compression methods other than store and deflate, self-extracting archives, and entry comments.
Each refusal throws a RefusedException that names the rule. The full list is under
Not supported.
The two scripts differ in a single use line. \ZipArchive extracts a program without its
executable bit, and writes 200 MB from a 202 KB archive whose 20 entries share the same data.
SafeUnzip keeps the bit and refuses the second archive in open(). The demo lives in
demo/ and runs in Docker on Debian 13, with PHP 8.4 and the system's libzip.
Installation
composer require sorge-it/safe-unzip
Requirements
- PHP 8.4 or later, with
proc_open(). Noext-zip, no other package. - Info-ZIP UnZip 6.00 and ZipInfo 3.00: the
unzipandzipinfoprograms from the system'sunzippackage (apt install unzip,apk add unzip; macOS ships them). The firstopen()on each object checks the first line ofunzip -vandzipinfoand throws aToolExceptionfor any other release, such as BusyBox'sunzip. - Linux or macOS. Windows is not supported.
Why this exists
I don't trust \ZipArchive with a ZIP file I didn't create myself. ZIP is a complex format. Each
entry has a local and a central header, an archive has two end records, and a size can appear in
three places. Wherever two fields describe the same thing, two programs can read two different
things. And every program that reads these fields has had security bugs of its own. As of
2026-10-29:
- PHP's
\ZipArchive(ext-zip): a directory traversal inextractTo()(CVE-2014-9767, fixed in PHP 5.6.13 on 2015-09-03, and CVE-2021-21706 on Windows, fixed in PHP 8.0.11 on 2021-09-23), integer overflows (CVE-2016-3078, PHP 7.0.6 on 2016-04-28) and a use-after-free (CVE-2016-5773, PHP 7.0.8 on 2016-06-23). Each fix shipped with a new PHP release, and nowhere else. - libzip, the library behind ext-zip and a system package since PHP 7.4: heap and integer overflows, a double free and a use-after-free when reading the central directory (CVE-2012-1162, CVE-2015-2331, CVE-2017-12858, CVE-2019-17582). After the ZipDiff paper, libzip 1.11 (2024-09-19) changed how it selects the central directory. Debian 12 still ships libzip 1.7.3 from 2020-07-15, without that change.
- Info-ZIP UnZip 6.0 had its last release in 2009. Distributions patch it:
Debian lists 23 fixed CVEs,
including the overlap zip bomb
CVE-2019-13232. That CVE has been
public since 2019-07-04. More than seven years later, macOS's
unzipstill has no fix: it writes the demo's bomb, all 200 MB of it. - macOS fixed three ZIP archives that bypass Gatekeeper between 2026-05-11 and 2026-10-26 (CVE-2026-28914, CVE-2026-84610 and CVE-2026-112672; Apple's notes 127115 and 149522).
This matters even more with AI coding agents. An agent writes the code that downloads a release, a
browser for tests or a data set, and continuous deployment ships it minutes later. Often no human
ever looks at the archive. So the code has to refuse a bad archive on its own, and say why: an
agent skips past a false, but it acts on an exception that names the entry and the rule. A fix
also has to reach production as fast as the code does. Renovate or Dependabot merges a
composer update in the same pipeline; a new PHP release or system package usually needs a new
base image first.
So SafeUnzip is a safety net, the way Safe PHP is one
for PHP's functions, which throw an exception there instead of returning false. It lives in
userland, as PHP code that Composer installs: small, quick to build, and with no new engine.
- An allow list. The "standard ZIP" profile defines what a ZIP
file may be. SafeUnzip refuses everything else, including any archive whose redundant fields
disagree, before a byte is written. So
unzipwrites exactly whatzipinfolisted and PHP checked. - Unix modes.
\ZipArchive::extractTo()writes each file with the mode the umask gives it, so a binary loses its executable bit. That is why Composer tries theunzipbinary first, and warns when it is missing: "any UNIX permissions (e.g. executable) defined in the archives will be lost". - A limit.
\ZipArchivehas no limit on entries or bytes. A small archive can fill the disk, and entries that share their data multiply it. - Exceptions. One class per cause, each with a message that names the entry. No
false, no error codes. - A new rule ships with
composer update. When a new attack on the ZIP structure becomes known, such as a new zip bomb, a new profile rule refuses it in your next deploy. A fix in ext-zip waits for a PHP release, a fix in libzip for a system package. A rule cannot fix a memory bug in Info-ZIP itself: Info-ZIP reads the archive before any rule runs, so that fix comes with the system'sunzippackage (see Limits of the model). - System updates still count. SafeUnzip replaces none of them. It runs the system's
unzipandzipinfo, so an Info-ZIP fix applies here as soon as it is installed. PHP and libzip updates apply to the rest of your code as before.
I built this so that a download extracts to exactly what I checked, even when an agent wrote the code and nobody looks at the archive. I hope it does the same for you. — Stefan Sorge
Comparison
\ZipArchive (ext-zip) |
unzip in a shell |
SafeUnzip | |
|---|---|---|---|
| Needs | ext-zip |
Info-ZIP | Info-ZIP, PHP 8.4 |
| Unix modes | lost | kept | kept |
| The demo's overlap bomb, default flags | 200 MB written | macOS: 200 MB written, exit 1; Debian 13: stops with exit 12 after the first 10 MiB | refused in open() (S3), nothing written |
| Limit on entries and bytes | none | none | Limits, 10,000 entries and 2 GiB by default |
| What it accepts | whatever libzip reads | whatever Info-ZIP reads | the "standard ZIP" allow list |
| The target before the checks pass | written | written | untouched: a check pass into a pipe, then a staging directory |
| Errors | false or an error code |
an exit code and text | one exception class per cause, with a Reason |
With CHECKCONS, libzip refuses the demo's overlap bomb too, as well as an archive whose local and
central headers name different compression methods. It does not keep Unix modes, enforce a limit,
or check names against an allow list.
Parity with php-src
Every archive in php-src ext/zip/tests
is a fixture of the PhpSrc suite. Where SafeUnzip extracts an archive, its test compares the
names and contents with those that \ZipArchive::extractTo() extracts. For each other archive,
SafeUnzip writes nothing. open() refuses it with the Reason of the rule it breaks, or
extractTo() throws an ExtractionMismatchException for data that does not match its CRC-32.
The php-src fixtures are copies from commit eefa52a5 (2026-10-26).
tests/Fixtures/php-src.lock lists the git blob of every file in
ext/zip/tests at that commit: the .phpt files, the archives and the helper files. A test in
the suite checks that each fixture matches its blob and has its own test.
The php-src parity workflow compares the lock with that commit
and with php-src master, every Monday, on each push to main and on each pull request. A red
badge means that the lock does not match php-src, mostly because php-src changed ext/zip/tests.
It does not mean that a test of this package fails. The maintainer then updates the lock, the
fixtures and their tests. GitHub disables the workflow after 60 days
without activity in the repository, until someone enables it again.
The rest of this file is reference: The interface · Exceptions · The profile · Refusals · Not supported · How it extracts · Reads by PHP · Scope · What a safe ZIP is · Measurements · Tests
The interface
| Part | SafeUnzip |
|---|---|
new ZipArchive(Limits $limits = new Limits, array $environment = [], int $timeout = 900) |
the limits, environment variables for the tools (such as PATH), and the timeout in seconds for each tool run |
open($filename, $flags) |
lists and checks the archive; nothing is written. RDONLY and CHECKCONS are allowed; the checks always run |
extractTo($pathto) |
extracts the whole archive. As in the extension, a file with the same name in the target is replaced silently, and everything else in the target stays |
extractTo($pathto, $files) |
throws UnsupportedException before any tool starts |
close() |
closes the archive |
count(), count($zip) |
the number of entries, like $numFiles; without an open archive, ValueError, as in the extension |
RDONLY, CHECKCONS |
the extension's values; no other constants |
$numFiles, $filename, $comment |
readable; writing to them from outside throws an Error |
The four methods take the extension's parameters. No other method, property or constant of
\ZipArchive exists.
Limits has two fields: entries (10,000) and bytes (2 GiB, the total size of all entries). A
negative value throws ValueError.
What breaks when you switch from \ZipArchive, within these calls:
- The class is not a subtype of
\ZipArchive: a parameter typed\ZipArchivewon't accept it. open(),extractTo()andclose()returntrueor throw;open()is declaredbool, notbool|int.- Errors throw an exception from this package;
open()never returns an error code. - The archive path must not contain
*,?,[,],\or a line break:unziptreats its archive argument as a pattern, andzipinfoprints a line break as two lines.open()throwsUnsupportedExceptionfor such a path.
Exceptions
Every exception in this package extends SorgeIt\SafeUnzip\Exception\UnzipException, a
RuntimeException. At the time of the call, the class names the cause: the archive, the caller,
the tools, or the host. A RefusedException with Reason::Target names the target. A fixed
exception or a change during the run can throw a different class (see Scope).
| Exception | Cause | When |
|---|---|---|
RefusedException |
the archive (question 1); the target for L2 (question 2) | the archive or the target is outside the profile; $reason is the Reason of the rule |
InvalidArchiveException |
the archive | the file is not a ZIP archive, zipinfo prints something the grammar does not know, or zipinfo or unzip exits with a code for a data error in the archive that no rule covers |
ExtractionMismatchException |
the archive | unzip wrote or returned something other than what the listing says |
UnsupportedException |
the caller, or outside the package's goals | a flag for open() that writes, a list of entries for extractTo(), an archive path that unzip would read as a pattern, or a target with a scheme |
ToolException |
the host, or a tool of another release (question 3) | a tool is missing, is another release or cannot start; it hit the timeout, exited with a code that names no archive error, was killed by a signal, or printed more than its limit outside the listing; or stream_select() failed |
FilesystemException |
the host | the archive is missing, not a file, unreadable, cannot be resolved to an absolute path, or changed since it was hashed; a file system operation failed for a reason on the host |
ErrorException, not from this package |
the host | a PHP warning in a step after open() resolved the archive and extractTo() the target, such as a path below the target that open_basedir hides, when a handler turns warnings into exceptions, as Laravel does |
Unless something changed during the run or Info-ZIP itself was modified, InvalidArchiveException,
ExtractionMismatchException and RefusedException with any $reason other than
Reason::Target mean: do not accept this archive. Two of them mean less: L1 against free memory
means "not with this much memory"; N9 means "not into this target". If a step in extractTo()
throws and another process then makes removing the staging directory fail, the
FilesystemException from the removal is thrown, with the first exception as getPrevious().
An argument with an invalid value, as listed below, or a call without an open archive throws
ValueError, not an exception from this package. As in the extension:
open()with an empty path, or a path that contains a NUL byte.extractTo()with an empty path, or a path that contains a NUL byte.extractTo(),close()andcount()without an open archive.
Beyond the extension, which has no such arguments:
new ZipArchive()with a negative$timeout, or with a name in$environmentthat is empty, a number, or contains=or a NUL byte.new Limits()with a negative value.
The profile "standard ZIP"
| Feature | Allowed |
|---|---|
| Compression methods | store (0), deflate (8) |
| Entry types | file, directory |
| Hosts | Unix (3), FAT (0), NTFS (11, as Info-ZIP counts it) |
| Names | ASCII A-Z a-z 0-9 . _ - /, each part up to 255 bytes, the path in the staging directory below PATH_MAX |
| Version needed to extract | up to 4.5 (ZIP64) |
| General purpose flags | bits 1 and 2 (deflate options), 3 (data descriptor, with deflate only), 11 (UTF-8) |
| Extra fields | ZIP64 (0x0001), NTFS times (0x000a), extended timestamp (0x5455), Unix IDs (0x5855, 0x7855, 0x7875) |
| Size | ZIP64 allowed |
| Archive comment | a single line of printable ASCII, such as the commit hash in a GitHub zipball |
| Archive | a single file, starting at offset 0 and ending with its end record, no password |
Everything outside this table is refused.
Refusals
When an archive breaks several rules, the first rule in Profile order throws, not the one with the
lowest number: S11 and S15 while the listing is read, then S8, S10, S4, S5, S2, S7, S3, S6, N1, N3,
N8, S1, S12, S16, S14, S13, N5, S9, N6, N7, L1. So an LZMA entry throws S12, and ..\x.txt throws
N3. An entry count above Limits throws L1 before the listing; in extractTo(), L2 comes before
N9. Each refusal has a case in SorgeIt\SafeUnzip\Profile\Reason whose value is the number shown
here. The ZipDiff types are those of the paper under What a safe ZIP is.
Structure
| # | Refused | ZipDiff | Detected by |
|---|---|---|---|
| S1 | the central header, the local header and the data descriptor disagree on name, version needed, flags, method, time, date, CRC-32 or sizes; a stored entry with two different sizes | A1, A2, A5 | PHP |
| S2 | the end record counts a different number of entries than the central directory holds | C3 | zipinfo |
| S3 | entries overlap, or there are bytes between entries, the central directory and the end record | C1 | PHP |
| S4 | bytes before the archive (self-extracting) or after the end record | C2, C4 | zipinfo, PHP |
| S5 | no end record where zipinfo found it; a comment length beyond the end of the file |
C2 | zipinfo, PHP |
| S6 | a Unicode path extra field (0x7075) or Unicode comment extra field (0x6375), in either header | A3 | zipinfo, PHP |
| S7 | flags outside the profile (encryption, patch data, a masked header); a stored entry with a data descriptor; a digital signature record | A5, C1, ISO/IEC 21320-1 | zipinfo (bit 0), PHP |
| S8 | a disk number other than 0 in an end record, a ZIP64 record or a header | ISO/IEC 21320-1 | zipinfo, PHP |
| S9 | a trailing / that disagrees with the entry type; a directory with content, a CRC-32 or more than 2 compressed bytes |
A4 | zipinfo |
| S10 | the ZIP64 end record and the end record disagree; a ZIP64 record with extensible data; a field at its maximum without a ZIP64 value | C5 | PHP |
| S11 | an entry comment | — | zipinfo |
| S12 | a compression method other than store and deflate | — | zipinfo |
| S13 | an extra field outside the profile, the same one twice in a header, or one whose lengths don't add up | — | zipinfo, PHP |
| S14 | a host other than Unix, FAT and NTFS; Unix permission bits on a FAT or NTFS entry | — | zipinfo |
| S15 | an archive comment that is not a single line of printable ASCII | — | zipinfo |
| S16 | an entry that needs a version above 4.5 to extract, which unzip skips with exit code 81 |
— | PHP |
Names and types
| # | Refused | Why |
|---|---|---|
| N1 | an absolute name, an empty part, a . or .. part |
path traversal; ZipDiff B3; Go ErrInsecurePath |
| N3 | any character outside A-Z a-z 0-9 . _ - /, including a backslash, which Info-ZIP reads as / for host FAT |
ZipDiff B2; Go ErrInsecurePath |
| N5 | a link, a device, a FIFO, a socket, a volume label | it writes outside the target, or has no content |
| N6 | the same name twice, even if only the case differs; a directory spelled two ways | ZipDiff B1, B4 |
| N7 | a file and a directory with the same name, such as a and a/b |
ZipDiff B1 |
| N8 | a part longer than 255 bytes | the name comes from the archive; otherwise unzip fails in the staging directory |
| N9 | a name whose path in the staging directory reaches PATH_MAX (1024 bytes on macOS, 4096 on Linux) |
as N8; extractTo() checks it because it needs the target |
N1, N3 and N8 check the names in both the central and the local header, as PHP reads them. N6 and N7 compare the names in the listing: S1 runs before them and refuses an entry whose local name differs from its central one.
Limits and target
| # | Refused |
|---|---|
| L1 | more entries or bytes than Limits allows; a zipinfo listing larger than the entry count and memory_limit allow |
| L2 | a link in the target, a file where a directory goes, a directory where a file goes, a mount point; a target, or a directory in it, that cannot be written to or moved into; a target path too long for the staging directory |
The mount point check has no test: it would need a second file system below the target.
Not supported
Each item is refused on purpose and has a fixture in the Profile suite. Support for any of them
is a feature request.
- Self-extracting archives and bytes after the archive (S4)
- Unicode path and comment extra fields (S6)
- Encryption, patch data, masked headers, signed archives, and stored entries with a data descriptor (S7)
- Split and spanned archives (S8)
- Entry comments (S11)
- Compression methods other than store and deflate (S12)
- Extra fields other than ZIP64, NTFS times, extended timestamp and Unix IDs (S13)
- Host systems other than Unix, FAT and NTFS (S14)
- Archive comments with a line break or a byte outside printable ASCII (S15)
- Entries that need a version above 4.5 to extract, such as LZMA or strong encryption (S16)
- Names outside A-Z a-z 0-9 . _ - / (N3)
- Symbolic links, hard links, devices, FIFOs and sockets (N5)
A zipball of a repository that contains a symbolic link is refused (N5), because git archive
writes the link as an entry. Stored entries with a data descriptor come from writers that stream
without compression (measured 2026-10-27): Python's zipfile on a stream with ZIP_STORED, Go's
archive/zip with Store, bsdtar with compression=store, zip -0 into a pipe, and
git archive -0 for a file above core.bigFileThreshold. With deflate, each of these produces an
archive that passes. Out of scope altogether: writing archives, reading single entries, and Windows.
How it extracts
- Listing.
zipinfo -hchecks the entry count againstLimits, thenzipinfo -vlists the archive. The output is parsed once, against a closed grammar, into value objects. - Header reads. PHP reads fixed header fields at the offsets from that listing, for what Info-ZIP does not report (see Reads by PHP).
- Profile. Each rule is one class; the first rule that breaks throws
RefusedException. - Check pass.
unzip -pdecompresses every file into a pipe. PHP splits the stream at the listed sizes and checks each CRC-32; the first byte beyond the total stopsunzip. Nothing has been written yet, so a zip bomb never reaches the disk. - Staging.
unzip -qwrites into a new directory inside the target, on the same file system. The archive's SHA-256 must then still match the one fromopen(). - Tree comparison. The staged tree must hold exactly the listing, each file with its size, and no links. Each node gets its mode.
- Placement. Every path in the target is checked for links first; then each entry moves into
place with
rename(). No entry reaches the target before step 6 passes. A target directory that the call created stays after an error, empty: the package removes nothing but its own staging directory.
The unzip binary comes first and is the source of truth: it lists, decompresses and writes. PHP
never decompresses an entry and never writes an extracted file. Two tests enforce this: one finds no
call in the sources to a function or class that decompresses or writes, and one extracts a deflate
entry in a PHP process where the zlib and bzip2 functions, the stream filters and the
compress.zlib, compress.bzip2, zip, phar and php stream wrappers are all disabled.
Limits of the model
- Info-ZIP reads the archive first.
zipinfoandunzipparse the untrusted bytes before any rule of this package runs, so a memory error in them happens before a refusal can. The release check sees UnZip 6.00, not the patches a system applied to it. Keep your system'sunzippackage up to date: of the systems measured, Debian and Alpine carry the patch for CVE-2019-13232, macOS does not (see What a safe ZIP is). - The target is the caller's responsibility. The checks in step 7 and each
rename()work by path. A process that can write into the target, or into one of its parents, can plant a link between the check and the move, and an entry then goes through it. Such a process can also put a link where the staging directory is: removing it then follows the link and deletes whatever is behind it. Extract into a directory that only your process can write to. The same applies to the archive: see Reads by PHP. - The move is not atomic. Each new top-level directory moves in a single
rename(); each file that goes into an existing directory moves on its own. When arename()fails, for example on an immutable file, the entries already moved stay in the target, and the rest stays in the staging directory, which is removed. Extract into a new, empty directory when a partial tree must not happen. - The PHP process is the host's responsibility. The package does not check
disable_functions,open_basedirorFD_SETSIZEin advance. PHP throwsErrorfor a function listed indisable_functions. A path hidden byopen_basedirthrowsFilesystemExceptionwith PHP's warning for the archive and the target; a later step yields PHP's warning itself.PATH_MAXcounts only against an archive name (N9) and the target (L2). - Changes during the run. Another process that swaps the archive, plants a link in the target,
or writes into the staging directory can make a check fail with any class: a changed archive can
fail the CRC-32 of the check pass with
ExtractionMismatchExceptionbefore the SHA-256 is compared. Keep both the archive and the target in directories that only your process can write to. - Free memory. L1 compares the listing with the memory free under
memory_limitwhenopen()runs, so the same archive can pass in a fresh process and be refused in one that already holds a lot. - A modified Info-ZIP. The release check reads the first line of
unzip -vandzipinfo, not anything a patch changes beyond it (see Scope). - Memory. The output of
zipinfo -vmay be up to 65,536 + 65,535 bytes plus 16,384 bytes per entry, and at most a sixth of the memory free undermemory_limit; beyond that,open()throwsRefusedException(L1). Parsing takes up to 4.6 times the output (measured with 5,000 entries on macOS, about 1.3 KB of output per entry). Short lines cost more: before parsing, 6 times the output plus 32 bytes per line must fit, otherwiseopen()throwsRefusedException(L1). Without this check, a 9 MB archive with entry comments made of line breaks kills PHP with a fatal error under amemory_limitof 128 MB. - Cost. For an archive with entries, PHP reads the whole file twice to hash it: once in
open()and once inextractTo(); for an empty archive, once.unzip -pandunzip -qeach decompress every entry. PHP computes the CRC-32 of each file once, from theunzip -pstream.\ZipArchive::extractTo()decompresses each entry once. The twozipinforuns read the end record and the central directory, not the entry data. - Time and disk.
Limitscounts the extracted bytes, not the compressed ones, so a large file takes its time, up to the timeout. A full disk stopsunzipin the staging directory withToolException, and no entry reaches the target. - The end of a deflate stream (ZipDiff C1). A deflate stream can end before its entry's
compressed size, with other bytes in the gap, such as a local header.
unzipreads the stream to its end and skips the rest, and so does this package; neitherunzip -tnorzipinforeports it (measured 2026-10-27 on macOS). For an entry with a data descriptor, a streaming parser such as Java'sZipInputStreamlooks for the descriptor right after the stream and reads those bytes. PHP would have to decompress to find the end, so this package does not check it. Read each archive with one parser only: this one.
Modes
An entry from a Unix host keeps its permission bits, minus write access for group and others; the set-user-id, set-group-id and sticky bits are dropped. An entry without Unix bits, such as one from a FAT host, gets 0644, or 0755 for a directory. A directory is always accessible to its owner. The umask applies.
Reads by PHP
PHP reads bytes of the archive in a single class, Archive\Headers, and only fixed fields at
offsets that zipinfo -v reports. It never searches for a signature. So PHP has no parser of its
own, and zipinfo and PHP cannot see two different archives in one file. Where parsers could read
one file in two ways, a rule refuses it: S1, S3, S4, S5 and S10, each with a fixture. Each read
covers something Info-ZIP does not, as measured on 2026-10-27:
| Read | Fields | Why zipinfo is not enough |
|---|---|---|
| each central header, from the central directory offset | version made by, version needed, flags, method, CRC-32, sizes, disk, external attributes, offset, name, extra field | zipinfo -v shows only flag bit 0 (S7); the local header is compared against it (S1), and its disk against the listing (S8) |
| the 4 bytes after the last central header | signature | a digital signature record (S7) |
| each local header at its offset | version needed, flags, method, time, date, CRC-32, sizes, name, extra field | zipinfo shows only the central header, while unzip uses the local one (S1, A1, A5) |
| the data descriptor after the data, where flag bit 3 is set | CRC-32, sizes | a streaming reader uses them (S1, C1) |
the end records at the offset zipinfo reports, the ZIP64 locator before the end record, and the ZIP64 end record at the locator's offset |
counts, sizes, offsets, disks, comment | zipinfo silently picks one of two disagreeing records (S10) |
the whole file, in open() before the listing, and in extractTo() after unzip -q |
SHA-256 | a safety net: a file rewritten in between, for example by mistake, is refused |
Walking the local headers also yields the exact layout (S3). zipinfo -v does report bytes before
an entry, but it estimates the length of the local header from the central extra field: with an
extended timestamp holding two times in the central header, it reports a 4-byte overlap that isn't
there.
The SHA-256 is a safety net, not a defence against an attacker. It refuses a file that was rewritten by mistake between the reads, such as a download that writes the same name twice. Each tool opens the archive by path, so a process that swaps the file on purpose can swap it back before a comparison. Like the target, the archive is the caller's responsibility: keep it in a directory that only your process can write to.
Scope
This section is for contributors: it decides where a check belongs. The four fixed exceptions apply first, then three questions in order. The first "yes" decides; a cause with no "yes" belongs to the host.
Three terms:
- Cause: what produces the error, not the step that notices it. An exit code, a failed file system operation or a failed comparison can have its cause in the archive.
- At the call: the archive as it is when
open()starts, and the target as it is whenextractTo()starts, not counting the package's staging directory. - During the run: any change by another process after that, until
extractTo()returns. It belongs to the host and can throw any class.
| Question | Answer | Duty |
|---|---|---|
1. Archive. Is the cause in the bytes of the archive, alone or against a limit? A limit is Limits, memory_limit, 255 bytes per part, PATH_MAX or the length of the target path; the timeout is not. |
in; out where PHP cannot see the cause without decompressing | In: a rule with a fixture; RefusedException where a rule names the cause, otherwise InvalidArchiveException or ExtractionMismatchException. Out: listed under Limits of the model. |
| 2. Caller. Is the cause an argument's value, or the target? | in | A value: checked at the start of the call, before any tool runs, with ValueError or UnsupportedException. The target: checked before an entry moves into it, with RefusedException and Reason::Target (L2). |
| 3. Tools. Is the cause which binary runs, or a variable that Info-ZIP reads, no matter who sets it? | in | The package pins the binary and each such variable, as listed under In scope. Another release throws ToolException. |
| Otherwise: host. | out | No check in advance. A failed step throws ToolException, FilesystemException, or PHP's own Error or warning. Outside the fixed exceptions, a host cause at the call never throws an archive exception. |
How to apply them:
- A path argument is a value with its links resolved, as the tools receive it. A path that cannot
be resolved belongs to the host, after the scheme check and before the pattern check:
FilesystemException. - What a path points to in the file system belongs to the host; only the state of the target belongs to the caller.
- A question-3 variable in
$environmentis not an argument error. A name that is empty, a number, or contains=or a NUL byte is. - Where several rules break, the first in
Profileorder throws (see Refusals).
The four fixed exceptions:
- An Info-ZIP modified by the host, through a patch or a preloaded library: a tool of the
right release that prints something the grammar does not know. The output looks like an unknown
archive:
InvalidArchiveException. - A safety net: if no check failed earlier,
extractTo()compares the SHA-256 fromopen()afterunzip -q. It refuses a file rewritten during the run withFilesystemException. It does not protect against a deliberate swap. - The extension's behaviour:
clonethrowsError,serialize()andunserialize()throwException, and a call without an open archive throwsValueError. - Outside the package's goals: an
open()flag other thanRDONLYandCHECKCONS, and a list forextractTo():UnsupportedExceptionat the start of the call. Windows as the host system: no check, no duty; an archive written on Windows is within the profile.
A change that adds or removes a check names its question or exception. A case that none of them answers changes this section first.
In scope
- Question 1:
- S1 to S16; N1 and N3, which covers backslashes; N5 to N7.
- N8: a part above 255 bytes. N9: a name whose path in the staging directory reaches the
system's
PATH_MAX. - L1: the entries, the bytes, and the listing against the memory free under
memory_limit. - Exit codes for a data error: 2, 3, 11 and 51 from
zipinfo; 2, 3 and 51 fromunzip -p. Exit 11 means an end record on another disk: S8. Exit 51 means a name beyond the end of the file, before any rule can read it:InvalidArchiveException(measured 2026-10-29). - The sizes and CRC-32 values of the check pass; the tree comparison.
- Question 2, a value:
ValueError: an empty path or one with a NUL byte, an empty target, a negative timeout or limit, a name in$environmentas described above.UnsupportedException: an archive path that contains a pattern once resolved; a target with a scheme, such asfile://,phar://ordata:.
- Question 2, the target (L2): a link, file or directory in the way of an entry; a mount point;
a target, or a directory in it, that cannot be written to or moved into; a target whose path,
with only the package's own names added, reaches
PATH_MAX. With a name from the archive, N9 applies. - Question 3:
- The binary: its release is checked on the first
open()of each object, and that same binary runs every time. - Set:
LC_ALL=Cfor both tools, which overridesLANGandLC_*;TZ=UTCforzipinfo. - Kept: the process's
TZforunzip, for the DOS times. - Removed:
UNZIP,UNZIPOPT,ZIPINFO,ZIPINFOOPT, and every variable whose namegetenv()returns as a number.
- The binary: its release is checked on the first
Out of scope
- Question 1, not visible: the part of ZipDiff C1 that no rule covers, a deflate stream that
ends before its entry's compressed size; an Info-ZIP crash on crafted bytes (
ToolException). - Host, at the call:
disable_functions,open_basedir,FD_SETSIZE, the working directory, every variable that question 3 does not name, such asLD_PRELOAD; free disk space; system memory, and so a tool killed by a signal; the timeout, even for a large valid archive; an archive path that is missing or not a file. - Host, during the run: a swapped archive (TOCTOU); an archive deleted after its path was
resolved; a link in the target after
extractTo()started; a link in place of the staging directory; another process writing into the staging directory.
What a safe ZIP is
No standard called "safe ZIP" turned up (research on 2026-10-27, web pages and the paper only). Three sources together draw the line for this package:
-
ISO/IEC 21320-1:2015, Document Container File — Core: a subset of ZIP (APPNOTE 6.3.3). Store or deflate only; no encryption, no digital signatures, no patch data; a single volume, not segmented; names should be UTF-8. Source: Library of Congress, fdd000361, read 2026-10-27.
-
Go's
archive/zip,ErrInsecurePath(Go 1.20, issue 55356, read 2026-10-27): a name is insecure if it is absolute, contains a..part or a backslash. -
"My ZIP isn't your ZIP: Identifying and Exploiting Semantic Gaps Between ZIP Parsers" by Yufan You, Jianjun Chen, Qi Wang and Haixin Duan, USENIX Security 2025, read 2026-10-27. Their differential fuzzer ZipDiff found ambiguities in 1,221 of 1,225 pairs among 50 parsers. It names 14 types:
- A, redundant metadata: A1 compression method, A2 file size, A3 file name, A4 fake directory, A5 fake encryption;
- B, path processing: B1 duplicate files, B2 invalid characters, B3 path canonicalization, B4 case;
- C, structure: C1 streaming parsing, C2 end record selection, C3 central directory count, C4 offsets, C5 ZIP64 end record.
The paper names seven mitigations: use the same parser; on-access scanning; normalize the ZIP; identify ambiguous patterns; combine several parsers; fix unique parser behaviour; a better format. This package takes "identify ambiguous patterns": a rule refuses each type except C1 with deflate (see Limits of the model). Each type has a fixture in the
Profilesuite. The paper's Table 4, read from the image, counts 6 types on which Info-ZIP 6.0 and libzip 1.10.1 disagree; a caller who checks withZipArchiveand extracts with this package meets exactly those. Results of the paper: CVE-2024-24789 (Go), CVE-2024-7788 (LibreOffice), CVE-2024-38807 (Spring Boot), and stricter checks in libzip.
Further sources:
- SEI CERT IDS04-J, "Safely extract files from ZipInputStream": check each name against the target, and do not decompress beyond a size limit (N1, L1).
- CVE-2019-13232, David Fifield, "A better zip bomb" (USENIX WOOT 2019): UnZip 6.0 mishandles
overlapping entries. Debian (bullseye 6.0-26+deb11u1, bookworm 6.0-28, trixie 6.0-29) and Alpine
(6.0-r16) carry a patch; macOS's
unzipdoes not, as measured on 2026-10-27. So S3 does not rely on the system. - libzip's
CHECKCONS(lib/zip_open.c, read 2026-10-27): it compares the local and central headers on version, method, time, date, name, CRC-32 and sizes, but not on flags (that comparison is behind#if 0), and it accepts overlapping entries and a self-extracting prefix. This package always checks whatCHECKCONSchecks, and adds flags, overlaps and prefixes. bsdtar(libarchive 3.7.4 on macOS, measured 2026-10-27), rejected as the engine: it strips a leading/and writes the file anyway, leaves partial output behind after an error, and reads a tar file whose first name starts withPK\x03\x04as tar.- Not read: Gynvael Coldwind's page lists no pitfalls, only slides and a video; David Fifield's page on Mozilla bug 1534483 refused the request with HTTP 403 (both tried on 2026-10-27).
Measurements
On macOS, UnZip 6.00 of 20 April 2009, by Info-ZIP, with modifications by Apple Inc., 2026-10-27:
git archive --format=zipand a GitHub zipball write host FAT (-rw---- 0.0 fat), the extended timestamp 0x5455, no data descriptor, and the commit as the archive comment; the top directory is named after the tag object. A symbolic link becomes an entry with host Unix.zipinfo -vshows only the central header: a method or flag that differs in the local header passesunzip -twithout a message (S1). An entry inside the data of another passesunzip -twith "No errors detected" (S3).- Info-ZIP counts NTFS as host 11, and calls host 10 (NTFS according to APPNOTE) TOPS-20.
- For host FAT,
zipinfo -vshows the upper 24 bits of the external attributes as "non-MSDOS external file attributes". zipinfoprints warnings and errors to stderr; after an error it stops without its final blank line.- In the fifth field of
zipinfo,xmeans an extra field and no data descriptor,la data descriptor and no extra field,Xboth (man page). So thetxfromgit archivemeans no data descriptor. zipinfoandunzip -texit with 3 for an end record that counts more entries than exist (S2), with 1 for bytes before the archive when the offsets were not adjusted (S4), and with 0 for a local header that differs only in method or flags (S1). For two disagreeing end records they exit with 2 or 3 (S10).- With a ZIP64 end record,
zipinfo -v's "Actual end-cent-dir record offset" is the offset of the ZIP64 record. So the archive ends at that offset + 56 + 20 + 22 + the comment length (S4).
On Debian (PHP 8.4, UnZip 6.00 … by Debian) and Alpine (PHP 8.5, the unzip package), in Docker,
on 2026-10-27, the whole suite passes. One difference from macOS: their zipinfo lists the name
from a Unicode path field (0x7075) instead of the name field, so S6 runs before every rule on names.
Tests
composer test # pint, rector, PHPStan level max, type coverage 100 %, every suite composer test:coverage # line coverage 100 %, with pcov; a CI job
Line coverage skips one range, marked @codeCoverageIgnoreStart with its reason on the lines
before it: the exception in Filesystem::done(), for a file system operation that only another
process or the file system itself can make fail, such as a rename() after its checks passed. A
test in Extraction exercises the code in that range, and phpunit.xml excludes no file.
The CI runs the suite on Ubuntu and macOS, and in Docker on Debian (PHP 8.4, not as root) and Alpine (PHP 8.5, x64 and arm64). Every action is pinned to a commit and every image to a digest.
| Suite | Checks |
|---|---|
Interface |
the four methods with \ZipArchive's parameters and a compatible return type, the two flags, the properties, and the extension's errors |
PhpSrc |
the php-src and libzip fixtures, a 7-Zip archive, and archives written by ext-zip: extracted as by the extension, or refused with their Reason; the php-src fixtures, compared with php-src.lock |
Profile |
each rule: archives that break it, refused with their Reason and message, and archives within the profile; the zipinfo -v grammar |
Extraction |
attacks on the target, links, staging, the target left as before a failed check, a zip bomb, and the sources |
Tools |
missing tools, timeouts, output limits, the environment |
Acceptance |
a GitHub zipball and a ZIP with the structure of Chrome for Testing, extracted and compared |
The archives for Profile are built byte by byte at test time by tests/Support/ZipFixture.php;
none of them is in the repository.
Development
Problems come as a pull request that fixes them, not as an issue. How to run the package's checks: CONTRIBUTING.md.
Security
Report vulnerabilities privately through GitHub. See SECURITY.md.
License
MIT, see LICENSE. The fixtures keep the licenses of their origin, see tests/Fixtures/README.md.
About the author
Stefan Sorge · PHP expert, builder, agentic engineer. 25 years of PHP and tech for 40+ startups. I build tools that humans and AI agents read and act on the same way.
Also by me: phpunit-pest-html-assertions, HTML assertions for PHPUnit and Pest that keep AI coding agents from checking markup as a string.
