From 8dc79f4ed68b98ae3f9f772725f0357224093b18 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:49:15 +0200 Subject: [PATCH] fix(api): expose nested server secrets for privileged tokens Add `exposeNestedServerSecrets()` to Applications, Databases, and Services controllers so that `read:sensitive`/`root` tokens see sentinel and logdrain fields on eager-loaded Server + ServerSetting relations. ServicesController handles both single models and Eloquent Collections (listing endpoint passes a Collection per project). Tests tightened to use JSON-key assertions (`"field":`) to avoid false positives from field names appearing in values. --- .../Api/ApplicationsController.php | 29 ++++++++++++++ .../Controllers/Api/DatabasesController.php | 28 ++++++++++++++ .../Controllers/Api/ServicesController.php | 38 +++++++++++++++++++ .../Security/ApiSensitiveFieldsTest.php | 27 ++++++++++--- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 9d9c5d33a..e189e6baa 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -57,6 +57,7 @@ private function removeSensitiveData($application) 'value', 'real_value', ]); + $this->exposeNestedServerSecrets($application); } else { $application->makeHidden([ 'private_key_id', @@ -66,6 +67,34 @@ private function removeSensitiveData($application) return serializeApiResponse($application); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Models hide these by default via $hidden; this re-exposes them per-request. + */ + private function exposeNestedServerSecrets($model): void + { + $server = $model->destination?->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all applications.', diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index acfc64355..d09c309f8 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -49,11 +49,39 @@ private function removeSensitiveData($database) 'mariadb_password', 'mariadb_root_password', ]); + $this->exposeNestedServerSecrets($database); } return serializeApiResponse($database); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + */ + private function exposeNestedServerSecrets($model): void + { + $server = $model->destination?->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all databases.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 4a04f499d..c3ced0e31 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -16,6 +16,7 @@ use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; use Symfony\Component\Yaml\Yaml; @@ -37,11 +38,48 @@ private function removeSensitiveData($service) 'value', 'real_value', ]); + $this->exposeNestedServerSecrets($service); } return serializeApiResponse($service); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Handles both single models and Eloquent Collections (the listing endpoint + * passes a Collection of Services per project to removeSensitiveData()). + */ + private function exposeNestedServerSecrets($model): void + { + if ($model instanceof Collection || $model instanceof \Illuminate\Database\Eloquent\Collection) { + foreach ($model as $item) { + $this->exposeNestedServerSecrets($item); + } + + return; + } + $server = $model->destination?->server ?? $model->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array { $errors = []; diff --git a/tests/Feature/Security/ApiSensitiveFieldsTest.php b/tests/Feature/Security/ApiSensitiveFieldsTest.php index b71881e3b..2275f7cc2 100644 --- a/tests/Feature/Security/ApiSensitiveFieldsTest.php +++ b/tests/Feature/Security/ApiSensitiveFieldsTest.php @@ -128,9 +128,23 @@ function makeTeamUser(): array $response->assertStatus(200); $body = $response->getContent(); - expect($body)->not->toContain('sentinel_token'); - expect($body)->not->toContain('sentinel_custom_url'); - expect($body)->not->toContain('logdrain_axiom_api_key'); + expect($body)->not->toContain('"sentinel_token":'); + expect($body)->not->toContain('"sentinel_custom_url":'); + expect($body)->not->toContain('"logdrain_axiom_api_key":'); + }); + + test('read-sensitive token sees nested sentinel_token via destination.server.settings', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/applications'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"sentinel_token":'); + expect($body)->toContain('"sentinel_custom_url":'); }); }); @@ -168,7 +182,7 @@ function makeTeamUser(): array expect($body)->not->toContain('external_db_url'); }); - test('read sensitive token sees postgres_password', function () { + test('read sensitive token sees postgres_password and nested sentinel_token', function () { $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); $response = $this->withHeaders([ @@ -178,7 +192,8 @@ function makeTeamUser(): array $response->assertStatus(200); $body = $response->getContent(); - expect($body)->toContain('postgres_password'); - expect($body)->toContain('internal_db_url'); + expect($body)->toContain('"postgres_password":'); + expect($body)->toContain('"internal_db_url":'); + expect($body)->toContain('"sentinel_token":'); }); });