fix(api): hide sensitive fields by default, expose via makeVisible for privileged tokens

Models now declare $hidden for passwords, tokens, db URLs, and compose
fields. API controllers flip from makeHidden-on-deny to makeVisible-on-
allow (can_read_sensitive=true), fixing fields that were never hidden.
Also adds missing fields (mysql/mariadb passwords, logdrain keys, etc.)
to privileged disclosure lists.

Tests added: Feature/Security/ApiSensitiveFieldsTest and
Unit/Models/SensitiveFieldsHiddenTest cover all affected models and
controllers.
This commit is contained in:
Andras Bacsai 2026-04-30 11:28:06 +02:00
parent 7ab16ad7b5
commit 8b7dbbafb2
19 changed files with 549 additions and 16 deletions

View file

@ -43,8 +43,8 @@ private function removeSensitiveData($application)
'resourceable_id',
'resourceable_type',
]);
if (request()->attributes->get('can_read_sensitive', false) === false) {
$application->makeHidden([
if (request()->attributes->get('can_read_sensitive', false) === true) {
$application->makeVisible([
'custom_labels',
'dockerfile',
'docker_compose',
@ -53,10 +53,13 @@ private function removeSensitiveData($application)
'manual_webhook_secret_gitea',
'manual_webhook_secret_github',
'manual_webhook_secret_gitlab',
'private_key_id',
'http_basic_auth_password',
'value',
'real_value',
'http_basic_auth_password',
]);
} else {
$application->makeHidden([
'private_key_id',
]);
}

View file

@ -33,16 +33,21 @@ private function removeSensitiveData($database)
'id',
'laravel_through_key',
]);
if (request()->attributes->get('can_read_sensitive', false) === false) {
$database->makeHidden([
if (request()->attributes->get('can_read_sensitive', false) === true) {
$database->makeVisible([
'internal_db_url',
'external_db_url',
'init_scripts',
'postgres_password',
'dragonfly_password',
'redis_password',
'mongo_initdb_root_password',
'keydb_password',
'clickhouse_admin_password',
'mysql_password',
'mysql_root_password',
'mariadb_password',
'mariadb_root_password',
]);
}
@ -2957,8 +2962,8 @@ private function removeSensitiveEnvData($env)
'resourceable_id',
'resourceable_type',
]);
if (request()->attributes->get('can_read_sensitive', false) === false) {
$env->makeHidden([
if (request()->attributes->get('can_read_sensitive', false) === true) {
$env->makeVisible([
'value',
'real_value',
]);

View file

@ -22,9 +22,14 @@ class ServersController extends Controller
{
private function removeSensitiveDataFromSettings($settings)
{
if (request()->attributes->get('can_read_sensitive', false) === false) {
$settings = $settings->makeHidden([
if (request()->attributes->get('can_read_sensitive', false) === true) {
$settings = $settings->makeVisible([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
@ -36,8 +41,11 @@ private function removeSensitiveData($server)
$server->makeHidden([
'id',
]);
if (request()->attributes->get('can_read_sensitive', false) === false) {
// Do nothing
if (request()->attributes->get('can_read_sensitive', false) === true) {
$server->makeVisible([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
}
return serializeApiResponse($server);

View file

@ -30,8 +30,8 @@ private function removeSensitiveData($service)
'resourceable_id',
'resourceable_type',
]);
if (request()->attributes->get('can_read_sensitive', false) === false) {
$service->makeHidden([
if (request()->attributes->get('can_read_sensitive', false) === true) {
$service->makeVisible([
'docker_compose_raw',
'docker_compose',
'value',

View file

@ -215,6 +215,24 @@ class Application extends BaseModel
protected $appends = ['server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability. Internal serializers (deployment
* job, compose generation) must makeVisible explicitly before toArray().
*/
protected $hidden = [
'http_basic_auth_password',
'manual_webhook_secret_github',
'manual_webhook_secret_gitlab',
'manual_webhook_secret_bitbucket',
'manual_webhook_secret_gitea',
'dockerfile',
'docker_compose',
'docker_compose_raw',
'custom_labels',
];
protected function casts(): array
{
return [

View file

@ -76,9 +76,19 @@ class EnvironmentVariable extends BaseModel
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'value',
'real_value',
];
protected static function booted()
{
static::created(function (EnvironmentVariable $environment_variable) {
static::created(function (ModelsEnvironmentVariable $environment_variable) {
if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) {
$found = ModelsEnvironmentVariable::where('key', $environment_variable->key)
->where('resourceable_type', Application::class)
@ -109,7 +119,7 @@ protected static function booted()
]);
});
static::saving(function (EnvironmentVariable $environmentVariable) {
static::saving(function (ModelsEnvironmentVariable $environmentVariable) {
$environmentVariable->updateIsShared();
});
}

View file

@ -254,6 +254,16 @@ public static function flushIdentityMap(): void
'force_disabled' => 'boolean',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
];
protected $schemalessAttributes = [
'proxy',
];

View file

@ -113,6 +113,20 @@ class ServerSetting extends Model
'connection_timeout' => 'integer',
];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
];
protected static function booted()
{
static::creating(function ($setting) {

View file

@ -67,6 +67,17 @@ class Service extends BaseModel
protected $appends = ['server_status', 'status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability. Internal compose generators
* must makeVisible explicitly before toArray().
*/
protected $hidden = [
'docker_compose',
'docker_compose_raw',
];
protected static function booted()
{
static::creating(function ($service) {

View file

@ -48,6 +48,17 @@ class StandaloneClickhouse extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'clickhouse_admin_password',
'internal_db_url',
'external_db_url',
];
protected $casts = [
'clickhouse_admin_password' => 'encrypted',
'public_port_timeout' => 'integer',

View file

@ -47,6 +47,17 @@ class StandaloneDragonfly extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'dragonfly_password',
'internal_db_url',
'external_db_url',
];
protected $casts = [
'dragonfly_password' => 'encrypted',
'public_port_timeout' => 'integer',

View file

@ -48,6 +48,17 @@ class StandaloneKeydb extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'keydb_password',
'internal_db_url',
'external_db_url',
];
protected $casts = [
'keydb_password' => 'encrypted',
'public_port_timeout' => 'integer',

View file

@ -51,6 +51,18 @@ class StandaloneMariadb extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'mariadb_password',
'mariadb_root_password',
'internal_db_url',
'external_db_url',
];
protected $casts = [
'mariadb_password' => 'encrypted',
'public_port_timeout' => 'integer',

View file

@ -51,6 +51,17 @@ class StandaloneMongodb extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'mongo_initdb_root_password',
'internal_db_url',
'external_db_url',
];
protected $casts = [
'public_port_timeout' => 'integer',
'restart_count' => 'integer',

View file

@ -52,6 +52,18 @@ class StandaloneMysql extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'mysql_password',
'mysql_root_password',
'internal_db_url',
'external_db_url',
];
protected $casts = [
'mysql_password' => 'encrypted',
'mysql_root_password' => 'encrypted',

View file

@ -54,6 +54,18 @@ class StandalonePostgresql extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'postgres_password',
'init_scripts',
'internal_db_url',
'external_db_url',
];
protected $casts = [
'init_scripts' => 'array',
'postgres_password' => 'encrypted',

View file

@ -47,6 +47,17 @@ class StandaloneRedis extends BaseModel
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'redis_password',
'internal_db_url',
'external_db_url',
];
protected $casts = [
'public_port_timeout' => 'integer',
'restart_count' => 'integer',

View file

@ -0,0 +1,184 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function makeApiToken(User $user, Team $team, array $abilities): string
{
session(['currentTeam' => $team]);
$token = $user->createToken('sensitive-test', $abilities);
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
'team_id' => $team->id,
]);
return $token->plainTextToken;
}
function makeTeamUser(): array
{
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
session(['currentTeam' => $team]);
return [$team, $user];
}
beforeEach(function () {
InstanceSettings::query()->delete();
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
[$this->team, $this->user] = makeTeamUser();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings->forceFill([
'sentinel_token' => encrypt('super-secret-sentinel-token'),
'sentinel_custom_url' => 'https://sentinel.internal',
'logdrain_axiom_api_key' => encrypt('axiom-key-secret'),
'logdrain_newrelic_license_key' => encrypt('newrelic-key-secret'),
'logdrain_custom_config' => 'custom-config-data',
'logdrain_custom_config_parser' => 'custom-parser-data',
])->saveQuietly();
});
describe('GET /api/v1/servers sensitive field gating', function () {
test('read token does not leak sentinel or logdrain fields', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/servers');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('sentinel_token');
expect($body)->not->toContain('sentinel_custom_url');
expect($body)->not->toContain('logdrain_axiom_api_key');
expect($body)->not->toContain('logdrain_newrelic_license_key');
expect($body)->not->toContain('logdrain_custom_config');
});
test('read sensitive token sees sentinel and logdrain fields', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/servers');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('sentinel_token');
expect($body)->toContain('sentinel_custom_url');
expect($body)->toContain('logdrain_axiom_api_key');
});
test('root token sees sentinel and logdrain fields', function () {
$token = makeApiToken($this->user, $this->team, ['root']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/servers');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('sentinel_token');
});
});
describe('GET /api/v1/applications nested-relation scrubbing', 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->application = Application::create([
'name' => 'sensitive-test-app',
'git_repository' => 'https://github.com/test/test',
'git_branch' => 'main',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
test('read token does not leak sentinel_token via destination.server.settings', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/applications');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('sentinel_token');
expect($body)->not->toContain('sentinel_custom_url');
expect($body)->not->toContain('logdrain_axiom_api_key');
});
});
describe('GET /api/v1/databases 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' => 'sensitive-db',
'description' => 'test',
'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(),
]);
});
test('read token does not leak postgres_password or db urls', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/databases');
$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');
});
test('read sensitive token sees postgres_password', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/databases');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('postgres_password');
expect($body)->toContain('internal_db_url');
});
});

View file

@ -0,0 +1,179 @@
<?php
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
describe('Sensitive model fields are hidden by default', function () {
test('ServerSetting hides sentinel and logdrain secrets', function () {
$hidden = (new ServerSetting)->getHidden();
expect($hidden)->toContain(
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
);
});
test('Server hides logdrain api keys', function () {
$hidden = (new Server)->getHidden();
expect($hidden)->toContain(
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
);
});
test('Application hides webhook secrets, dockerfile, compose, and labels', function () {
$hidden = (new Application)->getHidden();
expect($hidden)->toContain(
'http_basic_auth_password',
'manual_webhook_secret_github',
'manual_webhook_secret_gitlab',
'manual_webhook_secret_bitbucket',
'manual_webhook_secret_gitea',
'dockerfile',
'docker_compose',
'docker_compose_raw',
'custom_labels',
);
});
test('EnvironmentVariable hides value and real_value', function () {
$hidden = (new EnvironmentVariable)->getHidden();
expect($hidden)->toContain('value', 'real_value');
});
test('Service hides docker_compose and docker_compose_raw', function () {
$hidden = (new Service)->getHidden();
expect($hidden)->toContain('docker_compose', 'docker_compose_raw');
});
test('StandalonePostgresql hides password, init_scripts, db urls', function () {
$hidden = (new StandalonePostgresql)->getHidden();
expect($hidden)->toContain(
'postgres_password',
'init_scripts',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneMysql hides passwords and db urls', function () {
$hidden = (new StandaloneMysql)->getHidden();
expect($hidden)->toContain(
'mysql_password',
'mysql_root_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneMariadb hides passwords and db urls', function () {
$hidden = (new StandaloneMariadb)->getHidden();
expect($hidden)->toContain(
'mariadb_password',
'mariadb_root_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneMongodb hides root password and db urls', function () {
$hidden = (new StandaloneMongodb)->getHidden();
expect($hidden)->toContain(
'mongo_initdb_root_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneRedis hides password and db urls', function () {
$hidden = (new StandaloneRedis)->getHidden();
expect($hidden)->toContain(
'redis_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneClickhouse hides password and db urls', function () {
$hidden = (new StandaloneClickhouse)->getHidden();
expect($hidden)->toContain(
'clickhouse_admin_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneKeydb hides password and db urls', function () {
$hidden = (new StandaloneKeydb)->getHidden();
expect($hidden)->toContain(
'keydb_password',
'internal_db_url',
'external_db_url',
);
});
test('StandaloneDragonfly hides password and db urls', function () {
$hidden = (new StandaloneDragonfly)->getHidden();
expect($hidden)->toContain(
'dragonfly_password',
'internal_db_url',
'external_db_url',
);
});
});
describe('Sensitive fields are absent from toArray() by default', function () {
test('ServerSetting::toArray() excludes sentinel_custom_url by default', function () {
$setting = new ServerSetting;
$setting->setRawAttributes([
'server_id' => 1,
'sentinel_custom_url' => 'https://secret.example.com',
'wildcard_domain' => 'public.example.com',
], sync: true);
$array = $setting->toArray();
expect($array)->not->toHaveKey('sentinel_custom_url');
expect($array)->toHaveKey('wildcard_domain');
});
test('ServerSetting::toArray() includes sentinel_custom_url after makeVisible', function () {
$setting = new ServerSetting;
$setting->setRawAttributes([
'server_id' => 1,
'sentinel_custom_url' => 'https://secret.example.com',
], sync: true);
$setting->makeVisible(['sentinel_custom_url']);
$array = $setting->toArray();
expect($array['sentinel_custom_url'])->toBe('https://secret.example.com');
});
});