getgrav/grav · critical · RuntimeException

ZipArchiver: refused to extract {archive_file}. Entry "{name

Error message

ZipArchiver: refused to extract {archive_file}. Entry "{name}" would escape the destination directory (Zip Slip).

What it means

Before extraction, ZipArchiver::extract() runs isSafeEntryPath() on every entry name to block Zip Slip (CWE-22): entries whose path resolves outside the destination — '../' sequences that climb past the root, absolute paths starting with '/', or Windows drive letters like 'C:' — cause an immediate RuntimeException naming the offending entry, and nothing is extracted. This guards against archives that would overwrite system files (e.g. ../../public/index.php).

Source

Thrown at system/src/Grav/Common/Filesystem/ZipArchiver.php:66

            //    GHSA-928x-9mpw-8h56).
            [$maxSize, $maxFiles, $maxDepth] = $this->archiveLimits();
            $numFiles = $zip->count();

            if ($maxFiles > 0 && $numFiles > $maxFiles) {
                $zip->close();
                throw new RuntimeException('ZipArchiver: refused to extract ' . $this->archive_file . '. Archive exceeds the maximum file count (' . $maxFiles . ').');
            }

            $totalSize = 0;
            for ($i = 0; $i < $numFiles; $i++) {
                $name = $zip->getNameIndex($i);
                if ($name === false) {
                    continue;
                }

                if (!$this->isSafeEntryPath($name)) {
                    $zip->close();
                    throw new RuntimeException('ZipArchiver: refused to extract ' . $this->archive_file . '. Entry "' . $name . '" would escape the destination directory (Zip Slip).');
                }

                if ($maxDepth > 0) {
                    $depth = count(array_filter(preg_split('#[\\\\/]+#', trim($name, '/\\'))));
                    if ($depth > $maxDepth) {
                        $zip->close();
                        throw new RuntimeException('ZipArchiver: refused to extract ' . $this->archive_file . '. Entry "' . $name . '" exceeds the maximum nesting depth (' . $maxDepth . ').');
                    }
                }

                if ($maxSize > 0) {
                    // Advisory only: statIndex()['size'] is the uncompressed size
                    // declared in the central directory, which the archive author
                    // controls and can forge small (GHSA-8h9x-89f2-m7x3). It gives
                    // an early reject for honest oversized archives, but the real
                    // enforcement happens during streamed extraction below, against
                    // the bytes actually inflated.
                    $stat = $zip->statIndex($i);

View on GitHub (pinned to 6040efed04)

Solutions

  1. Treat this throw as a security stop, not a bug: do not bypass it — quarantine the archive and find where it came from
  2. List entries to confirm: unzip -l package.zip | grep -E '(^|/)\.\./|^/|^[A-Za-z]:'
  3. If the content is genuinely needed, extract it on an isolated machine with a tool that sanitizes paths, re-zip it with clean relative names, and reinstall
  4. Keep ZipArchiver/GPM up to date so the isSafeEntryPath checks stay current

Example fix

# before — installing an untrusted archive
$archiver = Archiver::create('zip')->setArchive($uploadedZip)->extract( GRAV_ROOT . '/user/themes/x');
# throws: Entry "../../index.php" would escape the destination directory (Zip Slip)

# after — validate source and entries before touching the site
# 1. only install packages from getgrav.org / trusted vendors
# 2. pre-scan entries (see validationCode) and reject any traversal pattern
Defensive patterns

Strategy: validation

Validate before calling

$zip = new ZipArchive();
if ($zip->open($path) === true) {
    for ($i = 0; $i < $zip->count(); $i++) {
        $name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
        if (str_starts_with($name, '/') || preg_match('#^[a-zA-Z]:#', $name) || str_contains($name, '../')) {
            $zip->close();
            throw new RuntimeException('Unsafe entry path: ' . $name);
        }
    }
    $zip->close();
}

Try / catch

try {
    (new ZipArchiver($path))->extract($destination);
} catch (RuntimeException $e) {
    if (str_contains($e->getMessage(), 'Zip Slip')) {
        // hostile or malformed archive — quarantine it and alert; never widen the check
    }
}

Prevention

When it happens

Trigger: Extracting a crafted archive containing entries like '../../evil.php', '/etc/passwd' style absolute names, or 'C:\\x\\y'; archives produced by buggy tools that store absolute or backslash-absolute paths; penetration tests or real attacks delivering malicious theme/plugin zips through an upload/install endpoint.

Common situations: Package installed from an untrusted mirror or intercepted download; custom code letting users upload and extract zips; legacy archives created with tools that recorded absolute filenames (some old Windows zippers do).

Related errors


AI-assisted analysis of getgrav/grav@6040efed04 (2026-08-17). Data as JSON: /api/errors/aca57c5082adc618. Report an issue: GitHub.