{"record":{"id":"aca57c5082adc618","repo":"getgrav/grav","slug":"ziparchiver-refused-to-extract-archive-file-en","errorCode":null,"errorMessage":"ZipArchiver: refused to extract {archive_file}. Entry \"{name}\" would escape the destination directory (Zip Slip).","messagePattern":"ZipArchiver: refused to extract (.+?)\\. Entry \"(.+?)\" would escape the destination directory \\(Zip Slip\\)\\.","errorType":"exception","errorClass":"RuntimeException","httpStatus":null,"severity":"critical","filePath":"system/src/Grav/Common/Filesystem/ZipArchiver.php","lineNumber":66,"sourceCode":"            //    GHSA-928x-9mpw-8h56).\n            [$maxSize, $maxFiles, $maxDepth] = $this->archiveLimits();\n            $numFiles = $zip->count();\n\n            if ($maxFiles > 0 && $numFiles > $maxFiles) {\n                $zip->close();\n                throw new RuntimeException('ZipArchiver: refused to extract ' . $this->archive_file . '. Archive exceeds the maximum file count (' . $maxFiles . ').');\n            }\n\n            $totalSize = 0;\n            for ($i = 0; $i < $numFiles; $i++) {\n                $name = $zip->getNameIndex($i);\n                if ($name === false) {\n                    continue;\n                }\n\n                if (!$this->isSafeEntryPath($name)) {\n                    $zip->close();\n                    throw new RuntimeException('ZipArchiver: refused to extract ' . $this->archive_file . '. Entry \"' . $name . '\" would escape the destination directory (Zip Slip).');\n                }\n\n                if ($maxDepth > 0) {\n                    $depth = count(array_filter(preg_split('#[\\\\\\\\/]+#', trim($name, '/\\\\'))));\n                    if ($depth > $maxDepth) {\n                        $zip->close();\n                        throw new RuntimeException('ZipArchiver: refused to extract ' . $this->archive_file . '. Entry \"' . $name . '\" exceeds the maximum nesting depth (' . $maxDepth . ').');\n                    }\n                }\n\n                if ($maxSize > 0) {\n                    // Advisory only: statIndex()['size'] is the uncompressed size\n                    // declared in the central directory, which the archive author\n                    // controls and can forge small (GHSA-8h9x-89f2-m7x3). It gives\n                    // an early reject for honest oversized archives, but the real\n                    // enforcement happens during streamed extraction below, against\n                    // the bytes actually inflated.\n                    $stat = $zip->statIndex($i);","sourceCodeStart":48,"sourceCodeEnd":84,"githubUrl":"https://github.com/getgrav/grav/blob/6040efed04efa69b8209448ed81308e7c24147c2/system/src/Grav/Common/Filesystem/ZipArchiver.php#L48-L84","documentation":"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).","triggerScenarios":"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.","commonSituations":"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).","solutions":["Treat this throw as a security stop, not a bug: do not bypass it — quarantine the archive and find where it came from","List entries to confirm: unzip -l package.zip | grep -E '(^|/)\\.\\./|^/|^[A-Za-z]:'","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","Keep ZipArchiver/GPM up to date so the isSafeEntryPath checks stay current"],"exampleFix":"# before — installing an untrusted archive\n$archiver = Archiver::create('zip')->setArchive($uploadedZip)->extract( GRAV_ROOT . '/user/themes/x');\n# throws: Entry \"../../index.php\" would escape the destination directory (Zip Slip)\n\n# after — validate source and entries before touching the site\n# 1. only install packages from getgrav.org / trusted vendors\n# 2. pre-scan entries (see validationCode) and reject any traversal pattern","handlingStrategy":"validation","validationCode":"$zip = new ZipArchive();\nif ($zip->open($path) === true) {\n    for ($i = 0; $i < $zip->count(); $i++) {\n        $name = str_replace('\\\\', '/', (string) $zip->getNameIndex($i));\n        if (str_starts_with($name, '/') || preg_match('#^[a-zA-Z]:#', $name) || str_contains($name, '../')) {\n            $zip->close();\n            throw new RuntimeException('Unsafe entry path: ' . $name);\n        }\n    }\n    $zip->close();\n}","typeGuard":null,"tryCatchPattern":"try {\n    (new ZipArchiver($path))->extract($destination);\n} catch (RuntimeException $e) {\n    if (str_contains($e->getMessage(), 'Zip Slip')) {\n        // hostile or malformed archive — quarantine it and alert; never widen the check\n    }\n}","preventionTips":["Only install packages from trusted origins (official GPM, verified vendors)","Pre-scan uploaded zips for ../, absolute paths, and drive letters before any extraction","Log and retain rejected archives for forensics — a Zip Slip hit usually means a targeted upload"],"tags":["grav","zip","security","zip-slip","cwe-22","path-traversal"],"backgroundTag":"zip-slip","analyzedSha":"6040efed04efa69b8209448ed81308e7c24147c2","analyzedAt":"2026-08-17T05:07:31.593Z","schemaVersion":2},"datasetVersion":"2026-08-17T09:17:11.063Z"}