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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-04-20 11:45:00 +02:00
co-authored by Claude Opus 4.7
parent e1f40903c3
commit af0a8badb3
2 changed files with 131 additions and 25 deletions
+69 -25
View File
@@ -11,6 +11,26 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
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 @@ class UploadController extends BaseController
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 @@ class UploadController extends BaseController
'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 @@ class UploadController extends BaseController
]);
}
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';
}
}
@@ -0,0 +1,62 @@
<?php
use App\Http\Controllers\UploadController;
function invokeHasAllowedExtension(string $name): bool
{
$method = new ReflectionMethod(UploadController::class, 'hasAllowedExtension');
$method->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);
}
});