fix(api): hide sensitive fields by default

Add model-level hidden fields for secrets, tokens, keys, notification
credentials, deployment logs, and environment values. Allow explicit
read:sensitive API access to reveal gated private keys and deployment logs,
and cover the behavior with feature and unit tests.
This commit is contained in:
Andras Bacsai 2026-05-11 21:18:07 +02:00
parent f40bb80f9d
commit 81a3bb0f07
20 changed files with 335 additions and 2 deletions

View file

@ -25,6 +25,10 @@ private function removeSensitiveData($deployment)
$deployment->makeHidden([ $deployment->makeHidden([
'logs', 'logs',
]); ]);
} else {
$deployment->makeVisible([
'logs',
]);
} }
return serializeApiResponse($deployment); return serializeApiResponse($deployment);
@ -699,6 +703,9 @@ public function get_application_deployments(Request $request)
$this->authorize('view', $application); $this->authorize('view', $application);
$deployments = $application->deployments($skip, $take); $deployments = $application->deployments($skip, $take);
if ($request->attributes->get('can_read_sensitive', false) === true) {
$deployments['deployments']->each->makeVisible(['logs']);
}
return response()->json($deployments); return response()->json($deployments);
} }

View file

@ -16,6 +16,10 @@ private function removeSensitiveData($team)
$team->makeHidden([ $team->makeHidden([
'private_key', 'private_key',
]); ]);
} else {
$team->makeVisible([
'private_key',
]);
} }
return serializeApiResponse($team); return serializeApiResponse($team);

View file

@ -68,6 +68,10 @@ class ApplicationDeploymentQueue extends Model
'finished_at', 'finished_at',
]; ];
protected $hidden = [
'logs',
];
protected $casts = [ protected $casts = [
'pull_request_id' => 'integer', 'pull_request_id' => 'integer',
'finished_at' => 'datetime', 'finished_at' => 'datetime',

View file

@ -12,6 +12,10 @@ class CloudInitScript extends Model
'script', 'script',
]; ];
protected $hidden = [
'script',
];
protected function casts(): array protected function casts(): array
{ {
return [ return [

View file

@ -11,6 +11,10 @@ class CloudProviderToken extends BaseModel
'name', 'name',
]; ];
protected $hidden = [
'token',
];
protected $casts = [ protected $casts = [
'token' => 'encrypted', 'token' => 'encrypted',
]; ];

View file

@ -34,6 +34,10 @@ class DiscordNotificationSettings extends Model
'discord_ping_enabled', 'discord_ping_enabled',
]; ];
protected $hidden = [
'discord_webhook_url',
];
protected $casts = [ protected $casts = [
'discord_enabled' => 'boolean', 'discord_enabled' => 'boolean',
'discord_webhook_url' => 'encrypted', 'discord_webhook_url' => 'encrypted',

View file

@ -43,6 +43,16 @@ class EmailNotificationSettings extends Model
'traefik_outdated_email_notifications', 'traefik_outdated_email_notifications',
]; ];
protected $hidden = [
'smtp_from_address',
'smtp_from_name',
'smtp_recipients',
'smtp_host',
'smtp_username',
'smtp_password',
'resend_api_key',
];
protected $casts = [ protected $casts = [
'smtp_enabled' => 'boolean', 'smtp_enabled' => 'boolean',
'smtp_from_address' => 'encrypted', 'smtp_from_address' => 'encrypted',

View file

@ -48,6 +48,17 @@ class InstanceSettings extends Model
'is_mcp_server_enabled', 'is_mcp_server_enabled',
]; ];
protected $hidden = [
'smtp_from_address',
'smtp_from_name',
'smtp_recipients',
'smtp_host',
'smtp_username',
'smtp_password',
'resend_api_key',
'sentinel_token',
];
protected $casts = [ protected $casts = [
'smtp_enabled' => 'boolean', 'smtp_enabled' => 'boolean',
'smtp_from_address' => 'encrypted', 'smtp_from_address' => 'encrypted',

View file

@ -13,6 +13,10 @@ class OauthSetting extends Model
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled'];
protected $hidden = [
'client_secret',
];
protected function clientSecret(): Attribute protected function clientSecret(): Attribute
{ {
return Attribute::make( return Attribute::make(

View file

@ -41,6 +41,10 @@ class PrivateKey extends BaseModel
'fingerprint', 'fingerprint',
]; ];
protected $hidden = [
'private_key',
];
protected $casts = [ protected $casts = [
'private_key' => 'encrypted', 'private_key' => 'encrypted',
]; ];

View file

@ -34,6 +34,11 @@ class PushoverNotificationSettings extends Model
'traefik_outdated_pushover_notifications', 'traefik_outdated_pushover_notifications',
]; ];
protected $hidden = [
'pushover_user_key',
'pushover_api_token',
];
protected $casts = [ protected $casts = [
'pushover_enabled' => 'boolean', 'pushover_enabled' => 'boolean',
'pushover_user_key' => 'encrypted', 'pushover_user_key' => 'encrypted',

View file

@ -26,6 +26,11 @@ class S3Storage extends BaseModel
'unusable_email_sent', 'unusable_email_sent',
]; ];
protected $hidden = [
'key',
'secret',
];
protected $casts = [ protected $casts = [
'is_usable' => 'boolean', 'is_usable' => 'boolean',
'key' => 'encrypted', 'key' => 'encrypted',

View file

@ -30,6 +30,10 @@ class SharedEnvironmentVariable extends Model
'version', 'version',
]; ];
protected $hidden = [
'value',
];
protected $casts = [ protected $casts = [
'key' => 'string', 'key' => 'string',
'value' => 'encrypted', 'value' => 'encrypted',

View file

@ -33,6 +33,10 @@ class SlackNotificationSettings extends Model
'traefik_outdated_slack_notifications', 'traefik_outdated_slack_notifications',
]; ];
protected $hidden = [
'slack_webhook_url',
];
protected $casts = [ protected $casts = [
'slack_enabled' => 'boolean', 'slack_enabled' => 'boolean',
'slack_webhook_url' => 'encrypted', 'slack_webhook_url' => 'encrypted',

View file

@ -20,6 +20,10 @@ class SslCertificate extends Model
'is_ca_certificate', 'is_ca_certificate',
]; ];
protected $hidden = [
'ssl_private_key',
];
protected $casts = [ protected $casts = [
'ssl_certificate' => 'encrypted', 'ssl_certificate' => 'encrypted',
'ssl_private_key' => 'encrypted', 'ssl_private_key' => 'encrypted',

View file

@ -49,6 +49,25 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_traefik_outdated_thread_id', 'telegram_notifications_traefik_outdated_thread_id',
]; ];
protected $hidden = [
'telegram_token',
'telegram_chat_id',
'telegram_notifications_deployment_success_thread_id',
'telegram_notifications_deployment_failure_thread_id',
'telegram_notifications_status_change_thread_id',
'telegram_notifications_backup_success_thread_id',
'telegram_notifications_backup_failure_thread_id',
'telegram_notifications_scheduled_task_success_thread_id',
'telegram_notifications_scheduled_task_failure_thread_id',
'telegram_notifications_docker_cleanup_success_thread_id',
'telegram_notifications_docker_cleanup_failure_thread_id',
'telegram_notifications_server_disk_usage_thread_id',
'telegram_notifications_server_reachable_thread_id',
'telegram_notifications_server_unreachable_thread_id',
'telegram_notifications_server_patch_thread_id',
'telegram_notifications_traefik_outdated_thread_id',
];
protected $casts = [ protected $casts = [
'telegram_enabled' => 'boolean', 'telegram_enabled' => 'boolean',
'telegram_token' => 'encrypted', 'telegram_token' => 'encrypted',
@ -75,7 +94,8 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_backup_failure_thread_id' => 'encrypted', 'telegram_notifications_backup_failure_thread_id' => 'encrypted',
'telegram_notifications_scheduled_task_success_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_success_thread_id' => 'encrypted',
'telegram_notifications_scheduled_task_failure_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_failure_thread_id' => 'encrypted',
'telegram_notifications_docker_cleanup_thread_id' => 'encrypted', 'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted',
'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted',
'telegram_notifications_server_disk_usage_thread_id' => 'encrypted', 'telegram_notifications_server_disk_usage_thread_id' => 'encrypted',
'telegram_notifications_server_reachable_thread_id' => 'encrypted', 'telegram_notifications_server_reachable_thread_id' => 'encrypted',
'telegram_notifications_server_unreachable_thread_id' => 'encrypted', 'telegram_notifications_server_unreachable_thread_id' => 'encrypted',

View file

@ -33,6 +33,10 @@ class WebhookNotificationSettings extends Model
'traefik_outdated_webhook_notifications', 'traefik_outdated_webhook_notifications',
]; ];
protected $hidden = [
'webhook_url',
];
protected function casts(): array protected function casts(): array
{ {
return [ return [

View file

@ -2,7 +2,7 @@
<h1>Notifications</h1> <h1>Notifications</h1>
<div class="subtitle">Get notified about your infrastructure.</div> <div class="subtitle">Get notified about your infrastructure.</div>
<div class="navbar-main"> <div class="navbar-main">
<nav class="flex items-center gap-3.5 min-h-10"> <nav class="flex items-center gap-6 min-h-10">
<a class="{{ request()->routeIs('notifications.email') ? 'dark:text-white' : '' }}" {{ wireNavigate() }} <a class="{{ request()->routeIs('notifications.email') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('notifications.email') }}"> href="{{ route('notifications.email') }}">
<button>Email</button> <button>Email</button>

View file

@ -1,8 +1,10 @@
<?php <?php
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment; use App\Models\Environment;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
@ -101,6 +103,99 @@ function makeTeamUser(): array
}); });
}); });
describe('GET /api/v1/security/keys sensitive field gating', function () {
beforeEach(function () {
PrivateKey::withoutEvents(function () {
PrivateKey::forceCreate([
'uuid' => 'private-key-sensitive-test',
'name' => 'Sensitive key',
'description' => 'test',
'private_key' => 'super-secret-private-key',
'is_git_related' => false,
'team_id' => $this->team->id,
]);
});
});
test('read token does not leak private key material', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/security/keys/private-key-sensitive-test');
$response->assertStatus(200);
expect($response->getContent())->not->toContain('"private_key":');
});
test('read sensitive token sees private key material', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/security/keys/private-key-sensitive-test');
$response->assertStatus(200);
expect($response->getContent())->toContain('"private_key":');
});
});
describe('GET /api/v1/deployments 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->application = Application::create([
'name' => 'deployment-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(),
]);
ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'deployment-sensitive-test',
'pull_request_id' => 0,
'status' => 'in_progress',
'logs' => '[{"output":"super-secret-deployment-log"}]',
'server_id' => $this->server->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
]);
});
test('read token does not leak deployment logs', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/deployments/deployment-sensitive-test');
$response->assertStatus(200);
expect($response->getContent())->not->toContain('"logs":');
});
test('read sensitive token sees deployment logs', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/deployments/deployment-sensitive-test');
$response->assertStatus(200);
expect($response->getContent())->toContain('"logs":');
});
});
describe('GET /api/v1/applications nested-relation scrubbing', function () { describe('GET /api/v1/applications nested-relation scrubbing', function () {
beforeEach(function () { beforeEach(function () {
$this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->project = Project::factory()->create(['team_id' => $this->team->id]);

View file

@ -1,10 +1,23 @@
<?php <?php
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\DiscordNotificationSettings;
use App\Models\EmailNotificationSettings;
use App\Models\EnvironmentVariable; use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use App\Models\PrivateKey;
use App\Models\PushoverNotificationSettings;
use App\Models\S3Storage;
use App\Models\Server; use App\Models\Server;
use App\Models\ServerSetting; use App\Models\ServerSetting;
use App\Models\Service; use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\SlackNotificationSettings;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse; use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
@ -13,6 +26,8 @@
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Models\TelegramNotificationSettings;
use App\Models\WebhookNotificationSettings;
describe('Sensitive model fields are hidden by default', function () { describe('Sensitive model fields are hidden by default', function () {
test('ServerSetting hides sentinel and logdrain secrets', function () { test('ServerSetting hides sentinel and logdrain secrets', function () {
@ -61,12 +76,133 @@
expect($hidden)->toContain('value', 'real_value'); expect($hidden)->toContain('value', 'real_value');
}); });
test('SharedEnvironmentVariable hides value', function () {
$hidden = (new SharedEnvironmentVariable)->getHidden();
expect($hidden)->toContain('value');
});
test('Service hides docker_compose and docker_compose_raw', function () { test('Service hides docker_compose and docker_compose_raw', function () {
$hidden = (new Service)->getHidden(); $hidden = (new Service)->getHidden();
expect($hidden)->toContain('docker_compose', 'docker_compose_raw'); expect($hidden)->toContain('docker_compose', 'docker_compose_raw');
}); });
test('ApplicationDeploymentQueue hides logs', function () {
$hidden = (new ApplicationDeploymentQueue)->getHidden();
expect($hidden)->toContain('logs');
});
test('PrivateKey hides private key material', function () {
$hidden = (new PrivateKey)->getHidden();
expect($hidden)->toContain('private_key');
});
test('CloudProviderToken hides provider token', function () {
$hidden = (new CloudProviderToken)->getHidden();
expect($hidden)->toContain('token');
});
test('CloudInitScript hides script content', function () {
$hidden = (new CloudInitScript)->getHidden();
expect($hidden)->toContain('script');
});
test('S3Storage hides credentials', function () {
$hidden = (new S3Storage)->getHidden();
expect($hidden)->toContain('key', 'secret');
});
test('OauthSetting hides client secret', function () {
$hidden = (new OauthSetting)->getHidden();
expect($hidden)->toContain('client_secret');
});
test('SslCertificate hides private key', function () {
$hidden = (new SslCertificate)->getHidden();
expect($hidden)->toContain('ssl_private_key');
});
test('EmailNotificationSettings hides SMTP and resend secrets', function () {
$hidden = (new EmailNotificationSettings)->getHidden();
expect($hidden)->toContain(
'smtp_from_address',
'smtp_from_name',
'smtp_recipients',
'smtp_host',
'smtp_username',
'smtp_password',
'resend_api_key',
);
});
test('InstanceSettings hides instance notification secrets', function () {
$hidden = (new InstanceSettings)->getHidden();
expect($hidden)->toContain(
'smtp_from_address',
'smtp_from_name',
'smtp_recipients',
'smtp_host',
'smtp_username',
'smtp_password',
'resend_api_key',
'sentinel_token',
);
});
test('Webhook-style notification settings hide delivery endpoints', function () {
expect((new DiscordNotificationSettings)->getHidden())->toContain('discord_webhook_url');
expect((new SlackNotificationSettings)->getHidden())->toContain('slack_webhook_url');
expect((new WebhookNotificationSettings)->getHidden())->toContain('webhook_url');
});
test('PushoverNotificationSettings hides credentials', function () {
$hidden = (new PushoverNotificationSettings)->getHidden();
expect($hidden)->toContain('pushover_user_key', 'pushover_api_token');
});
test('TelegramNotificationSettings hides bot, chat, and thread identifiers', function () {
$hidden = (new TelegramNotificationSettings)->getHidden();
expect($hidden)->toContain(
'telegram_token',
'telegram_chat_id',
'telegram_notifications_deployment_success_thread_id',
'telegram_notifications_deployment_failure_thread_id',
'telegram_notifications_status_change_thread_id',
'telegram_notifications_backup_success_thread_id',
'telegram_notifications_backup_failure_thread_id',
'telegram_notifications_scheduled_task_success_thread_id',
'telegram_notifications_scheduled_task_failure_thread_id',
'telegram_notifications_docker_cleanup_success_thread_id',
'telegram_notifications_docker_cleanup_failure_thread_id',
'telegram_notifications_server_disk_usage_thread_id',
'telegram_notifications_server_reachable_thread_id',
'telegram_notifications_server_unreachable_thread_id',
'telegram_notifications_server_patch_thread_id',
'telegram_notifications_traefik_outdated_thread_id',
);
});
test('TelegramNotificationSettings casts actual docker cleanup thread ids as encrypted', function () {
$casts = (new TelegramNotificationSettings)->getCasts();
expect($casts)->toMatchArray([
'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted',
'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted',
]);
});
test('StandalonePostgresql hides password, init_scripts, db urls', function () { test('StandalonePostgresql hides password, init_scripts, db urls', function () {
$hidden = (new StandalonePostgresql)->getHidden(); $hidden = (new StandalonePostgresql)->getHidden();