*/ private const BLOCKED_EXTENSIONS = [ 'php', 'exe', 'sh', 'bat', 'js', 'ps1', 'cmd', 'vbs', 'jar', 'elf', ]; public function inspect(string $archivePath): \App\Uploads\Services\InspectionResult { $zip = new ZipArchive(); $opened = $zip->open($archivePath); if ($opened !== true) { throw new RuntimeException('Unable to read archive metadata.'); } $files = 0; $maxDepth = 0; $totalUncompressed = 0; $totalCompressed = 0; for ($index = 0; $index < $zip->numFiles; $index++) { $stat = $zip->statIndex($index); if (! is_array($stat)) { continue; } $entryName = (string) ($stat['name'] ?? ''); $uncompressedSize = (int) ($stat['size'] ?? 0); $compressedSize = (int) ($stat['comp_size'] ?? 0); $isDirectory = str_ends_with($entryName, '/'); $pathCheck = $this->validatePath($entryName); if ($pathCheck !== null) { return $this->closeAndFail($zip, $pathCheck, $files, $maxDepth, $totalUncompressed, $totalCompressed); } if ($this->isSymlink($zip, $index)) { return $this->closeAndFail($zip, 'Archive contains a symlink entry.', $files, $maxDepth, $totalUncompressed, $totalCompressed); } $depth = $this->depthForEntry($entryName, $isDirectory); $maxDepth = max($maxDepth, $depth); if ($maxDepth > self::MAX_DEPTH) { return $this->closeAndFail($zip, 'Archive directory depth exceeds 5.', $files, $maxDepth, $totalUncompressed, $totalCompressed); } if (! $isDirectory) { $files++; if ($files > self::MAX_FILES) { return $this->closeAndFail($zip, 'Archive file count exceeds 5000.', $files, $maxDepth, $totalUncompressed, $totalCompressed); } $extension = strtolower((string) pathinfo($entryName, PATHINFO_EXTENSION)); if (in_array($extension, self::BLOCKED_EXTENSIONS, true)) { return $this->closeAndFail($zip, 'Archive contains blocked executable/script file.', $files, $maxDepth, $totalUncompressed, $totalCompressed); } $totalUncompressed += max(0, $uncompressedSize); $totalCompressed += max(0, $compressedSize); if ($totalUncompressed > self::MAX_UNCOMPRESSED_BYTES) { return $this->closeAndFail($zip, 'Archive uncompressed size exceeds 500 MB.', $files, $maxDepth, $totalUncompressed, $totalCompressed); } $ratio = $this->compressionRatio($totalUncompressed, $totalCompressed); if ($ratio > self::MAX_RATIO) { return $this->closeAndFail($zip, 'Archive compression ratio exceeds safety threshold.', $files, $maxDepth, $totalUncompressed, $totalCompressed); } } } $stats = $this->stats($files, $maxDepth, $totalUncompressed, $totalCompressed); $zip->close(); return \App\Uploads\Services\InspectionResult::pass($stats); } private function validatePath(string $entryName): ?string { $normalized = str_replace('\\', '/', $entryName); if ($normalized === '' || str_contains($normalized, "\0")) { return 'Archive contains invalid entry path.'; } if ( strlen($entryName) >= 3 && ctype_alpha($entryName[0]) && $entryName[1] === ':' && in_array($entryName[2], ['\\', '/'], true) ) { return 'Archive entry contains drive-letter absolute path.'; } if (str_starts_with($normalized, '/') || str_starts_with($normalized, '\\')) { return 'Archive entry contains absolute path.'; } if (str_contains($entryName, '../') || str_contains($entryName, '..\\') || str_contains($normalized, '../')) { return 'Archive entry contains path traversal sequence.'; } $segments = array_filter(explode('/', trim($normalized, '/')), static fn (string $segment): bool => $segment !== ''); foreach ($segments as $segment) { if ($segment === '..') { return 'Archive entry contains parent traversal segment.'; } } return null; } private function isSymlink(ZipArchive $zip, int $index): bool { $attributes = 0; $opsys = 0; if (! $zip->getExternalAttributesIndex($index, $opsys, $attributes)) { return false; } if ($opsys !== ZipArchive::OPSYS_UNIX) { return false; } $mode = ($attributes >> 16) & 0xF000; return $mode === 0xA000; } private function depthForEntry(string $entryName, bool $isDirectory): int { $normalized = trim(str_replace('\\', '/', $entryName), '/'); if ($normalized === '') { return 0; } $segments = array_values(array_filter(explode('/', $normalized), static fn (string $segment): bool => $segment !== '')); if ($segments === []) { return 0; } return max(0, count($segments) - ($isDirectory ? 0 : 1)); } private function compressionRatio(int $uncompressed, int $compressed): float { if ($uncompressed <= 0) { return 0.0; } if ($compressed <= 0) { return (float) $uncompressed; } return $uncompressed / $compressed; } /** * @return array{files:int,depth:int,size:int,ratio:float} */ private function stats(int $files, int $depth, int $size, int $compressed): array { return [ 'files' => $files, 'depth' => $depth, 'size' => $size, 'ratio' => $this->compressionRatio($size, $compressed), ]; } private function closeAndFail(ZipArchive $zip, string $reason, int $files, int $depth, int $size, int $compressed): \App\Uploads\Services\InspectionResult { $stats = $this->stats($files, $depth, $size, $compressed); $zip->close(); return \App\Uploads\Services\InspectionResult::fail($reason, $stats); } }