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();
+});