Upload beautify
This commit is contained in:
205
app/Uploads/Services/ArchiveInspectorService.php
Normal file
205
app/Uploads/Services/ArchiveInspectorService.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user