Files
SkinbaseNova/app/Uploads/Services/ArchiveInspectorService.php
2026-02-14 15:14:12 +01:00

206 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Uploads\Services;
use RuntimeException;
use ZipArchive;
final class ArchiveInspectorService
{
private const MAX_DEPTH = 5;
private const MAX_FILES = 5000;
private const MAX_UNCOMPRESSED_BYTES = 524288000; // 500 MB
private const MAX_RATIO = 50.0;
/** @var array<int, string> */
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);
}
}