From af0a8badb3cd9f470cb55c5f714263f63425d40b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:45:00 +0200 Subject: [PATCH] refactor(backup): validate database backup upload file type and size Add allowlist of backup file extensions (sql, sql.gz, tar, tgz, zip, dump, bak, bson, archive, bz2, xz, and compound variants) and enforce a 10 GiB maximum file size on the backup upload endpoint. Validation runs early on each chunk using the dropzone metadata and again on the assembled file. Also drops the unused createFilename helper and the commented-out S3 block. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/UploadController.php | 94 ++++++++++++++----- .../DatabaseBackupUploadValidationTest.php | 62 ++++++++++++ 2 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 tests/Feature/DatabaseBackupUploadValidationTest.php diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 93847589a..96fbd7193 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -11,6 +11,26 @@ class UploadController extends BaseController { + private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB + + private const ALLOWED_EXTENSIONS = [ + 'sql', + 'sql.gz', + 'gz', + 'zip', + 'tar', + 'tar.gz', + 'tgz', + 'dump', + 'bak', + 'bson', + 'bson.gz', + 'archive', + 'archive.gz', + 'bz2', + 'xz', + ]; + public function upload(Request $request) { $databaseIdentifier = request()->route('databaseUuid'); @@ -18,6 +38,22 @@ public function upload(Request $request) if (is_null($resource)) { return response()->json(['error' => 'You do not have permission for this database'], 500); } + + $chunk = $request->file('file'); + $originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null; + if (blank($originalName) || ! self::hasAllowedExtension($originalName)) { + return response()->json([ + 'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS), + ], 422); + } + + $declaredTotalSize = (int) $request->input('dzTotalFilesize', 0); + if ($declaredTotalSize > self::MAX_BYTES) { + return response()->json([ + 'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.', + ], 422); + } + $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); if ($receiver->isUploaded() === false) { @@ -40,29 +76,20 @@ public function upload(Request $request) 'status' => true, ]); } - // protected function saveFileToS3($file) - // { - // $fileName = $this->createFilename($file); - // $disk = Storage::disk('s3'); - // // It's better to use streaming Streaming (laravel 5.4+) - // $disk->putFileAs('photos', $file, $fileName); - - // // for older laravel - // // $disk->put($fileName, file_get_contents($file), 'public'); - // $mime = str_replace('/', '-', $file->getMimeType()); - - // // We need to delete the file when uploaded to s3 - // unlink($file->getPathname()); - - // return response()->json([ - // 'path' => $disk->url($fileName), - // 'name' => $fileName, - // 'mime_type' => $mime - // ]); - // } protected function saveFile(UploadedFile $file, string $resourceIdentifier) { + $originalName = $file->getClientOriginalName(); + $size = $file->getSize(); + + if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) { + @unlink($file->getPathname()); + + return response()->json([ + 'error' => 'Uploaded file failed validation.', + ], 422); + } + $mime = str_replace('/', '-', $file->getMimeType()); $filePath = "upload/{$resourceIdentifier}"; $finalPath = storage_path('app/'.$filePath); @@ -73,13 +100,30 @@ protected function saveFile(UploadedFile $file, string $resourceIdentifier) ]); } - protected function createFilename(UploadedFile $file) + private static function hasAllowedExtension(string $name): bool { - $extension = $file->getClientOriginalExtension(); - $filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension + $lower = strtolower($name); + $suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS); + usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a)); - $filename .= '_'.md5(time()).'.'.$extension; + foreach ($suffixes as $suffix) { + if (! str_ends_with($lower, $suffix)) { + continue; + } - return $filename; + $stem = substr($lower, 0, -strlen($suffix)); + if ($stem !== '' && ! str_ends_with($stem, '.')) { + return true; + } + + return false; + } + + return false; + } + + private static function formatMaxSize(): string + { + return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB'; } } diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php new file mode 100644 index 000000000..a9d9886b8 --- /dev/null +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -0,0 +1,62 @@ +setAccessible(true); + + return $method->invoke(null, $name); +} + +test('hasAllowedExtension accepts supported extensions', function (string $name) { + expect(invokeHasAllowedExtension($name))->toBeTrue(); +})->with([ + 'plain sql' => ['backup.sql'], + 'uppercase sql' => ['BACKUP.SQL'], + 'compound sql.gz' => ['backup.sql.gz'], + 'compound tar.gz' => ['backup.tar.gz'], + 'tgz' => ['archive.tgz'], + 'zip' => ['dump.zip'], + 'tar' => ['dump.tar'], + 'gz' => ['data.gz'], + 'dump' => ['data.dump'], + 'bak' => ['data.bak'], + 'bson' => ['data.bson'], + 'bson.gz' => ['data.bson.gz'], + 'archive' => ['data.archive'], + 'archive.gz' => ['data.archive.gz'], + 'bz2' => ['data.bz2'], + 'xz' => ['data.xz'], +]); + +test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) { + expect(invokeHasAllowedExtension($name))->toBeFalse(); +})->with([ + 'php' => ['shell.php'], + 'phtml' => ['shell.phtml'], + 'sh' => ['run.sh'], + 'exe' => ['malware.exe'], + 'elf binary no ext' => ['payload'], + 'html' => ['index.html'], + 'bare compound without stem' => ['.sql.gz'], + 'bare extension' => ['.sql'], + 'empty string' => [''], + 'misleading double ext' => ['shell.php.sql-evil'], +]); + +test('MAX_BYTES constant is 10 GiB', function () { + $constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES'); + expect($constant)->toBe(10 * 1024 * 1024 * 1024); +}); + +test('ALLOWED_EXTENSIONS does not include executable formats', function () { + $constant = (new ReflectionClass(UploadController::class))->getConstant('ALLOWED_EXTENSIONS'); + expect($constant)->toBeArray(); + + $forbidden = ['php', 'phtml', 'php5', 'sh', 'bash', 'exe', 'js', 'html', 'htm', 'pl', 'py']; + foreach ($forbidden as $bad) { + expect($constant)->not->toContain($bad); + } +});