169 lines
5.0 KiB
PHP
169 lines
5.0 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Uploads;
|
|
|
|
use App\Uploads\Services\ArchiveInspectorService;
|
|
use App\Uploads\Services\InspectionResult;
|
|
use Tests\TestCase;
|
|
use ZipArchive;
|
|
|
|
class ArchiveInspectorServiceTest extends TestCase
|
|
{
|
|
/** @var array<int, string> */
|
|
private array $tempFiles = [];
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
foreach ($this->tempFiles as $file) {
|
|
if (is_file($file)) {
|
|
@unlink($file);
|
|
}
|
|
}
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function test_rejects_zip_slip_path(): void
|
|
{
|
|
$archive = $this->makeZip([
|
|
'../evil.txt' => 'x',
|
|
]);
|
|
|
|
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
|
|
|
$this->assertFalse($result->valid);
|
|
$this->assertStringContainsString('path traversal', (string) $result->reason);
|
|
}
|
|
|
|
public function test_rejects_symlink_entries(): void
|
|
{
|
|
$archive = $this->makeZipWithCallback([
|
|
'safe/file.txt' => 'ok',
|
|
'safe/link' => 'target',
|
|
], function (ZipArchive $zip, string $entryName): void {
|
|
if ($entryName === 'safe/link') {
|
|
$zip->setExternalAttributesName($entryName, ZipArchive::OPSYS_UNIX, 0120777 << 16);
|
|
}
|
|
});
|
|
|
|
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
|
|
|
$this->assertFalse($result->valid);
|
|
$this->assertStringContainsString('symlink', strtolower((string) $result->reason));
|
|
}
|
|
|
|
public function test_rejects_deep_nesting(): void
|
|
{
|
|
$archive = $this->makeZip([
|
|
'a/b/c/d/e/f/file.txt' => 'too deep',
|
|
]);
|
|
|
|
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
|
|
|
$this->assertFalse($result->valid);
|
|
$this->assertStringContainsString('depth', strtolower((string) $result->reason));
|
|
}
|
|
|
|
public function test_rejects_too_many_files(): void
|
|
{
|
|
$entries = [];
|
|
for ($index = 0; $index < 5001; $index++) {
|
|
$entries['f' . $index . '.txt'] = 'x';
|
|
}
|
|
|
|
$archive = $this->makeZip($entries);
|
|
|
|
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
|
|
|
$this->assertFalse($result->valid);
|
|
$this->assertStringContainsString('5000', (string) $result->reason);
|
|
}
|
|
|
|
public function test_rejects_executable_extensions(): void
|
|
{
|
|
$archive = $this->makeZip([
|
|
'skins/readme.txt' => 'ok',
|
|
'skins/run.exe' => 'MZ',
|
|
]);
|
|
|
|
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
|
|
|
$this->assertFalse($result->valid);
|
|
$this->assertStringContainsString('blocked', strtolower((string) $result->reason));
|
|
}
|
|
|
|
public function test_rejects_zip_bomb_ratio(): void
|
|
{
|
|
$archive = $this->makeZip([
|
|
'payload.txt' => str_repeat('A', 6 * 1024 * 1024),
|
|
]);
|
|
|
|
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
|
|
|
$this->assertFalse($result->valid);
|
|
$this->assertStringContainsString('ratio', strtolower((string) $result->reason));
|
|
}
|
|
|
|
public function test_valid_archive_passes(): void
|
|
{
|
|
$archive = $this->makeZip([
|
|
'skins/theme/readme.txt' => 'safe',
|
|
'skins/theme/colors.ini' => 'accent=blue',
|
|
]);
|
|
|
|
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
|
|
|
$this->assertInstanceOf(InspectionResult::class, $result);
|
|
$this->assertTrue($result->valid);
|
|
$this->assertNull($result->reason);
|
|
$this->assertIsArray($result->stats);
|
|
$this->assertArrayHasKey('files', $result->stats);
|
|
$this->assertArrayHasKey('depth', $result->stats);
|
|
$this->assertArrayHasKey('size', $result->stats);
|
|
$this->assertArrayHasKey('ratio', $result->stats);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $entries
|
|
*/
|
|
private function makeZip(array $entries): string
|
|
{
|
|
return $this->makeZipWithCallback($entries, null);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $entries
|
|
* @param (callable(ZipArchive,string):void)|null $entryCallback
|
|
*/
|
|
private function makeZipWithCallback(array $entries, ?callable $entryCallback): string
|
|
{
|
|
if (! class_exists(ZipArchive::class)) {
|
|
$this->markTestSkipped('ZipArchive extension is required.');
|
|
}
|
|
|
|
$path = tempnam(sys_get_temp_dir(), 'sb_zip_');
|
|
if ($path === false) {
|
|
throw new \RuntimeException('Unable to create temporary zip path.');
|
|
}
|
|
|
|
$this->tempFiles[] = $path;
|
|
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($path, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) {
|
|
throw new \RuntimeException('Unable to open temporary zip for writing.');
|
|
}
|
|
|
|
foreach ($entries as $name => $content) {
|
|
$zip->addFromString($name, $content);
|
|
|
|
if ($entryCallback !== null) {
|
|
$entryCallback($zip, $name);
|
|
}
|
|
}
|
|
|
|
$zip->close();
|
|
|
|
return $path;
|
|
}
|
|
}
|