mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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
This commit is contained in:
@@ -49,6 +49,7 @@ class Domains extends Component
|
||||
public ?int $pendingRedirectServiceApplicationId = null;
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
#[Locked]
|
||||
public array $domainRows = [];
|
||||
|
||||
public ?int $newServiceApplicationId = null;
|
||||
|
||||
@@ -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<string, array<int, string>>
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Service\Domains;
|
||||
use App\Livewire\Server\DockerImages;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
$this->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);
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\SecretManagerLinks;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
$this->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();
|
||||
});
|
||||
Reference in New Issue
Block a user