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:
parent
f40bb80f9d
commit
81a3bb0f07
20 changed files with 335 additions and 2 deletions
|
|
@ -25,6 +25,10 @@ private function removeSensitiveData($deployment)
|
|||
$deployment->makeHidden([
|
||||
'logs',
|
||||
]);
|
||||
} else {
|
||||
$deployment->makeVisible([
|
||||
'logs',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($deployment);
|
||||
|
|
@ -699,6 +703,9 @@ public function get_application_deployments(Request $request)
|
|||
$this->authorize('view', $application);
|
||||
|
||||
$deployments = $application->deployments($skip, $take);
|
||||
if ($request->attributes->get('can_read_sensitive', false) === true) {
|
||||
$deployments['deployments']->each->makeVisible(['logs']);
|
||||
}
|
||||
|
||||
return response()->json($deployments);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ private function removeSensitiveData($team)
|
|||
$team->makeHidden([
|
||||
'private_key',
|
||||
]);
|
||||
} else {
|
||||
$team->makeVisible([
|
||||
'private_key',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($team);
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ class ApplicationDeploymentQueue extends Model
|
|||
'finished_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'logs',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
'finished_at' => 'datetime',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ class CloudInitScript extends Model
|
|||
'script',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'script',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ class CloudProviderToken extends BaseModel
|
|||
'name',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'token' => 'encrypted',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ class DiscordNotificationSettings extends Model
|
|||
'discord_ping_enabled',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'discord_webhook_url',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'discord_enabled' => 'boolean',
|
||||
'discord_webhook_url' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ class EmailNotificationSettings extends Model
|
|||
'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 = [
|
||||
'smtp_enabled' => 'boolean',
|
||||
'smtp_from_address' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ class InstanceSettings extends Model
|
|||
'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 = [
|
||||
'smtp_enabled' => 'boolean',
|
||||
'smtp_from_address' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ class OauthSetting extends Model
|
|||
|
||||
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled'];
|
||||
|
||||
protected $hidden = [
|
||||
'client_secret',
|
||||
];
|
||||
|
||||
protected function clientSecret(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ class PrivateKey extends BaseModel
|
|||
'fingerprint',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'private_key',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'private_key' => 'encrypted',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ class PushoverNotificationSettings extends Model
|
|||
'traefik_outdated_pushover_notifications',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'pushover_user_key',
|
||||
'pushover_api_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pushover_enabled' => 'boolean',
|
||||
'pushover_user_key' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ class S3Storage extends BaseModel
|
|||
'unusable_email_sent',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'key',
|
||||
'secret',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_usable' => 'boolean',
|
||||
'key' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ class SharedEnvironmentVariable extends Model
|
|||
'version',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'value',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'key' => 'string',
|
||||
'value' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ class SlackNotificationSettings extends Model
|
|||
'traefik_outdated_slack_notifications',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'slack_webhook_url',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'slack_enabled' => 'boolean',
|
||||
'slack_webhook_url' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ class SslCertificate extends Model
|
|||
'is_ca_certificate',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'ssl_private_key',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ssl_certificate' => 'encrypted',
|
||||
'ssl_private_key' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -49,6 +49,25 @@ class TelegramNotificationSettings extends Model
|
|||
'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 = [
|
||||
'telegram_enabled' => 'boolean',
|
||||
'telegram_token' => 'encrypted',
|
||||
|
|
@ -75,7 +94,8 @@ class TelegramNotificationSettings extends Model
|
|||
'telegram_notifications_backup_failure_thread_id' => 'encrypted',
|
||||
'telegram_notifications_scheduled_task_success_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_reachable_thread_id' => 'encrypted',
|
||||
'telegram_notifications_server_unreachable_thread_id' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ class WebhookNotificationSettings extends Model
|
|||
'traefik_outdated_webhook_notifications',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'webhook_url',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<h1>Notifications</h1>
|
||||
<div class="subtitle">Get notified about your infrastructure.</div>
|
||||
<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() }}
|
||||
href="{{ route('notifications.email') }}">
|
||||
<button>Email</button>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
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 () {
|
||||
beforeEach(function () {
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
<?php
|
||||
|
||||
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\InstanceSettings;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\Service;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\SlackNotificationSettings;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
|
|
@ -13,6 +26,8 @@
|
|||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
|
||||
describe('Sensitive model fields are hidden by default', function () {
|
||||
test('ServerSetting hides sentinel and logdrain secrets', function () {
|
||||
|
|
@ -61,12 +76,133 @@
|
|||
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 () {
|
||||
$hidden = (new Service)->getHidden();
|
||||
|
||||
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 () {
|
||||
$hidden = (new StandalonePostgresql)->getHidden();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue