diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index ee546680f0..0b5dd1ec83 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -9,6 +9,7 @@ use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; class General extends Component @@ -35,6 +36,7 @@ class General extends Component public ?string $postgresConf = null; + #[Locked] public ?array $initScripts = null; public string $image; @@ -265,11 +267,19 @@ class General extends Component $container_name = $this->database->uuid; $configuration_dir = database_configuration_dir().'/'.$container_name; + // Only an unchanged filename of an existing script skips validation, so legacy rows stay editable. + if (! $oldScript || $oldScript['filename'] !== $script['filename']) { + try { + validateFilenameSafe((string) data_get($script, 'filename'), 'init script filename'); + } catch (Exception $e) { + $this->dispatch('error', $e->getMessage()); + + return; + } + } + if ($oldScript && $oldScript['filename'] !== $script['filename']) { try { - // New filename is user-supplied — must be safe before accepting the rename. - validateFilenameSafe($script['filename'], 'init script filename'); - // Old filename may be a legacy value written before this validation existed. // basename() scopes the rm to the initdb.d directory; escapeshellarg() contains // any remaining shell-metachars. No validator — don't block cleanup of legacy rows. diff --git a/resources/css/app.css b/resources/css/app.css index aae0abaf21..6d4621eee2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4274,6 +4274,18 @@ html[data-theme="custom"] .logs-viewer-timestamp { width: auto; } +/* Mobile: search on its own row, cancel/force-start + menus below it */ +@media (max-width: 639.98px) { + .logs-viewer-end:not(.runtime-logs-viewer-end) { + flex-wrap: wrap; + } + + .logs-viewer-end:not(.runtime-logs-viewer-end) > .logs-viewer-search { + order: -1; + flex: 1 1 100%; + } +} + .runtime-logs-viewer-end { align-items: stretch; flex-direction: column; @@ -4395,7 +4407,7 @@ html[data-theme="custom"] .logs-viewer-timestamp { /* Desktop: [actions + status] ... [cancel + search] */ .logs-viewer-toolbar-controls { flex-direction: row; - flex-wrap: nowrap; + flex-wrap: wrap; align-items: center; gap: 0.5rem 0.75rem; } diff --git a/tests/Feature/PostgresqlInitScriptPathTraversalTest.php b/tests/Feature/PostgresqlInitScriptPathTraversalTest.php new file mode 100644 index 0000000000..4627299ece --- /dev/null +++ b/tests/Feature/PostgresqlInitScriptPathTraversalTest.php @@ -0,0 +1,103 @@ + 0]); + config(['app.maintenance.store' => 'array', 'cache.default' => 'array']); + Process::fake(); + Queue::fake(); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->team->members()->attach($this->owner->id, ['role' => 'owner']); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $this->destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +test('a stored init script filename with path traversal is written inside the initdb directory', function () { + $executor = new class + { + public array $commands = []; + + public function execute(array $commands, $database, Activity $activity): Activity + { + $this->commands = $commands; + + return $activity; + } + }; + app()->instance(DatabaseStartCommandExecutor::class, $executor); + + $database = create_standalone_postgresql($this->environment->id, $this->destination, [ + 'init_scripts' => [['index' => 0, 'filename' => '../../../../../etc/cron.d/pwn', 'content' => 'x']], + ]); + + StartPostgresql::run($database->fresh(), new Activity); + + $initDir = database_configuration_dir().'/'.$database->uuid.'/docker-entrypoint-initdb.d'; + $teeCommands = collect($executor->commands)->filter(fn ($command) => is_string($command) && str_contains($command, '| tee ') && str_contains($command, 'docker-entrypoint-initdb.d')); + + expect($teeCommands)->toHaveCount(1) + ->and($teeCommands->first())->toEndWith('| tee '.escapeshellarg($initDir.'/pwn').' > /dev/null') + ->and(implode("\n", array_filter($executor->commands, 'is_string')))->not->toContain('etc/cron.d'); +}); + +test('the init script list cannot be replaced from the browser', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination); + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + expect(fn () => Livewire::test(PostgresqlGeneral::class, ['database' => $database->fresh()]) + ->set('initScripts', [['index' => 0, 'filename' => '../../etc/cron.d/pwn', 'content' => 'x']])) + ->toThrow(CannotUpdateLockedPropertyException::class); + + expect($database->fresh()->init_scripts)->toBeEmpty(); +}); + +test('saving an init script with an unknown original filename validates the new filename', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination); + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(PostgresqlGeneral::class, ['database' => $database->fresh()]) + ->call('save_init_script', ['index' => 0, 'filename' => '../../etc/cron.d/pwn', 'content' => 'x'], 'missing.sql') + ->assertDispatched('error'); + + expect($database->fresh()->init_scripts)->toBeEmpty(); +}); + +test('saving an existing init script with a safe filename still works', function () { + $database = create_standalone_postgresql($this->environment->id, $this->destination, [ + 'init_scripts' => [['index' => 0, 'filename' => 'init.sql', 'content' => 'select 1;']], + ]); + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(PostgresqlGeneral::class, ['database' => $database->fresh()]) + ->call('save_init_script', ['index' => 0, 'filename' => 'init.sql', 'content' => 'select 2;'], 'init.sql') + ->assertDispatched('success'); + + expect($database->fresh()->init_scripts)->toBe([['index' => 0, 'filename' => 'init.sql', 'content' => 'select 2;']]); +});