From 5ff94dbd7a65d59f71728354931e3d2b1717aa48 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:03:22 +0100 Subject: [PATCH] fix(api): limit nested server data to team-wide tokens Co-Authored-By: Claude Opus 5.5 --- .changes/hid-the-secrets-of-the-server.md | 4 ++ .../Api/ApplicationsController.php | 8 ++- .../Controllers/Api/DatabasesController.php | 6 +- .../Controllers/Api/ServicesController.php | 10 +-- app/Http/Middleware/ApiSensitiveData.php | 10 ++- bootstrap/helpers/api.php | 13 +++- tests/Feature/Access/ScopedApiTokenTest.php | 71 +++++++++++++++++++ 7 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 .changes/hid-the-secrets-of-the-server.md diff --git a/.changes/hid-the-secrets-of-the-server.md b/.changes/hid-the-secrets-of-the-server.md new file mode 100644 index 0000000000..4a01617d68 --- /dev/null +++ b/.changes/hid-the-secrets-of-the-server.md @@ -0,0 +1,4 @@ +--- +type: security +--- +Limited the server data in the application, service, and database API responses to tokens that cover the whole team diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 8fd9586e4d..87d0cc2acc 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -150,7 +150,9 @@ class ApplicationsController extends Controller 'value', 'real_value', ]); - $this->exposeNestedServerSecrets($application); + if (canReadServerSecretsFromApi()) { + $this->exposeNestedServerSecrets($application); + } } else { $application->makeHidden([ 'private_key_id', @@ -272,7 +274,7 @@ class ApplicationsController extends Controller /** * Expose sensitive fields on eager-loaded nested Server + ServerSetting - * relations for callers with the `read:sensitive` or `root` token ability. + * relations for tokens that can read server secrets (`canReadServerSecretsFromApi()`). * Models hide these by default via $hidden; this re-exposes them per-request. */ private function exposeNestedServerSecrets($model): void @@ -350,7 +352,7 @@ class ApplicationsController extends Controller } $tagName = $request->query('tag'); - $applicationRelations = $request->attributes->get('can_read_sensitive', false) === true + $applicationRelations = canReadServerSecretsFromApi() ? ['destination.server.settings'] : []; diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 7d103d7d52..bb02bed6c7 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -253,6 +253,8 @@ class DatabasesController extends Controller 'mariadb_root_password', ...$database::CONFIGURATION_ATTRIBUTES, ]); + } + if (canReadServerSecretsFromApi()) { $this->exposeNestedServerSecrets($database); } else { $this->hideNestedServerSecrets($database, $loadNestedServerSecrets); @@ -301,7 +303,7 @@ class DatabasesController extends Controller /** * Expose sensitive fields on eager-loaded nested Server + ServerSetting - * relations for callers with the `read:sensitive` or `root` token ability. + * relations for tokens that can read server secrets (`canReadServerSecretsFromApi()`). */ private function exposeNestedServerSecrets(Model $model): void { @@ -363,7 +365,7 @@ class DatabasesController extends Controller } $projects = Project::where('team_id', $teamId)->get(); $databases = collect(); - $databaseRelations = $request->attributes->get('can_read_sensitive', false) === true + $databaseRelations = canReadServerSecretsFromApi() ? ['destination.server.settings'] : []; diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 88bad734c2..aa81a61c31 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -72,7 +72,9 @@ class ServicesController extends Controller 'value', 'real_value', ]); - $this->exposeNestedServerSecrets($service); + if (canReadServerSecretsFromApi()) { + $this->exposeNestedServerSecrets($service); + } } if ($service->is_shown_once ?? false) { @@ -84,7 +86,7 @@ class ServicesController extends Controller /** * Expose sensitive fields on eager-loaded nested Server + ServerSetting - * relations for callers with the `read:sensitive` or `root` token ability. + * relations for tokens that can read server secrets (`canReadServerSecretsFromApi()`). * Handles both single models and Eloquent Collections (the listing endpoint * passes a Collection of Services per project to removeSensitiveData()). */ @@ -270,7 +272,7 @@ class ServicesController extends Controller } $projects = Project::where('team_id', $teamId)->get(); $services = collect(); - $serviceRelations = $request->attributes->get('can_read_sensitive', false) === true + $serviceRelations = canReadServerSecretsFromApi() ? ['destination.server.settings'] : []; @@ -886,7 +888,7 @@ class ServicesController extends Controller $this->authorize('view', $service); $serviceRelations = ['applications', 'databases']; - if ($request->attributes->get('can_read_sensitive', false) === true) { + if (canReadServerSecretsFromApi()) { $serviceRelations[] = 'destination.server.settings'; } diff --git a/app/Http/Middleware/ApiSensitiveData.php b/app/Http/Middleware/ApiSensitiveData.php index 05d1b3e6e9..b45f1481dd 100644 --- a/app/Http/Middleware/ApiSensitiveData.php +++ b/app/Http/Middleware/ApiSensitiveData.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Services\Access\AccessResolver; use Closure; use Illuminate\Http\Request; @@ -15,9 +16,14 @@ class ApiSensitiveData // team_id 0 is the instance-admin team, so a falsy check must not exclude it $isAdmin = ! is_null($teamId) ? $request->user()->isAdminOfTeam((int) $teamId) : false; - // Allow access to sensitive data only if token has permission AND user is admin/owner + $canReadSensitive = $hasTokenPermission && $isAdmin; + + // Server secrets are team-level: a token limited to some projects, environments, or resources + // can read the secrets of those resources, but not of the servers they run on. $request->attributes->add([ - 'can_read_sensitive' => $hasTokenPermission && $isAdmin, + 'can_read_sensitive' => $canReadSensitive, + 'can_read_server_sensitive' => $canReadSensitive + && app(AccessResolver::class)->coversWholeTeam($request->user(), (int) $teamId), ]); return $next($request); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 4b37525ffd..63b37d07b3 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -120,7 +120,7 @@ function apiEnvironmentVariableDisplayValue(mixed $data): mixed /** * Remove the saved proxy configuration and validation logs from every * serialized server in an API payload, including nested ones such as - * `destination.server`, unless the token can read sensitive data. + * `destination.server`, unless the token can read sensitive server data. * The saved proxy configuration often contains DNS provider API tokens. * * @template T @@ -130,13 +130,22 @@ function apiEnvironmentVariableDisplayValue(mixed $data): mixed */ function removeServerProxySecretsFromApiPayload(mixed $payload): mixed { - if (request()->attributes->get('can_read_sensitive', false) === true) { + if (canReadServerSecretsFromApi()) { return $payload; } return removeServerProxySecrets($payload); } +/** + * True when the API token can read server secrets (Sentinel token, log drain keys, proxy configuration). + * A token limited to some projects, environments, or resources cannot, because servers are team-level. + */ +function canReadServerSecretsFromApi(): bool +{ + return request()->attributes->get('can_read_server_sensitive', false) === true; +} + /** * @template T * diff --git a/tests/Feature/Access/ScopedApiTokenTest.php b/tests/Feature/Access/ScopedApiTokenTest.php index fcc025619d..5dbf0ceb7f 100644 --- a/tests/Feature/Access/ScopedApiTokenTest.php +++ b/tests/Feature/Access/ScopedApiTokenTest.php @@ -11,12 +11,15 @@ use App\Models\PersonalAccessToken; use App\Models\Project; use App\Models\Scopes\VisibleThroughAccessGrants; use App\Models\Server; +use App\Models\Service; use App\Models\SharedEnvironmentVariable; +use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Once; +use Illuminate\Support\Str; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -196,6 +199,74 @@ describe('token scoped to one resource', function () { }); }); +describe('server secrets in resource responses', function () { + beforeEach(function () { + $this->server->settings->forceFill([ + 'sentinel_token' => 'sentinel-token-canary', + 'logdrain_axiom_api_key' => 'axiom-key-canary', + ])->save(); + $this->server->proxy->last_saved_proxy_configuration = 'proxy-config-canary'; + $this->server->validation_logs = 'validation-logs-canary'; + $this->server->save(); + + $this->shopService = Service::factory()->create([ + 'environment_id' => $this->shopProduction->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx\n", + ]); + $this->shopDatabase = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'shop-db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'db', + 'image' => 'postgres:17', + 'environment_id' => $this->shopProduction->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->endpoints = [ + '/api/v1/applications', + "/api/v1/applications/{$this->shopApi->uuid}", + '/api/v1/services', + "/api/v1/services/{$this->shopService->uuid}", + '/api/v1/databases', + "/api/v1/databases/{$this->shopDatabase->uuid}", + ]; + $this->canaries = ['sentinel-token-canary', 'axiom-key-canary', 'proxy-config-canary', 'validation-logs-canary']; + }); + + test('a project-scoped token with read:sensitive does not get the secrets of the server', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'read:sensitive'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + scopedTokenTestApi($token, 'GET', "/api/v1/servers/{$this->server->uuid}")->assertForbidden(); + + foreach ($this->endpoints as $endpoint) { + $body = scopedTokenTestApi($token, 'GET', $endpoint)->assertOk()->getContent(); + foreach ($this->canaries as $canary) { + expect($body)->not->toContain($canary); + } + } + + // The secrets of the resources in the scope stay readable. + expect(scopedTokenTestApi($token, 'GET', "/api/v1/databases/{$this->shopDatabase->uuid}")->json('postgres_password'))->toBe('password'); + }); + + test('a team-wide token with read:sensitive still gets the secrets of the server', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'read:sensitive'], null); + + foreach ($this->endpoints as $endpoint) { + $body = scopedTokenTestApi($token, 'GET', $endpoint)->assertOk()->getContent(); + foreach ($this->canaries as $canary) { + expect($body)->toContain($canary); + } + } + }); +}); + describe('delete ability', function () { test('a write token without delete cannot delete an application', function () { $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null);