From 12aba41d9a539cc7052ae69c57e6e63979fbedd6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 11:53:22 +0200 Subject: [PATCH] fix(api): gate service server secrets by sensitive scope Only eager load nested server settings for service responses when the API token has read:sensitive, preventing read-only tokens from exposing sentinel fields while preserving sensitive access. --- .../Controllers/Api/ServicesController.php | 18 ++++- .../Security/ApiSensitiveFieldsTest.php | 75 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index c3ced0e31..773fadd78 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -14,6 +14,7 @@ use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -50,9 +51,9 @@ private function removeSensitiveData($service) * Handles both single models and Eloquent Collections (the listing endpoint * passes a Collection of Services per project to removeSensitiveData()). */ - private function exposeNestedServerSecrets($model): void + private function exposeNestedServerSecrets(Model|Collection $model): void { - if ($model instanceof Collection || $model instanceof \Illuminate\Database\Eloquent\Collection) { + if ($model instanceof Collection) { foreach ($model as $item) { $this->exposeNestedServerSecrets($item); } @@ -215,8 +216,12 @@ public function services(Request $request) } $projects = Project::where('team_id', $teamId)->get(); $services = collect(); + $serviceRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; + foreach ($projects as $project) { - $services->push($project->services()->get()); + $services->push($project->services()->with($serviceRelations)->get()); } foreach ($services as $service) { $service = $this->removeSensitiveData($service); @@ -771,7 +776,12 @@ public function service_by_uuid(Request $request) $this->authorize('view', $service); - $service = $service->load(['applications', 'databases']); + $serviceRelations = ['applications', 'databases']; + if ($request->attributes->get('can_read_sensitive', false) === true) { + $serviceRelations[] = 'destination.server.settings'; + } + + $service = $service->load($serviceRelations); return response()->json($this->removeSensitiveData($service)); } diff --git a/tests/Feature/Security/ApiSensitiveFieldsTest.php b/tests/Feature/Security/ApiSensitiveFieldsTest.php index 591f227cb..0036b8546 100644 --- a/tests/Feature/Security/ApiSensitiveFieldsTest.php +++ b/tests/Feature/Security/ApiSensitiveFieldsTest.php @@ -5,6 +5,7 @@ use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; +use App\Models\Service; use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; @@ -207,3 +208,77 @@ function makeTeamUser(): array ->and($database->destination->server->relationLoaded('settings'))->toBeTrue(); }); }); + +describe('GET /api/v1/services sensitive field gating', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $destination = $this->server->standaloneDockers()->firstOrFail(); + $this->service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + }); + + test('read token does not leak service or nested server sensitive fields', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/services'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('"docker_compose_raw":') + ->and($body)->not->toContain('"sentinel_token":') + ->and($body)->not->toContain('"sentinel_custom_url":'); + }); + + test('read sensitive token sees service and nested server sensitive fields', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/services'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"docker_compose_raw":') + ->and($body)->toContain('"sentinel_token":') + ->and($body)->toContain('"sentinel_custom_url":'); + }); + + test('read sensitive service list eager loads nested server settings once', function () { + $secondServer = Server::factory()->create(['team_id' => $this->team->id]); + $secondDestination = $secondServer->standaloneDockers()->firstOrFail(); + + Service::factory()->create([ + 'server_id' => $secondServer->id, + 'destination_id' => $secondDestination->id, + 'destination_type' => $secondDestination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + $serverSettingsQueries = collect(); + + DB::listen(function ($query) use ($serverSettingsQueries) { + if (str_contains($query->sql, 'from "server_settings"')) { + $serverSettingsQueries->push($query->sql); + } + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/services'); + + $response->assertStatus(200); + + expect($serverSettingsQueries->contains(fn (string $sql) => str_contains($sql, '"server_settings"."server_id" in')))->toBeTrue(); + }); +});