fix: harden database backup imports

This commit is contained in:
Andras Bacsai committed 2026-06-29 10:27:01 +02:00
1 parent e7dff30b7c
commit 2d63d51237
5 files changed
+471 -44

No files matched your search

@@ -1,6 +1,28 @@
<?php
use App\Http\Controllers\UploadController;
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandalonePostgresql;
use App\Support\DatabaseBackupFileValidator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Process;
function writeScanPayload(string $content, bool $gzip = false): string
{
$path = tempnam(sys_get_temp_dir(), 'coolify-scan-payload-');
file_put_contents($path, $gzip ? gzencode($content) : $content);
return $path;
}
/**
* Execute the real shell scanner snippet (as it runs inside the container)
* against a local payload file and return true when the restore is blocked.
*/
function scannerBlocks(string $script): bool
{
return Process::run(['sh', '-c', $script])->exitCode() === 1;
}
function invokeHasAllowedExtension(string $name): bool
{
@@ -10,6 +32,28 @@ function invokeHasAllowedExtension(string $name): bool
return $method->invoke(null, $name);
}
function backupValidationImportFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
function makeTemporaryUpload(string $name, string $content): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'coolify-upload-test-');
file_put_contents($path, $content);
return new UploadedFile($path, $name, null, null, true);
}
test('hasAllowedExtension accepts supported extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeTrue();
})->with([
@@ -46,6 +90,140 @@ test('hasAllowedExtension rejects unsupported or empty stems', function (string
'misleading double ext' => ['shell.php.sql-evil'],
]);
test('hasAllowedExtension rejects dangerous double extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeFalse();
})->with([
'php sql' => ['evil.php.sql'],
'php gzip' => ['evil.php.gz'],
'shell tar' => ['evil.sh.tar'],
'php tar gzip' => ['shell.php.tar.gz'],
'exe zip' => ['cmd.exe.zip'],
'jsp sql' => ['evil.jsp.sql'],
]);
test('backup validator rejects content that does not match the backup extension', function () {
$file = makeTemporaryUpload('payload.sql.gz', 'not actually gzip');
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('backup validator accepts valid plain sql and gzip backup content', function () {
$plainSql = makeTemporaryUpload('backup.sql', "CREATE TABLE users (id integer);\n");
$gzipSql = makeTemporaryUpload('backup.sql.gz', gzencode("CREATE TABLE users (id integer);\n"));
expect(DatabaseBackupFileValidator::isUploadAllowed($plainSql, 10 * 1024 * 1024))->toBeTrue()
->and(DatabaseBackupFileValidator::isUploadAllowed($gzipSql, 10 * 1024 * 1024))->toBeTrue();
});
test('postgresql backup safety scanner detects program execution payloads', function (string $payload) {
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeTrue();
})->with([
'copy from program' => ["COPY pwned FROM PROGRAM 'id';"],
'copy to program' => ["COPY pwned TO PROGRAM 'cat > /tmp/out';"],
'copy with block comment' => ["COPY pwned FROM/**/PROGRAM 'id';"],
'psql shell command' => ["\\! id\n"],
'psql copy program' => ["\\copy pwned from program 'id'\n"],
]);
test('postgresql backup safety scanner allows ordinary sql dumps', function () {
$dump = <<<'SQL'
-- PostgreSQL database dump
CREATE TABLE users (id integer, name text);
COPY users (id, name) FROM stdin;
1 Taylor
\.
SQL;
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse();
});
test('postgresql restore commands include a safety check before execution', function () {
$component = new class extends ImportForm
{
public function __get($property)
{
if ($property === 'resource') {
return new class
{
public function getMorphClass(): string
{
return StandalonePostgresql::class;
}
};
}
return parent::__get($property);
}
};
$component->container = 'postgres-test';
$command = $component->buildRestoreSafetyCheckCommand('/tmp/restore_test');
expect($command)
->toContain('docker exec postgres-test')
->toContain('COPY ... PROGRAM')
->toContain('/tmp/restore_test')
->toContain('grep -Eiq');
});
test('non postgresql restore commands do not include a safety check', function () {
$component = backupValidationImportFormWithResource('App\Models\StandaloneMysql');
$component->container = 'mysql-test';
expect($component->buildRestoreSafetyCheckCommand('/tmp/restore_test'))->toBeNull();
});
test('file scanner detects program execution payloads inside gzipped backups', function () {
$gzPayload = writeScanPayload("CREATE TABLE x();\nCOPY x FROM/**/PROGRAM 'id';\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzPayload))->toBeTrue();
});
test('file scanner allows ordinary gzipped dumps', function () {
$gzClean = writeScanPayload("CREATE TABLE x();\nCOPY x FROM stdin;\n1\\.\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse();
});
test('backup validator rejects plaintext .dump containing program execution', function () {
$file = makeTemporaryUpload('evil.dump', "COPY x FROM PROGRAM 'id';\n");
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('remote postgresql scanner blocks bypass payloads', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeTrue();
})->with([
'psql shell escape' => ["\\! id\n", false],
'copy from program' => ["COPY x FROM PROGRAM 'id';\n", false],
'copy with block comment' => ["COPY x FROM/**/PROGRAM 'id';\n", false],
'copy split across lines' => ["COPY x FROM\nPROGRAM 'id';\n", false],
'copy to program' => ["COPY x TO PROGRAM 'cat > /tmp/x';\n", false],
'psql pipe redirect' => ["\\o | id\n", false],
'gzipped comment bypass' => ["COPY x FROM/**/PROGRAM 'id';\n", true],
]);
test('remote postgresql scanner allows legitimate restores', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeFalse();
})->with([
'commented out payload' => ["-- COPY x FROM PROGRAM 'id'\nSELECT 1;\n", false],
'copy from stdin' => ["COPY users FROM stdin;\n1\tTaylor\n\\.\n", false],
'plain select' => ["SELECT * FROM users;\n", false],
'gzipped clean dump' => ["CREATE TABLE users (id int);\n", true],
]);
test('MAX_BYTES constant is 10 GiB', function () {
$constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
expect($constant)->toBe(10 * 1024 * 1024 * 1024);