From 87dade45178a82131ea7b3c6087ebcba30f23a86 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:00:38 +0200 Subject: [PATCH] fix(secret-manager): add configurable request timeout for Infisical and Vault --- .../Api/IntegrationTokensController.php | 2 ++ .../Security/IntegrationTokenEditor.php | 2 ++ .../Security/IntegrationTokenForm.php | 2 ++ app/Models/IntegrationToken.php | 10 ++++++ app/Models/SecretManagerLink.php | 2 ++ app/Services/InfisicalService.php | 6 ++-- app/Services/IntegrationTokenValidator.php | 4 +++ app/Services/VaultService.php | 6 ++-- .../integration-token-editor.blade.php | 4 +++ .../security/integration-token-form.blade.php | 4 +++ .../SecretManagers/SecretManagerLinkTest.php | 19 +++++++++++ .../SecretManagerServicesTest.php | 15 +++++++++ .../Security/IntegrationTokenFormTest.php | 33 +++++++++++++++++++ 13 files changed, 103 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/IntegrationTokensController.php b/app/Http/Controllers/Api/IntegrationTokensController.php index 13a225a107..afd31bc699 100644 --- a/app/Http/Controllers/Api/IntegrationTokensController.php +++ b/app/Http/Controllers/Api/IntegrationTokensController.php @@ -64,9 +64,11 @@ class IntegrationTokensController extends Controller } elseif (($body['provider'] ?? null) === 'infisical') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } elseif (($body['provider'] ?? null) === 'vault') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } $validator = customApiValidator($body, $rules); diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 16a7f0ad9b..81db162a6f 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -64,11 +64,13 @@ class IntegrationTokenEditor extends Component if ($this->integrationToken->provider === 'infisical') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } if ($this->integrationToken->provider === 'vault') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } return $rules; diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 3cfbc75aa7..238c0a0c73 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -66,6 +66,7 @@ class IntegrationTokenForm extends Component if ($this->provider === 'infisical') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } if ($this->provider === 'doppler') { @@ -75,6 +76,7 @@ class IntegrationTokenForm extends Component if ($this->provider === 'vault') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } return $rules; diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 119c8d95df..437aa9b5b9 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -82,6 +82,16 @@ class IntegrationToken extends BaseModel return $this->provider === 'cloudflare' && data_get($this->metadata, 'proxied_dns') === true; } + /** + * Optional request timeout in seconds for self-hosted secret managers. + * + * @param array $metadata + */ + public static function requestTimeout(array $metadata): ?int + { + return filled($metadata['timeout'] ?? null) ? (int) $metadata['timeout'] : null; + } + public function dopplerTokenType(): ?string { if ($this->provider !== 'doppler') { diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php index 0e47cf63ed..20a8ff55d6 100644 --- a/app/Models/SecretManagerLink.php +++ b/app/Models/SecretManagerLink.php @@ -63,6 +63,7 @@ class SecretManagerLink extends BaseModel data_get($metadata, 'base_url', 'https://app.infisical.com'), (string) data_get($metadata, 'client_id'), $token->token, + IntegrationToken::requestTimeout($metadata), ))->fetchSecrets( (string) data_get($settings, 'project_id'), (string) data_get($settings, 'environment'), @@ -72,6 +73,7 @@ class SecretManagerLink extends BaseModel (string) data_get($metadata, 'base_url'), $token->token, data_get($metadata, 'namespace'), + IntegrationToken::requestTimeout($metadata), ))->fetchSecrets( (string) data_get($settings, 'mount', 'secret'), (string) data_get($settings, 'path'), diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php index 1d3e87e48c..3be8de7728 100644 --- a/app/Services/InfisicalService.php +++ b/app/Services/InfisicalService.php @@ -14,7 +14,7 @@ class InfisicalService /** @var array */ private array $httpClientOptions; - public function __construct(string $baseUrl, private string $clientId, private string $clientSecret) + public function __construct(string $baseUrl, private string $clientId, private string $clientSecret, private ?int $timeout = null) { $this->baseUrl = rtrim($baseUrl, '/'); Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); @@ -87,7 +87,7 @@ class InfisicalService { return Http::acceptJson() ->withOptions($this->httpClientOptions) - ->connectTimeout(5) - ->timeout(10); + ->connectTimeout($this->timeout ?? 5) + ->timeout($this->timeout ?? 10); } } diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php index 6033ce98f7..2872a83a91 100644 --- a/app/Services/IntegrationTokenValidator.php +++ b/app/Services/IntegrationTokenValidator.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Models\IntegrationToken; + /** * Validates an integration token against its provider API before it is saved. */ @@ -16,11 +18,13 @@ class IntegrationTokenValidator (string) data_get($metadata, 'base_url', 'https://app.infisical.com'), (string) data_get($metadata, 'client_id'), $token, + IntegrationToken::requestTimeout($metadata), ))->validate(), 'vault' => (new VaultService( (string) data_get($metadata, 'base_url'), $token, data_get($metadata, 'namespace'), + IntegrationToken::requestTimeout($metadata), ))->validate(), default => false, }; diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php index e41652cd54..852b749303 100644 --- a/app/Services/VaultService.php +++ b/app/Services/VaultService.php @@ -14,7 +14,7 @@ class VaultService /** @var array */ private array $httpClientOptions; - public function __construct(string $baseUrl, private string $token, private ?string $namespace = null) + public function __construct(string $baseUrl, private string $token, private ?string $namespace = null, private ?int $timeout = null) { $this->baseUrl = rtrim($baseUrl, '/'); Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); @@ -56,8 +56,8 @@ class VaultService $client = Http::withHeaders(['X-Vault-Token' => $this->token]) ->acceptJson() ->withOptions($this->httpClientOptions) - ->connectTimeout(5) - ->timeout(10); + ->connectTimeout($this->timeout ?? 5) + ->timeout($this->timeout ?? 10); if (filled($this->namespace)) { $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]); diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index ed7d108aed..5f9a01f374 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -20,12 +20,16 @@ + @elseif ($integrationToken->provider === 'vault')
+
@endif diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php index a085ebbe1a..a0c3566025 100644 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -17,6 +17,8 @@ + @else
@@ -34,6 +36,8 @@ placeholder="https://vault.example.com:8200" /> +
@endif diff --git a/tests/Feature/SecretManagers/SecretManagerLinkTest.php b/tests/Feature/SecretManagers/SecretManagerLinkTest.php index acaa33f7a7..c7074ceb8d 100644 --- a/tests/Feature/SecretManagers/SecretManagerLinkTest.php +++ b/tests/Feature/SecretManagers/SecretManagerLinkTest.php @@ -187,6 +187,25 @@ test('a vault link uses the base url and namespace from the token metadata', fun Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'team-a')); }); +test('an infisical link uses the request timeout from the token metadata', function () { + $timeouts = []; + Http::fake(function ($request, array $options) use (&$timeouts) { + $timeouts[] = [$options['connect_timeout'], $options['timeout']]; + + return str_contains($request->url(), '/auth/universal-auth/login') + ? Http::response(['accessToken' => 'short-lived-token']) + : Http::response(['secrets' => [['secretKey' => 'KEY', 'secretValue' => 'value']]]); + }); + + $link = createSecretManagerLink('infisical', + ['project_id' => 'project-1', 'environment' => 'prod'], + ['base_url' => 'https://example.com', 'client_id' => 'client-id', 'timeout' => '30'], + ); + + expect($link->fetchSecrets())->toBe(['KEY' => 'value']) + ->and($timeouts)->toBe([[30, 30], [30, 30]]); +}); + test('services resolve environment variables from their configured secret manager', function () { Http::fake([ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([ diff --git a/tests/Feature/SecretManagers/SecretManagerServicesTest.php b/tests/Feature/SecretManagers/SecretManagerServicesTest.php index f6a2c34ac3..7618c6cb96 100644 --- a/tests/Feature/SecretManagers/SecretManagerServicesTest.php +++ b/tests/Feature/SecretManagers/SecretManagerServicesTest.php @@ -125,6 +125,21 @@ describe('InfisicalService', function () { && $request['expandSecretReferences'] === 'true'); }); + test('uses a 5 second connect timeout and a 10 second request timeout by default', function (?int $timeout, array $expected) { + $timeouts = []; + Http::fake(function ($request, array $options) use (&$timeouts) { + $timeouts[] = [$options['connect_timeout'], $options['timeout']]; + + return Http::response(['accessToken' => 'short-lived-token']); + }); + + expect((new InfisicalService('https://example.com', 'client-id', 'client-secret', $timeout))->validate())->toBeTrue() + ->and($timeouts)->toBe([$expected]); + })->with([ + 'default' => [null, [5, 10]], + 'custom' => [60, [60, 60]], + ]); + test('throws when the login fails', function () { Http::fake([ 'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([ diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php index 41d18784ee..b46c6d1364 100644 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -469,3 +469,36 @@ test('saving a vault token with unchanged connection settings and a blank token ->and($savedToken->token)->toBe('hvs.stored-secret') ->and($savedToken->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); }); + +test('changing the vault request timeout keeps the stored token and validates with the new timeout', function () { + $timeouts = []; + Http::fake(function ($request, array $options) use (&$timeouts) { + $timeouts[] = $options['timeout']; + + return Http::response(['data' => []]); + }); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.timeout', '45') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $savedToken->refresh(); + expect($timeouts)->toBe([45]) + ->and($savedToken->token)->toBe('hvs.stored-secret') + ->and(IntegrationToken::requestTimeout($savedToken->metadata))->toBe(45); +}); + +test('a request timeout outside 1 to 300 seconds is rejected', function (string $timeout) { + Http::fake(); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.timeout', $timeout) + ->call('save') + ->assertHasErrors(['metadata.timeout']); + + Http::assertNothingSent(); +})->with(['0', '301', 'abc']);