fix(security): hide notification secrets from non-admins

Prevent users without update permission from reading notification credentials and manual webhook secrets in Livewire state or rendered forms.
This commit is contained in:
Andras Bacsai
2026-06-04 11:24:19 +02:00
parent 062ad57740
commit 5973bb4d4f
14 changed files with 219 additions and 45 deletions
+3 -1
View File
@@ -110,7 +110,9 @@ class Discord extends Component
refreshSession();
} else {
$this->discordEnabled = $this->settings->discord_enabled;
$this->discordWebhookUrl = $this->settings->discord_webhook_url;
$this->discordWebhookUrl = auth()->user()->can('update', $this->settings)
? $this->settings->discord_webhook_url
: null;
$this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications;
$this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications;
+7 -2
View File
@@ -113,8 +113,13 @@ class Pushover extends Component
refreshSession();
} else {
$this->pushoverEnabled = $this->settings->pushover_enabled;
$this->pushoverUserKey = $this->settings->pushover_user_key;
$this->pushoverApiToken = $this->settings->pushover_api_token;
if (auth()->user()->can('update', $this->settings)) {
$this->pushoverUserKey = $this->settings->pushover_user_key;
$this->pushoverApiToken = $this->settings->pushover_api_token;
} else {
$this->pushoverUserKey = null;
$this->pushoverApiToken = null;
}
$this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications;
$this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications;
+3 -1
View File
@@ -110,7 +110,9 @@ class Slack extends Component
refreshSession();
} else {
$this->slackEnabled = $this->settings->slack_enabled;
$this->slackWebhookUrl = $this->settings->slack_webhook_url;
$this->slackWebhookUrl = auth()->user()->can('update', $this->settings)
? $this->settings->slack_webhook_url
: null;
$this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications;
$this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications;
+7 -2
View File
@@ -169,8 +169,13 @@ class Telegram extends Component
$this->settings->save();
} else {
$this->telegramEnabled = $this->settings->telegram_enabled;
$this->telegramToken = $this->settings->telegram_token;
$this->telegramChatId = $this->settings->telegram_chat_id;
if (auth()->user()->can('update', $this->settings)) {
$this->telegramToken = $this->settings->telegram_token;
$this->telegramChatId = $this->settings->telegram_chat_id;
} else {
$this->telegramToken = null;
$this->telegramChatId = null;
}
$this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications;
$this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications;
+3 -1
View File
@@ -105,7 +105,9 @@ class Webhook extends Component
refreshSession();
} else {
$this->webhookEnabled = $this->settings->webhook_enabled;
$this->webhookUrl = $this->settings->webhook_url;
$this->webhookUrl = auth()->user()->can('update', $this->settings)
? $this->settings->webhook_url
: null;
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
+12 -7
View File
@@ -34,19 +34,24 @@ class Webhooks extends Component
{
$this->deploywebhook = generateDeployWebhook($this->resource);
$this->githubManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_github');
if ($this->canViewSecrets()) {
$this->githubManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_github');
$this->gitlabManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitlab');
$this->bitbucketManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_bitbucket');
$this->giteaManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitea');
}
$this->githubManualWebhook = generateGitManualWebhook($this->resource, 'github');
$this->gitlabManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitlab');
$this->gitlabManualWebhook = generateGitManualWebhook($this->resource, 'gitlab');
$this->bitbucketManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_bitbucket');
$this->bitbucketManualWebhook = generateGitManualWebhook($this->resource, 'bitbucket');
$this->giteaManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitea');
$this->giteaManualWebhook = generateGitManualWebhook($this->resource, 'gitea');
}
public function canViewSecrets(): bool
{
return auth()->user()->can('update', $this->resource);
}
public function submit()
{
try {
@@ -26,9 +26,15 @@
helper="If enabled, a ping (@here) will be sent to the notification when a critical event happens."
label="Ping Enabled" />
</div>
<x-forms.input canGate="update" :canResource="$settings" type="password"
helper="Create a Discord Server and generate a Webhook URL. <br><a class='inline-block underline dark:text-white' href='https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks' target='_blank'>Webhook Documentation</a>"
required id="discordWebhookUrl" label="Webhook" />
@can('update', $settings)
<x-forms.input type="password"
helper="Create a Discord Server and generate a Webhook URL. <br><a class='inline-block underline dark:text-white' href='https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks' target='_blank'>Webhook Documentation</a>"
required id="discordWebhookUrl" label="Webhook" />
@else
<x-forms.input disabled
helper="Create a Discord Server and generate a Webhook URL. <br><a class='inline-block underline dark:text-white' href='https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks' target='_blank'>Webhook Documentation</a>"
required label="Webhook" value="Hidden (only admins can view)" />
@endcan
</form>
<h2 class="mt-4">Notification Settings</h2>
<p class="mb-4">
@@ -24,12 +24,21 @@
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSavePushoverEnabled" id="pushoverEnabled" label="Enabled" />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$settings" type="password"
helper="Get your User Key in Pushover. You need to be logged in to Pushover to see your user key in the top right corner. <br><a class='inline-block underline dark:text-white' href='https://pushover.net/' target='_blank'>Pushover Dashboard</a>"
required id="pushoverUserKey" label="User Key" />
<x-forms.input canGate="update" :canResource="$settings" type="password"
helper="Generate an API Token/Key in Pushover by creating a new application. <br><a class='inline-block underline dark:text-white' href='https://pushover.net/apps/build' target='_blank'>Create Pushover Application</a>"
required id="pushoverApiToken" label="API Token" />
@can('update', $settings)
<x-forms.input type="password"
helper="Get your User Key in Pushover. You need to be logged in to Pushover to see your user key in the top right corner. <br><a class='inline-block underline dark:text-white' href='https://pushover.net/' target='_blank'>Pushover Dashboard</a>"
required id="pushoverUserKey" label="User Key" />
<x-forms.input type="password"
helper="Generate an API Token/Key in Pushover by creating a new application. <br><a class='inline-block underline dark:text-white' href='https://pushover.net/apps/build' target='_blank'>Create Pushover Application</a>"
required id="pushoverApiToken" label="API Token" />
@else
<x-forms.input disabled
helper="Get your User Key in Pushover. You need to be logged in to Pushover to see your user key in the top right corner. <br><a class='inline-block underline dark:text-white' href='https://pushover.net/' target='_blank'>Pushover Dashboard</a>"
required label="User Key" value="Hidden (only admins can view)" />
<x-forms.input disabled
helper="Generate an API Token/Key in Pushover by creating a new application. <br><a class='inline-block underline dark:text-white' href='https://pushover.net/apps/build' target='_blank'>Create Pushover Application</a>"
required label="API Token" value="Hidden (only admins can view)" />
@endcan
</div>
</form>
<h2 class="mt-4">Notification Settings</h2>
@@ -23,9 +23,15 @@
<div class="w-32">
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSaveSlackEnabled" id="slackEnabled" label="Enabled" />
</div>
<x-forms.input canGate="update" :canResource="$settings" type="password"
helper="Create a Slack APP and generate a Incoming Webhook URL. <br><a class='inline-block underline dark:text-white' href='https://api.slack.com/apps' target='_blank'>Create Slack APP</a>"
required id="slackWebhookUrl" label="Webhook" />
@can('update', $settings)
<x-forms.input type="password"
helper="Create a Slack APP and generate a Incoming Webhook URL. <br><a class='inline-block underline dark:text-white' href='https://api.slack.com/apps' target='_blank'>Create Slack APP</a>"
required id="slackWebhookUrl" label="Webhook" />
@else
<x-forms.input disabled
helper="Create a Slack APP and generate a Incoming Webhook URL. <br><a class='inline-block underline dark:text-white' href='https://api.slack.com/apps' target='_blank'>Create Slack APP</a>"
required label="Webhook" value="Hidden (only admins can view)" />
@endcan
</form>
<h2 class="mt-4">Notification Settings</h2>
<p class="mb-4">
@@ -24,12 +24,21 @@
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSaveTelegramEnabled" id="telegramEnabled" label="Enabled" />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$settings" type="password" autocomplete="new-password"
helper="Get it from the <a class='inline-block underline dark:text-white' href='https://t.me/botfather' target='_blank'>BotFather Bot</a> on Telegram."
required id="telegramToken" label="Bot API Token" />
<x-forms.input canGate="update" :canResource="$settings" type="password" autocomplete="new-password"
helper="Add your bot to a group chat and add its Chat ID here." required id="telegramChatId"
label="Chat ID" />
@can('update', $settings)
<x-forms.input type="password" autocomplete="new-password"
helper="Get it from the <a class='inline-block underline dark:text-white' href='https://t.me/botfather' target='_blank'>BotFather Bot</a> on Telegram."
required id="telegramToken" label="Bot API Token" />
<x-forms.input type="password" autocomplete="new-password"
helper="Add your bot to a group chat and add its Chat ID here." required id="telegramChatId"
label="Chat ID" />
@else
<x-forms.input disabled autocomplete="new-password"
helper="Get it from the <a class='inline-block underline dark:text-white' href='https://t.me/botfather' target='_blank'>BotFather Bot</a> on Telegram."
required label="Bot API Token" value="Hidden (only admins can view)" />
<x-forms.input disabled autocomplete="new-password"
helper="Add your bot to a group chat and add its Chat ID here." required label="Chat ID"
value="Hidden (only admins can view)" />
@endcan
</div>
</form>
<h2 class="mt-4">Notification Settings</h2>
@@ -28,9 +28,15 @@
</div>
<div class="flex items-end gap-2">
<x-forms.input canGate="update" :canResource="$settings" type="password"
helper="Enter a valid HTTP or HTTPS URL. Coolify will send POST requests to this endpoint when events occur."
required id="webhookUrl" label="Webhook URL (POST)" />
@can('update', $settings)
<x-forms.input type="password"
helper="Enter a valid HTTP or HTTPS URL. Coolify will send POST requests to this endpoint when events occur."
required id="webhookUrl" label="Webhook URL (POST)" />
@else
<x-forms.input disabled
helper="Enter a valid HTTP or HTTPS URL. Coolify will send POST requests to this endpoint when events occur."
required label="Webhook URL (POST)" value="Hidden (only admins can view)" />
@endcan
</div>
</form>
<h2 class="mt-4">Notification Settings</h2>
@@ -22,9 +22,9 @@
helper="Need to set a secret to be able to use this webhook. It should match with the secret in GitHub."
label="GitHub Webhook Secret" id="githubManualWebhookSecret"></x-forms.input>
@else
<x-forms.input disabled type="password"
<x-forms.input disabled
helper="Need to set a secret to be able to use this webhook. It should match with the secret in GitHub."
label="GitHub Webhook Secret" id="githubManualWebhookSecret"></x-forms.input>
label="GitHub Webhook Secret" value="Hidden (only admins can view)"></x-forms.input>
@endcan
</div>
<a target="_blank" class="flex hover:no-underline" href="{{ $resource?->gitWebhook }}">
@@ -39,9 +39,9 @@
helper="Need to set a secret to be able to use this webhook. It should match with the secret in GitLab."
label="GitLab Webhook Secret" id="gitlabManualWebhookSecret"></x-forms.input>
@else
<x-forms.input disabled type="password"
<x-forms.input disabled
helper="Need to set a secret to be able to use this webhook. It should match with the secret in GitLab."
label="GitLab Webhook Secret" id="gitlabManualWebhookSecret"></x-forms.input>
label="GitLab Webhook Secret" value="Hidden (only admins can view)"></x-forms.input>
@endcan
</div>
<div class="flex gap-2">
@@ -51,9 +51,9 @@
helper="Need to set a secret to be able to use this webhook. It should match with the secret in Bitbucket."
label="Bitbucket Webhook Secret" id="bitbucketManualWebhookSecret"></x-forms.input>
@else
<x-forms.input disabled type="password"
<x-forms.input disabled
helper="Need to set a secret to be able to use this webhook. It should match with the secret in Bitbucket."
label="Bitbucket Webhook Secret" id="bitbucketManualWebhookSecret"></x-forms.input>
label="Bitbucket Webhook Secret" value="Hidden (only admins can view)"></x-forms.input>
@endcan
</div>
<div class="flex gap-2">
@@ -63,9 +63,9 @@
helper="Need to set a secret to be able to use this webhook. It should match with the secret in Gitea."
label="Gitea Webhook Secret" id="giteaManualWebhookSecret"></x-forms.input>
@else
<x-forms.input disabled type="password"
<x-forms.input disabled
helper="Need to set a secret to be able to use this webhook. It should match with the secret in Gitea."
label="Gitea Webhook Secret" id="giteaManualWebhookSecret"></x-forms.input>
label="Gitea Webhook Secret" value="Hidden (only admins can view)"></x-forms.input>
@endcan
</div>
@can('update', $resource)
@@ -15,7 +15,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -275,3 +275,75 @@ test('member cannot send test on any notification channel', function () {
expect($this->member->can('sendTest', $this->team->pushoverNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->webhookNotificationSettings))->toBeFalse();
});
test('member cannot view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
$settings = $this->team->{$settingsRelation};
$settings->update($secrets);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$componentTest = Livewire::test($component);
foreach ($secrets as $column => $value) {
$property = str($column)->camel()->toString();
$componentTest
->assertSet($property, null)
->assertDontSee($value);
}
$componentTest->assertSee('Hidden (only admins can view)');
})->with([
'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [
'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-member',
]],
'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [
'slack_webhook_url' => 'https://hooks.slack.com/services/secret-member',
]],
'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [
'telegram_token' => 'telegram-secret-token',
'telegram_chat_id' => 'telegram-secret-chat',
]],
'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [
'pushover_user_key' => 'pushover-secret-user',
'pushover_api_token' => 'pushover-secret-token',
]],
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/secret-webhook',
]],
]);
test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
$settings = $this->team->{$settingsRelation};
$settings->update($secrets);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$componentTest = Livewire::test($component);
foreach ($secrets as $column => $value) {
$property = str($column)->camel()->toString();
$componentTest->assertSet($property, $value);
}
})->with([
'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [
'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-admin',
]],
'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [
'slack_webhook_url' => 'https://hooks.slack.com/services/secret-admin',
]],
'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [
'telegram_token' => 'telegram-admin-token',
'telegram_chat_id' => 'telegram-admin-chat',
]],
'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [
'pushover_user_key' => 'pushover-admin-user',
'pushover_api_token' => 'pushover-admin-token',
]],
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/admin-webhook',
]],
]);
@@ -20,7 +20,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -167,6 +167,51 @@ test('admin can update application webhooks', function () {
expect($this->admin->can('update', $this->application))->toBeTrue();
});
test('member cannot view application webhook secrets', function () {
$this->application->update([
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'manual_webhook_secret_github' => 'github-secret-value',
'manual_webhook_secret_gitlab' => 'gitlab-secret-value',
'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value',
'manual_webhook_secret_gitea' => 'gitea-secret-value',
]);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()])
->assertSet('githubManualWebhookSecret', null)
->assertSet('gitlabManualWebhookSecret', null)
->assertSet('bitbucketManualWebhookSecret', null)
->assertSet('giteaManualWebhookSecret', null)
->assertSee('Hidden (only admins can view)')
->assertDontSee('github-secret-value')
->assertDontSee('gitlab-secret-value')
->assertDontSee('bitbucket-secret-value')
->assertDontSee('gitea-secret-value');
});
test('admin can view application webhook secrets', function () {
$this->application->update([
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'manual_webhook_secret_github' => 'github-secret-value',
'manual_webhook_secret_gitlab' => 'gitlab-secret-value',
'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value',
'manual_webhook_secret_gitea' => 'gitea-secret-value',
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()])
->assertSet('githubManualWebhookSecret', 'github-secret-value')
->assertSet('gitlabManualWebhookSecret', 'gitlab-secret-value')
->assertSet('bitbucketManualWebhookSecret', 'bitbucket-secret-value')
->assertSet('giteaManualWebhookSecret', 'gitea-secret-value');
});
// --- Resource Limits (policy checks, mount requires full resource data) ---
test('member cannot update application resource limits', function () {