mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 04:15:55 +00:00
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:
1 parent
94c3129ad1
commit
43919ef4e0
5 files changed
+118
-4
No files matched your search
@@ -103,7 +103,7 @@ class Gitlab extends Controller
|
|||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$gitlab_app = GitlabApp::where('webhook_token', $x_gitlab_token)->first();
|
$gitlab_app = GitlabApp::findByWebhookToken($x_gitlab_token);
|
||||||
if (! $gitlab_app) {
|
if (! $gitlab_app) {
|
||||||
auditLogWebhookFailure('gitlab', 'invalid_token', [
|
auditLogWebhookFailure('gitlab', 'invalid_token', [
|
||||||
'event' => $object_kind,
|
'event' => $object_kind,
|
||||||
|
|||||||
@@ -237,7 +237,9 @@ class Change extends Component
|
|||||||
if (! empty($this->clientSecretInput)) {
|
if (! empty($this->clientSecretInput)) {
|
||||||
$this->gitlab_app->client_secret = $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->group_name = $this->groupName;
|
||||||
$this->gitlab_app->is_system_wide = $this->isSystemWide;
|
$this->gitlab_app->is_system_wide = $this->isSystemWide;
|
||||||
$this->gitlab_app->private_key_id = $this->privateKeyId;
|
$this->gitlab_app->private_key_id = $this->privateKeyId;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
class GitlabApp extends BaseModel
|
class GitlabApp extends BaseModel
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
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
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::deleting(function (GitlabApp $gitlabApp) {
|
static::deleting(function (GitlabApp $gitlabApp) {
|
||||||
|
|||||||
@@ -92,8 +92,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<x-forms.input readonly label="Webhook URL"
|
<x-forms.input readonly label="Webhook URL"
|
||||||
value="{{ rtrim($this->resolvePublicBaseUrl(), '/') }}/webhooks/source/gitlab/events" />
|
value="{{ rtrim($this->resolvePublicBaseUrl(), '/') }}/webhooks/source/gitlab/events" />
|
||||||
<x-forms.input canGate="update" :canResource="$gitlab_app" id="webhookToken" label="Webhook Secret Token"
|
<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." />
|
helper="Set this same token in your GitLab webhook's Secret token field. Stored encrypted." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($applications->count() > 0)
|
@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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user