mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
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:
co-authored by
Claude Opus 5.5
parent
132dd0e34c
commit
5ff94dbd7a
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user