From b8866b87e8e855e041c21330352ca615521afed3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:58:28 +0200 Subject: [PATCH] fix(livewire): authorize exposed resource mutations (#11596) --- app/Livewire/Project/Application/General.php | 2 + app/Livewire/Project/Database/Heading.php | 6 ++ .../Shared/EnvironmentVariable/Show.php | 2 + app/Livewire/Project/Shared/GetLogs.php | 7 +++ app/Livewire/Security/CloudInitScripts.php | 2 + .../Security/CloudProviderTokenForm.php | 1 + app/Livewire/Server/ValidateAndInstall.php | 10 ++++ .../ApplicationConfigAuthorizationTest.php | 58 +++++++++++++++++++ .../DatabaseConfigAuthorizationTest.php | 16 ++++- .../SecurityAuthorizationTest.php | 57 ++++++++++++++++++ .../Authorization/ServerAuthorizationTest.php | 19 ++++++ 11 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Authorization/SecurityAuthorizationTest.php diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index bb6bcf5f5..8f0bb2385 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -651,6 +651,8 @@ public function generateNginxConfiguration($type = 'static') public function resetDefaultLabels($manualReset = false) { + $this->authorize('update', $this->application); + try { if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) { return; diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 943f22702..f2f8fa387 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -35,6 +35,12 @@ public function getListeners() public function activityFinished() { + if (auth()->user()->cannot('update', $this->database)) { + $this->dispatch('refresh'); + + return; + } + try { // Only set started_at if database is actually running if ($this->database->isRunning()) { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 0e0851aaa..8350cffa4 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -145,6 +145,8 @@ public function refresh() */ public function loadValues(): void { + $this->authorize('update', $this->env); + if ($this->valuesLoaded) { return; } diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 67a040ef7..e1e541371 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -17,12 +17,15 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; use Livewire\Component; class GetLogs extends Component { + use AuthorizesRequests; + public const MAX_LOG_LINES = 50000; public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024; @@ -82,6 +85,10 @@ public function mount() public function instantSave() { if (! is_null($this->resource)) { + if (auth()->user()->cannot('update', $this->resource)) { + return; + } + if ($this->resource->getMorphClass() === Application::class) { $this->resource->settings->is_include_timestamps = $this->showTimeStamps; $this->resource->settings->save(); diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index b6d448e90..0d26d1d66 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -28,6 +28,8 @@ public function getListeners() public function loadScripts() { + $this->authorize('viewAny', CloudInitScript::class); + CloudInitScript::ownedByCurrentTeam() ->whereNull('uuid') ->get() diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index ba2655b43..2c31d2203 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -94,6 +94,7 @@ private function validateToken(string $provider, string $token): bool public function addToken() { + $this->authorize('create', CloudProviderToken::class); $this->validate(); try { diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index c39f868ba..db62bff2d 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -53,6 +53,8 @@ class ValidateAndInstall extends Component public function init(int $data = 0) { + $this->authorize('update', $this->server); + if (! $this->server->canBeValidated()) { $this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.'; $this->server->update([ @@ -160,6 +162,8 @@ public function validateConnection() public function validateOS() { + $this->authorize('update', $this->server); + $this->supported_os_type = $this->server->validateOS(); if (! $this->supported_os_type) { $this->error = 'Server OS type is not supported. Please install Docker manually before continuing: documentation.'; @@ -174,6 +178,8 @@ public function validateOS() public function validatePrerequisites() { + $this->authorize('update', $this->server); + $validationResult = $this->server->validatePrerequisites(); $this->prerequisites_installed = $validationResult['success']; if (! $validationResult['success']) { @@ -212,6 +218,8 @@ public function validatePrerequisites() public function validateDockerEngine() { + $this->authorize('update', $this->server); + $this->docker_installed = $this->server->validateDockerEngine(); $this->docker_compose_installed = $this->server->validateDockerCompose(); if (! $this->docker_installed || ! $this->docker_compose_installed) { @@ -248,6 +256,8 @@ public function validateDockerEngine() public function validateDockerVersion() { + $this->authorize('update', $this->server); + if ($this->server->isSwarm()) { $swarmInstalled = $this->server->validateDockerSwarm(); if ($swarmInstalled) { diff --git a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php index 584276990..eb331a775 100644 --- a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php +++ b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php @@ -1,8 +1,11 @@ member->can('update', $this->application))->toBeFalse(); }); +test('member cannot reset application labels', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $originalLabels = $this->application->custom_labels; + + $component = app(ApplicationGeneral::class); + $component->application = $this->application; + $component->isContainerLabelReadonlyEnabled = true; + + expect(fn () => $component->resetDefaultLabels(true)) + ->toThrow(AuthorizationException::class); + + expect($this->application->fresh()->custom_labels)->toBe($originalLabels); +}); + +test('member cannot load application environment variable values', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $environmentVariable = $this->application->environment_variables()->create([ + 'key' => 'SECRET_KEY', + 'value' => 'super-secret-value', + 'is_preview' => false, + ]); + + Livewire::test(EnvironmentVariableShow::class, [ + 'env' => $environmentVariable, + 'type' => 'application', + ]) + ->call('loadValues') + ->assertForbidden() + ->assertSet('valuesLoaded', false) + ->assertSet('value', null); +}); + +test('member cannot save application log timestamp settings', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $originalValue = $this->application->settings->is_include_timestamps; + + Livewire::test(GetLogs::class, [ + 'resource' => $this->application, + 'server' => $this->server, + 'container' => $this->application->uuid, + ]) + ->set('showTimeStamps', ! $originalValue) + ->call('instantSave') + ->assertSuccessful(); + + expect($this->application->settings->fresh()->is_include_timestamps)->toBe($originalValue); +}); + // --- Application Advanced Livewire actions --- test('member cannot save application advanced settings', function () { diff --git a/tests/Feature/Authorization/DatabaseConfigAuthorizationTest.php b/tests/Feature/Authorization/DatabaseConfigAuthorizationTest.php index 89c3d5e2f..7807f00a2 100644 --- a/tests/Feature/Authorization/DatabaseConfigAuthorizationTest.php +++ b/tests/Feature/Authorization/DatabaseConfigAuthorizationTest.php @@ -1,5 +1,6 @@ 0]); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0])); $this->team = Team::factory()->create(); @@ -89,6 +91,18 @@ expect($this->member->can('update', $this->database))->toBeFalse(); }); +test('member cannot persist database activity status', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Heading::class, ['database' => $this->database]) + ->call('activityFinished') + ->assertSuccessful() + ->assertDispatched('refresh'); + + expect($this->database->fresh()->started_at)->toBeNull(); +}); + // --- Database Policy: delete --- test('admin can delete database', function () { diff --git a/tests/Feature/Authorization/SecurityAuthorizationTest.php b/tests/Feature/Authorization/SecurityAuthorizationTest.php new file mode 100644 index 000000000..b36bcc3e4 --- /dev/null +++ b/tests/Feature/Authorization/SecurityAuthorizationTest.php @@ -0,0 +1,57 @@ +withoutVite(); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0])); + + $this->team = Team::factory()->create(); + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); +}); + +test('member cannot create a cloud provider token through the public action', function () { + Http::fake(['*' => Http::response([], 200)]); + + Livewire::test(CloudProviderTokenForm::class) + ->set('provider', 'hetzner') + ->set('token', 'secret-token') + ->set('name', 'Unauthorized token') + ->call('addToken') + ->assertForbidden(); + + expect(CloudProviderToken::query()->count())->toBe(0); +}); + +test('member cannot load cloud-init scripts through the public action', function () { + $script = CloudInitScript::query()->create([ + 'team_id' => $this->team->id, + 'name' => 'Protected script', + 'script' => '#cloud-config', + ]); + + CloudInitScript::query()->whereKey($script)->update(['uuid' => null]); + + $component = app(CloudInitScripts::class); + + expect(fn () => $component->loadScripts()) + ->toThrow(AuthorizationException::class); + + expect($script->refresh()->uuid)->toBeNull(); +}); diff --git a/tests/Feature/Authorization/ServerAuthorizationTest.php b/tests/Feature/Authorization/ServerAuthorizationTest.php index 63e15be2f..353c6a687 100644 --- a/tests/Feature/Authorization/ServerAuthorizationTest.php +++ b/tests/Feature/Authorization/ServerAuthorizationTest.php @@ -5,6 +5,7 @@ use App\Livewire\Server\Index as ServerIndex; use App\Livewire\Server\LogDrains; use App\Livewire\Server\Navbar as ServerNavbar; +use App\Livewire\Server\ValidateAndInstall; use App\Models\CloudProviderToken; use App\Models\InstanceSettings; use App\Models\Server; @@ -61,6 +62,24 @@ expect($this->member->can('update', $this->server))->toBeFalse(); }); +test('member cannot invoke server validation write steps', function (string $method) { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + $before = $this->server->fresh()->getAttributes(); + + Livewire::test(ValidateAndInstall::class, ['server' => $this->server]) + ->call($method) + ->assertForbidden(); + + expect($this->server->fresh()->getAttributes())->toBe($before); +})->with([ + 'init', + 'validateOS', + 'validatePrerequisites', + 'validateDockerEngine', + 'validateDockerVersion', +]); + // --- Server Policy: delete --- test('admin can delete server', function () {