Skip to content

feat(kmz): add archive limits and use temp_directory - #24

Merged
danielebarbaro merged 1 commit into
mainfrom
feat/kmz-limits-and-temp-directory
Sep 8, 2026
Merged

feat(kmz): add archive limits and use temp_directory#24
danielebarbaro merged 1 commit into
mainfrom
feat/kmz-limits-and-temp-directory

Conversation

@danielebarbaro

Copy link
Copy Markdown
Contributor

1. No ceiling on what an archive may expand into

$zip->extractTo($destination);

A KMZ is a ZIP. A ZIP can declare a handful of entries that expand into far more than the machine has. extractAllFiles() extracted whatever it was given, with no check on entry count or uncompressed size, and extractKmlContent() read an entry straight into a PHP string with no check on its size either. Point either at a hostile archive and the disk fills or the worker runs out of memory.

Both now validate the archive before reading anything out of it:

  • max_archive_entries, default 5000
  • max_uncompressed_size, default 256 MB, summed across entries from statIndex()

Deliberately generous. A real KMZ is a KML plus its icons, nowhere near either number. Set either to 0 to turn it off.

2. Entry names were not checked

Nothing looked at where an entry claimed to live. ZipArchive::extractTo() does sanitise paths, so this was not exploitable in practice, but the package was relying on that rather than deciding it. Entries whose name is absolute, starts with a drive letter, or contains a .. segment are now rejected outright with the offending name in the message. Tested against ../escaped.txt, images/../../escaped.txt and /etc/passwd.

3. mkdir() return value ignored

if (! file_exists($destination)) {
    mkdir($destination, 0755, true);
}

A destination that could not be created raised a warning and then failed further down with something unrelated to the actual cause. It now throws Unable to create the extraction directory: <path>.

The call is @mkdir(...) because the return value is what gets acted on, and an application converting warnings to exceptions would otherwise surface PHP's mkdir(): Not a directory instead of the exception carrying the path. The second is_dir() after it covers another process winning the race.

4. temp_directory did nothing

It has been in the config since the first release, with a comment promising it "determines the temporary directory used for extracting KMZ files", and nothing has ever read it.

extractAllFiles() now takes an optional destination:

$extractor->extractAllFiles($kmz);              // temp_directory, else sys_get_temp_dir()
$extractor->extractAllFiles($kmz, '/my/path');  // unchanged

Each call gets its own kml-parser-<uniqid> directory underneath, so two concurrent extractions do not write over each other. defaultDestination() is public so a caller can find out where the files went.

Also

extractAllFiles() threw bare KmlException for a missing file and an unopenable archive, while extractKmlContent() threw KmzExtractorException for the same two conditions. Now consistent. This is a narrowing rather than a break: KmzExtractorException extends KmlException, so an existing catch (KmlException) is unaffected.

extractKmlContent() also stopped calling range(0, $zip->numFiles - 1), which builds an array of every index in the archive just to find the first .kml.

Tests

tests/KmzHardeningTest.php, 10 tests building real archives on disk:

  • entry count over the limit is rejected
  • uncompressed size over the limit is rejected
  • three shapes of escaping entry name are rejected
  • an ordinary archive still works
  • 0 means no limit, for both
  • with no destination, files land under the configured temp_directory
  • with no temp_directory, defaultDestination() falls back to the system temp directory
  • a destination that cannot be created throws with the path

Suite 69 to 79. PHPStan and Pint clean. README updated for the new config keys and the optional destination.

Note

The defaults are a judgement call. 5000 entries and 256 MB are far above any real KMZ I can find but are not derived from a spec. If a legitimate archive trips them, the fix is to raise the config value, and the exception message says which limit was hit.

extractAllFiles() read an archive with no ceiling on entry count or
uncompressed size, so a KMZ declaring a handful of entries that expand
into gigabytes filled the disk before anything noticed. Entry names went
unchecked too. Both are now validated before a single byte is read, and
either limit can be turned off by setting it to 0.

The mkdir() return value was ignored, so a destination that could not be
created produced a warning and then a confusing failure further down.
It now throws with the path that could not be made.

temp_directory has been in the config since the first release without
anything reading it. extractAllFiles() takes an optional destination and
falls back to that key, then to the system temp directory, giving each
call a directory of its own.

extractAllFiles() also stops throwing bare KmlException for a missing or
unreadable archive and uses KmzExtractorException like the rest of the
class. That is a narrowing, KmzExtractorException extends KmlException.
@danielebarbaro
danielebarbaro merged commit 80e83d4 into main Sep 8, 2026
19 checks passed
@danielebarbaro
danielebarbaro deleted the feat/kmz-limits-and-temp-directory branch September 8, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant