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 2026-07-07 12:53:34 +02:00
parent e9bef8443b
commit 9a2c432c79
16 changed files with 239 additions and 13 deletions

View file

@ -166,6 +166,9 @@ public function environment_details(Request $request)
return response()->json(['message' => 'Environment not found.'], 404);
}
$environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']);
collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services'])
->flatMap(fn (string $relation) => $environment->{$relation})
->each(fn ($resource) => exposeSensitiveFields($resource));
return response()->json(serializeApiResponse($environment));
}

View file

@ -56,6 +56,7 @@ public function resources(Request $request)
}
$resources = $resources->flatten();
$resources = $resources->map(function ($resource) {
exposeSensitiveFields($resource);
$payload = $resource->toArray();
$payload['status'] = $resource->status;
$payload['type'] = $resource->type();

View file

@ -11,8 +11,9 @@ public function handle(Request $request, Closure $next)
{
$token = $request->user()->currentAccessToken();
$hasTokenPermission = $token->can('root') || $token->can('read:sensitive');
$teamId = (int) data_get($token, 'team_id');
$isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false;
$teamId = data_get($token, 'team_id');
// 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
$request->attributes->add([

View file

@ -1281,9 +1281,9 @@ private function legacyConfigurationHash(): string
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort());
} else {
$newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
$newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort());
}
return md5($newConfigHash);

View file

@ -105,7 +105,7 @@ public function isConfigurationChanged(bool $save = false)
$storages = $applicationStorages->merge($databaseStorages)->implode('updated_at');
$newConfigHash = $images.$domains.$images.$storages;
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {

View file

@ -134,7 +134,7 @@ public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {

View file

@ -133,7 +133,7 @@ public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {

View file

@ -134,7 +134,7 @@ public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {

View file

@ -138,7 +138,7 @@ public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {

View file

@ -143,7 +143,7 @@ public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {

View file

@ -140,7 +140,7 @@ public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {

View file

@ -182,7 +182,7 @@ public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {

View file

@ -138,7 +138,7 @@ public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {

View file

@ -6,6 +6,7 @@
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@ -87,6 +88,20 @@ function serializeApiResponse($data)
}
}
/**
* Re-expose a model's `$hidden` sensitive fields when the current API request
* carries the `read:sensitive` or `root` token ability (set by the
* ApiSensitiveData middleware).
*/
function exposeSensitiveFields(Model $model): Model
{
if (request()->attributes->get('can_read_sensitive', false) === true && filled($model->getHidden())) {
$model->makeVisible($model->getHidden());
}
return $model;
}
function sharedDataApplications()
{
return [

View file

@ -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();
});

View file

@ -1001,3 +1001,163 @@ function makeTeamUser(): array
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":');
});
});