fix(api): expose sensitive fields for privileged tokens

Privileged API tokens can read hidden resource fields in environment and
resource responses, including instance-admin team tokens with team_id 0.

Configuration hashes now include hidden environment variable values so
secret edits trigger restart detection.
This commit is contained in:
Andras Bacsai committed 2026-07-07 12:53:34 +02:00
1 parent e9bef8443b
commit 9a2c432c79
16 files changed
+239 -13

No files matched your search

@@ -0,0 +1,46 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Regression test: EnvironmentVariable hides `value` in serialized output, so the
* config hash must makeVisible('value') before json_encode — otherwise editing an
* env var value no longer flips the hash and no restart is suggested.
*/
it('detects environment variable value change in database config hash', function () {
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$destination = $server->standaloneDockers()->firstOrFail();
$database = StandalonePostgresql::create([
'name' => 'config-hash-db',
'postgres_user' => 'postgres',
'postgres_password' => encrypt('password'),
'postgres_db' => 'app',
'image' => 'postgres:16-alpine',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$variable = $database->environment_variables()->create([
'key' => 'APP_SECRET',
'value' => 'original-value',
]);
$database->isConfigurationChanged(save: true);
expect($database->refresh()->isConfigurationChanged())->toBeFalse();
$variable->update(['value' => 'changed-value']);
expect($database->refresh()->isConfigurationChanged())->toBeTrue();
});
@@ -1001,3 +1001,163 @@ describe('GET /api/v1/services sensitive field gating', function () {
expect($serverSettingsQueries->contains(fn (string $sql) => str_contains($sql, '"server_settings"."server_id" in')))->toBeTrue();
});
});
describe('GET /api/v1/resources 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->database = StandalonePostgresql::create([
'name' => 'resources-sensitive-db',
'postgres_user' => 'postgres',
'postgres_password' => encrypt('super-secret-db-password'),
'postgres_db' => 'app',
'image' => 'postgres:16-alpine',
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$this->application = Application::create([
'name' => 'resources-sensitive-app',
'git_repository' => 'https://github.com/test/test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'http_basic_auth_password' => 'basic-auth-secret',
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
test('read token does not leak database or application secrets', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/resources');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('postgres_password');
expect($body)->not->toContain('internal_db_url');
expect($body)->not->toContain('external_db_url');
expect($body)->not->toContain('http_basic_auth_password');
});
test('read sensitive token sees database and application secrets', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/resources');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('"postgres_password":');
expect($body)->toContain('"internal_db_url":');
expect($body)->toContain('"http_basic_auth_password":');
});
test('root token sees database secrets', function () {
$token = makeApiToken($this->user, $this->team, ['root']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/resources');
$response->assertStatus(200);
expect($response->getContent())->toContain('"postgres_password":');
});
});
describe('GET /api/v1/projects/{uuid}/{environment} 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->database = StandalonePostgresql::create([
'name' => 'environment-sensitive-db',
'postgres_user' => 'postgres',
'postgres_password' => encrypt('super-secret-db-password'),
'postgres_db' => 'app',
'image' => 'postgres:16-alpine',
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$this->application = Application::create([
'name' => 'environment-sensitive-app',
'git_repository' => 'https://github.com/test/test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'http_basic_auth_password' => 'basic-auth-secret',
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
test('read token does not leak database or application secrets', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/projects/{$this->project->uuid}/{$this->environment->name}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('postgres_password');
expect($body)->not->toContain('internal_db_url');
expect($body)->not->toContain('external_db_url');
expect($body)->not->toContain('http_basic_auth_password');
});
test('read sensitive token sees database and application secrets', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/projects/{$this->project->uuid}/{$this->environment->name}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('"postgres_password":');
expect($body)->toContain('"internal_db_url":');
expect($body)->toContain('"http_basic_auth_password":');
});
});
describe('instance admin team (id 0) sensitive gating', function () {
test('read sensitive token owned by team 0 sees sensitive fields', function () {
$team = Team::factory()->create(['id' => 0]);
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
session(['currentTeam' => $team]);
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings->forceFill([
'sentinel_token' => encrypt('team-zero-sentinel-token'),
])->saveQuietly();
$token = makeApiToken($user, $team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/servers');
$response->assertStatus(200);
expect($response->getContent())->toContain('"sentinel_token":');
});
});