From 43919ef4e03267cf47eef4151e8ead74ddde2e2f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:21:04 +0200 Subject: [PATCH] fix(security): encrypt GitLab webhook token and mask input Webhook secret was stored and shown as plaintext. Use a password field, encrypt at rest (with legacy plaintext read support), and look up tokens via findByWebhookToken so encrypted values still authenticate webhooks. --- app/Http/Controllers/Webhook/Gitlab.php | 2 +- app/Livewire/Source/Gitlab/Change.php | 4 +- app/Models/GitlabApp.php | 45 +++++++++++++ .../livewire/source/gitlab/change.blade.php | 4 +- .../GitlabAppWebhookTokenEncryptionTest.php | 67 +++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/GitlabAppWebhookTokenEncryptionTest.php diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 9371d9d91..e521093d7 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -103,7 +103,7 @@ class Gitlab extends Controller ], 401); } - $gitlab_app = GitlabApp::where('webhook_token', $x_gitlab_token)->first(); + $gitlab_app = GitlabApp::findByWebhookToken($x_gitlab_token); if (! $gitlab_app) { auditLogWebhookFailure('gitlab', 'invalid_token', [ 'event' => $object_kind, diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index 3038ff2a1..723b4eeae 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -237,7 +237,9 @@ class Change extends Component if (! empty($this->clientSecretInput)) { $this->gitlab_app->client_secret = $this->clientSecretInput; } - $this->gitlab_app->webhook_token = $this->webhookToken; + if (! empty($this->webhookToken)) { + $this->gitlab_app->webhook_token = $this->webhookToken; + } $this->gitlab_app->group_name = $this->groupName; $this->gitlab_app->is_system_wide = $this->isSystemWide; $this->gitlab_app->private_key_id = $this->privateKeyId; diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index 2279fe641..09a48e8b9 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -2,6 +2,10 @@ namespace App\Models; +use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Support\Facades\Crypt; + class GitlabApp extends BaseModel { protected $fillable = [ @@ -49,6 +53,47 @@ class GitlabApp extends BaseModel ]; } + /** + * Encrypt webhook tokens at rest. Supports legacy plaintext values until they are re-saved. + * Not a standard encrypted cast: webhooks look up by token value (see findByWebhookToken). + */ + protected function webhookToken(): Attribute + { + return Attribute::make( + get: function (?string $value): ?string { + if ($value === null || $value === '') { + return $value; + } + + try { + return Crypt::decryptString($value); + } catch (DecryptException) { + // Legacy rows stored the token in plaintext. + return $value; + } + }, + set: function (?string $value): ?string { + if ($value === null || $value === '') { + return $value; + } + + return Crypt::encryptString($value); + }, + ); + } + + public static function findByWebhookToken(string $token): ?self + { + if ($token === '') { + return null; + } + + // Encrypted values cannot be matched with a SQL equality; sources are few per instance. + return static::query()->get()->first( + fn (self $app): bool => filled($app->webhook_token) && hash_equals((string) $app->webhook_token, $token) + ); + } + protected static function booted(): void { static::deleting(function (GitlabApp $gitlabApp) { diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index f4156b811..b743fa0d7 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -92,8 +92,8 @@ - + @if ($applications->count() > 0) diff --git a/tests/Feature/GitlabAppWebhookTokenEncryptionTest.php b/tests/Feature/GitlabAppWebhookTokenEncryptionTest.php new file mode 100644 index 000000000..0a44e9c5a --- /dev/null +++ b/tests/Feature/GitlabAppWebhookTokenEncryptionTest.php @@ -0,0 +1,67 @@ +team = Team::create([ + 'name' => 'Webhook Token Team', + 'personal_team' => false, + ]); +}); + +it('encrypts webhook tokens at rest', function () { + $app = GitlabApp::create([ + 'name' => 'Encrypted webhook', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'webhook_token' => 'plain-webhook-secret', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $raw = DB::table('gitlab_apps')->where('id', $app->id)->value('webhook_token'); + expect($raw)->not->toBe('plain-webhook-secret') + ->and(Crypt::decryptString($raw))->toBe('plain-webhook-secret') + ->and($app->fresh()->webhook_token)->toBe('plain-webhook-secret'); +}); + +it('finds an app by webhook token for both encrypted and legacy plaintext values', function () { + $encrypted = GitlabApp::create([ + 'name' => 'Encrypted', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'webhook_token' => 'encrypted-secret', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $legacy = GitlabApp::create([ + 'name' => 'Legacy', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + DB::table('gitlab_apps')->where('id', $legacy->id)->update([ + 'webhook_token' => 'legacy-plain-secret', + ]); + + expect(GitlabApp::findByWebhookToken('encrypted-secret')?->id)->toBe($encrypted->id) + ->and(GitlabApp::findByWebhookToken('legacy-plain-secret')?->id)->toBe($legacy->id) + ->and(GitlabApp::findByWebhookToken('missing'))->toBeNull(); +});