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); + } +});