From f53a883a60c4cf11e1f398d0d0c1077341951a72 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:10:10 +0200 Subject: [PATCH] fix(secrets): require the secret again when a secret manager connection changes Changing the Vault base URL or namespace, or the Infisical base URL or client ID, sent the stored secret to the new settings. The secret must now be entered again, and base URL changes are audit logged. --- .../Security/IntegrationTokenEditor.php | 62 ++++++++- .../integration-token-editor.blade.php | 6 +- .../Security/IntegrationTokenFormTest.php | 126 ++++++++++++++++++ 3 files changed, 190 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index d10ebb1c14..d8dc685666 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -58,12 +58,12 @@ class IntegrationTokenEditor extends Component ]; if ($this->integrationToken->provider === 'infisical') { - $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; } if ($this->integrationToken->provider === 'vault') { - $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; } @@ -92,9 +92,16 @@ class IntegrationTokenEditor extends Component $metadata['automatic_dns'] = false; } } + $storedMetadata = $this->integrationToken->metadata ?? []; + $changedConnectionFields = $this->changedConnectionFields($provider, $storedMetadata, $metadata); + if ($changedConnectionFields !== [] && blank($validated['newToken'])) { + $this->addError('newToken', $this->reenterSecretMessage($provider)); + + return; + } $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() !== collect($this->integrationToken->capabilities)->sort()->values()->all(); - $metadataChanged = $metadata != ($this->integrationToken->metadata ?? []); + $metadataChanged = $metadata != $storedMetadata; try { if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged) @@ -129,8 +136,20 @@ class IntegrationTokenEditor extends Component 'integration_token_name' => $this->integrationToken->name, 'provider' => $this->integrationToken->provider, 'rotated' => array_key_exists('token', $updates), + 'connection_changed_fields' => $changedConnectionFields, ]); + if (in_array('base_url', $changedConnectionFields, true)) { + auditLog('ui.integration_token.base_url_changed', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $this->integrationToken->uuid, + 'integration_token_name' => $this->integrationToken->name, + 'provider' => $provider, + 'previous_base_url' => data_get($storedMetadata, 'base_url'), + 'base_url' => data_get($metadata, 'base_url'), + ], 'warning'); + } + $this->dispatch( 'integration-token-updated', uuid: $this->integrationToken->uuid, @@ -143,6 +162,43 @@ class IntegrationTokenEditor extends Component } } + /** + * Metadata keys that decide where or how the stored secret is sent. + * Changing any of them must require the secret to be entered again. + * + * @return array + */ + private function connectionFields(string $provider): array + { + return match ($provider) { + 'vault' => ['base_url', 'namespace'], + 'infisical' => ['base_url', 'client_id'], + default => [], + }; + } + + /** + * @param array $storedMetadata + * @param array $metadata + * @return array + */ + private function changedConnectionFields(string $provider, array $storedMetadata, array $metadata): array + { + return array_values(array_filter( + $this->connectionFields($provider), + fn (string $field): bool => (string) data_get($storedMetadata, $field, '') !== (string) data_get($metadata, $field, ''), + )); + } + + private function reenterSecretMessage(string $provider): string + { + return match ($provider) { + 'infisical' => 'Enter the client secret again when you change the base URL or the client ID.', + 'vault' => 'Enter the token again when you change the base URL or the namespace.', + default => 'Enter the token again when you change the connection settings.', + }; + } + public function delete(string $password = ''): void { $this->authorize('delete', $this->integrationToken); diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index e99106418f..9a558939ba 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -7,7 +7,11 @@ + helper="{{ match ($integrationToken->provider) { + 'infisical' => 'Paste a replacement client secret to rotate this credential. Required when you change the base URL or the client ID.', + 'vault' => 'Paste a replacement token to rotate this credential. Required when you change the base URL or the namespace.', + default => 'Paste a replacement token to rotate this credential.', + } }}" /> diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php index 6853a33623..0c70c868b5 100644 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -311,3 +311,129 @@ test('new integration token form controls declare authorization matching server- expect($form) ->toMatch('/]*id="automatic-dns")(?=[^>]*canGate="create")(?=[^>]*:canResource="\\\\App\\\\Models\\\\IntegrationToken::class")[^>]*>/'); }); + +function vaultIntegrationToken(Team $team, array $metadata = ['base_url' => 'https://example.com:8200']): IntegrationToken +{ + return IntegrationToken::query()->create([ + 'team_id' => $team->id, + 'provider' => 'vault', + 'name' => 'Production Vault', + 'token' => 'hvs.stored-secret', + 'capabilities' => ['secrets'], + 'metadata' => $metadata, + ]); +} + +test('changing the vault base url without re-entering the token does not send the stored token to the new host', function () { + Http::fake(); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.base_url', 'https://example.net:8200') + ->set('newToken', '') + ->call('save') + ->assertHasErrors(['newToken']) + ->assertNotDispatched('success'); + + Http::assertNothingSent(); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'example.net')); + expect($savedToken->fresh()->metadata)->toBe(['base_url' => 'https://example.com:8200']) + ->and($savedToken->fresh()->token)->toBe('hvs.stored-secret'); +}); + +test('changing the vault namespace without re-entering the token is rejected', function () { + Http::fake(); + $savedToken = vaultIntegrationToken($this->team, ['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.namespace', 'team-b') + ->call('save') + ->assertHasErrors(['newToken']); + + Http::assertNothingSent(); + expect($savedToken->fresh()->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); +}); + +test('changing the infisical base url or client id without re-entering the client secret is rejected', function (string $field, string $value) { + Http::fake(); + $savedToken = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'infisical', + 'name' => 'Production Infisical', + 'token' => 'stored-client-secret', + 'capabilities' => ['secrets'], + 'metadata' => ['base_url' => 'https://example.com', 'client_id' => 'client-a'], + ]); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set("metadata.{$field}", $value) + ->call('save') + ->assertHasErrors(['newToken']); + + Http::assertNothingSent(); + expect($savedToken->fresh()->metadata)->toBe(['base_url' => 'https://example.com', 'client_id' => 'client-a']); +})->with([ + 'base url' => ['base_url', 'https://example.net'], + 'client id' => ['client_id', 'client-b'], +]); + +test('an admin changing the vault base url without the token gets a validation error', function () { + Http::fake(); + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $this->actingAs($admin); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.base_url', 'https://example.net:8200') + ->call('save') + ->assertHasErrors(['newToken']); + + Http::assertNothingSent(); +}); + +test('changing the vault base url with a re-entered token validates against the new host and is audited', function () { + Http::fake([ + 'https://example.net:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]), + ]); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.base_url', 'https://example.net:8200') + ->set('newToken', 'hvs.new-secret') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + Http::assertSent(fn ($request) => $request->url() === 'https://example.net:8200/v1/auth/token/lookup-self' + && $request->hasHeader('X-Vault-Token', 'hvs.new-secret')); + Http::assertNotSent(fn ($request) => $request->hasHeader('X-Vault-Token', 'hvs.stored-secret')); + + $savedToken->refresh(); + expect($savedToken->metadata)->toBe(['base_url' => 'https://example.net:8200']) + ->and($savedToken->token)->toBe('hvs.new-secret'); + + $event = AuditEvent::query()->where('event', 'ui.integration_token.base_url_changed')->first(); + expect($event)->not->toBeNull() + ->and($event->team_id)->toBe($this->team->id); + expect(json_encode($event->toArray()))->toContain('example.net') + ->not->toContain('hvs.new-secret'); +}); + +test('saving a vault token with unchanged connection settings and a blank token keeps working', function () { + Http::fake(); + $savedToken = vaultIntegrationToken($this->team, ['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('name', 'Renamed Vault') + ->set('newToken', '') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + Http::assertNothingSent(); + $savedToken->refresh(); + expect($savedToken->name)->toBe('Renamed Vault') + ->and($savedToken->token)->toBe('hvs.stored-secret') + ->and($savedToken->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); +});