refactor(backup): validate database backup upload file type and size (#9667)
This commit is contained in:
commit
e6a6446dae
2 changed files with 131 additions and 25 deletions
|
|
@ -11,6 +11,26 @@
|
||||||
|
|
||||||
class UploadController extends BaseController
|
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)
|
public function upload(Request $request)
|
||||||
{
|
{
|
||||||
$databaseIdentifier = request()->route('databaseUuid');
|
$databaseIdentifier = request()->route('databaseUuid');
|
||||||
|
|
@ -18,6 +38,22 @@ public function upload(Request $request)
|
||||||
if (is_null($resource)) {
|
if (is_null($resource)) {
|
||||||
return response()->json(['error' => 'You do not have permission for this database'], 500);
|
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));
|
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
|
||||||
|
|
||||||
if ($receiver->isUploaded() === false) {
|
if ($receiver->isUploaded() === false) {
|
||||||
|
|
@ -40,29 +76,20 @@ public function upload(Request $request)
|
||||||
'status' => true,
|
'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)
|
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());
|
$mime = str_replace('/', '-', $file->getMimeType());
|
||||||
$filePath = "upload/{$resourceIdentifier}";
|
$filePath = "upload/{$resourceIdentifier}";
|
||||||
$finalPath = storage_path('app/'.$filePath);
|
$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();
|
$lower = strtolower($name);
|
||||||
$filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension
|
$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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
tests/Feature/DatabaseBackupUploadValidationTest.php
Normal file
62
tests/Feature/DatabaseBackupUploadValidationTest.php
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue