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.
This commit is contained in:
Andras Bacsai
2026-07-21 21:21:04 +02:00
parent 94c3129ad1
commit 43919ef4e0
5 changed files with 118 additions and 4 deletions
+1 -1
View File
@@ -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,
+3 -1
View File
@@ -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;
+45
View File
@@ -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) {
@@ -92,8 +92,8 @@
</div>
<x-forms.input readonly label="Webhook URL"
value="{{ rtrim($this->resolvePublicBaseUrl(), '/') }}/webhooks/source/gitlab/events" />
<x-forms.input canGate="update" :canResource="$gitlab_app" id="webhookToken" label="Webhook Secret Token"
helper="Set this same token in your GitLab webhook's 'Secret token' field." />
<x-forms.input canGate="update" :canResource="$gitlab_app" id="webhookToken" label="Webhook Secret Token" type="password"
helper="Set this same token in your GitLab webhook's Secret token field. Stored encrypted." />
</div>
@if ($applications->count() > 0)
@@ -0,0 +1,67 @@
<?php
use App\Models\GitlabApp;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});