From a331f91c4915db4abae7929d75eb31af74530d8f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:23:29 +0200 Subject: [PATCH] fix(ui): validate secret manager settings and lock server-built lists - SecretManagerLinks: validate settings when the integration token changes, and save only validated keys - Service Domains: lock domainRows - Server DockerImages: lock images --- app/Livewire/Project/Service/Domains.php | 1 + .../Project/Shared/SecretManagerLinks.php | 21 ++++- app/Livewire/Server/DockerImages.php | 5 + .../LockedDomainRowsAndDockerImagesTest.php | 78 ++++++++++++++++ ...retManagerLinksTokenChangeSettingsTest.php | 93 +++++++++++++++++++ 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/LockedDomainRowsAndDockerImagesTest.php create mode 100644 tests/Feature/SecretManagers/SecretManagerLinksTokenChangeSettingsTest.php diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index f70d521150..8747b0f619 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -49,6 +49,7 @@ class Domains extends Component public ?int $pendingRedirectServiceApplicationId = null; /** @var array> */ + #[Locked] public array $domainRows = []; public ?int $newServiceApplicationId = null; diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php index afe4c85f31..36d51252d4 100644 --- a/app/Livewire/Project/Shared/SecretManagerLinks.php +++ b/app/Livewire/Project/Shared/SecretManagerLinks.php @@ -8,6 +8,7 @@ use App\Support\ValidationPatterns; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Component; /** @@ -124,7 +125,8 @@ class SecretManagerLinks extends Component $this->settings = []; } - $settings = array_filter($this->settings, fn ($value) => filled($value)); + $validated = $this->validate($this->tokenChangeRules()); + $settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value)); $this->resource->secretManagerLink()->updateOrCreate([], [ 'integration_token_id' => $token->id, @@ -138,11 +140,28 @@ class SecretManagerLinks extends Component $this->resetKeys(); $this->loadData(); $this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.'); + } catch (ValidationException $e) { + throw $e; } catch (\Throwable $e) { handleError($e, $this); } } + /** + * The settings rules of the selected provider, with the fields still optional: a token is + * saved before the user fills in its settings. + * + * @return array> + */ + private function tokenChangeRules(): array + { + return collect($this->rules()) + ->map(fn (array $rules, string $field): array => str_starts_with($field, 'settings.') + ? array_map(fn (string $rule): string => $rule === 'required' ? 'nullable' : $rule, $rules) + : $rules) + ->all(); + } + /** * Auto-save of the provider-specific settings fields (called on blur). */ diff --git a/app/Livewire/Server/DockerImages.php b/app/Livewire/Server/DockerImages.php index dab873c650..e9f6b5bef8 100644 --- a/app/Livewire/Server/DockerImages.php +++ b/app/Livewire/Server/DockerImages.php @@ -4,6 +4,7 @@ namespace App\Livewire\Server; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; use Livewire\Component; class DockerImages extends Component @@ -14,6 +15,10 @@ class DockerImages extends Component public array $parameters = []; + /** + * Images read from the server. Deletion checks container usage against this list. + */ + #[Locked] public array $images = []; public ?array $usage = null; diff --git a/tests/Feature/LockedDomainRowsAndDockerImagesTest.php b/tests/Feature/LockedDomainRowsAndDockerImagesTest.php new file mode 100644 index 0000000000..72f31cf87e --- /dev/null +++ b/tests/Feature/LockedDomainRowsAndDockerImagesTest.php @@ -0,0 +1,78 @@ +withoutVite(); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0, 'is_dns_validation_enabled' => false])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'ip' => '203.0.113.10']); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); +}); + +it('rejects a client update of the service domain rows', function () { + $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + )); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'service_id' => $service->id, + 'name' => 'web', + 'image' => 'nginx:alpine', + 'fqdn' => 'https://web.example.com', + ]); + + Livewire::test(Domains::class, ['service' => $service->fresh(['applications', 'server'])]) + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->all() === ['https://web.example.com']) + ->set('domainRows', [['url' => 'https://attacker.example.com']]); +})->throws(CannotUpdateLockedPropertyException::class); + +it('rejects a client update of the docker image list', function () { + $imageId = 'sha256:'.str_repeat('a', 64); + Process::fake([ + '*docker image ls*' => Process::result(output: json_encode(['Repository' => 'nginx', 'Tag' => '1.27', 'ID' => $imageId, 'Size' => '52.5MB', 'CreatedSince' => '3 weeks ago'])), + '*docker ps -a*' => Process::result(output: "{$imageId}#/web\n"), + '*docker system df*' => Process::result(output: ''), + '*docker image rm*' => Process::result(output: 'Deleted'), + ]); + + Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid]) + ->call('load') + ->assertSet('images.0.containers', ['web']) + ->set('images.0.containers', []); +})->throws(CannotUpdateLockedPropertyException::class); diff --git a/tests/Feature/SecretManagers/SecretManagerLinksTokenChangeSettingsTest.php b/tests/Feature/SecretManagers/SecretManagerLinksTokenChangeSettingsTest.php new file mode 100644 index 0000000000..87183bac3b --- /dev/null +++ b/tests/Feature/SecretManagers/SecretManagerLinksTokenChangeSettingsTest.php @@ -0,0 +1,93 @@ +withoutDefer(); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $vaultToken = fn (string $name) => IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vault', + 'name' => $name, + 'token' => 'hvs.token', + 'capabilities' => ['secrets'], + 'metadata' => ['base_url' => 'https://vault.internal:8200'], + ]); + $this->currentToken = $vaultToken('Vault current'); + $this->nextToken = $vaultToken('Vault next'); + + $this->application->secretManagerLink()->create([ + 'integration_token_id' => $this->currentToken->id, + 'settings' => ['mount' => 'secret', 'path' => 'my-app/production'], + ]); +}); + +test('switching to a token of the same provider rejects invalid settings and keeps the saved source', function () { + Livewire::test(SecretManagerLinks::class, ['resource' => $this->application]) + ->set([ + 'settings' => ['mount' => ['nested' => 'value'], 'path' => 'my-app/production'], + 'integration_token_uuid' => $this->nextToken->uuid, + ]) + ->assertHasErrors(['settings.mount']); + + $link = $this->application->secretManagerLink()->firstOrFail(); + expect($link->integration_token_id)->toBe($this->currentToken->id) + ->and($link->settings)->toBe(['mount' => 'secret', 'path' => 'my-app/production']); +}); + +test('switching to a token of the same provider saves only the provider settings fields', function () { + Livewire::test(SecretManagerLinks::class, ['resource' => $this->application]) + ->set([ + 'settings' => ['mount' => 'kv', 'path' => 'my-app/staging', 'unexpected' => 'value'], + 'integration_token_uuid' => $this->nextToken->uuid, + ]) + ->assertHasNoErrors() + ->assertDispatched('success'); + + $link = $this->application->secretManagerLink()->firstOrFail(); + expect($link->integration_token_id)->toBe($this->nextToken->id) + ->and($link->settings)->toBe(['mount' => 'kv', 'path' => 'my-app/staging']); +}); + +test('switching to a token of the same provider keeps working while the settings are still empty', function () { + $this->application->secretManagerLink()->update(['settings' => null]); + + Livewire::test(SecretManagerLinks::class, ['resource' => $this->application]) + ->set('integration_token_uuid', $this->nextToken->uuid) + ->assertHasNoErrors() + ->assertDispatched('success'); + + $link = $this->application->secretManagerLink()->firstOrFail(); + expect($link->integration_token_id)->toBe($this->nextToken->id) + ->and($link->settings)->toBeNull(); +});