fix(api): limit nested server data to team-wide tokens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-09 11:03:22 +01:00
co-authored by Claude Opus 5.5
parent 132dd0e34c
commit 5ff94dbd7a
7 changed files with 109 additions and 13 deletions
@@ -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
@@ -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']
: [];
@@ -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']
: [];
@@ -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';
}
+8 -2
View File
@@ -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);
+11 -2
View File
@@ -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
*
@@ -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);