From bafb9a5a8baf8518a5b9c1cda59f158f5e726436 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:52:23 +0200 Subject: [PATCH] refactor(webhook): encrypt manual webhook secrets and tighten HMAC verification - Auto-generate a 40-char random secret for each manual_webhook_secret_* column on Application creation so new apps are never left with an empty secret. - Add encrypted cast for the four webhook-secret columns; backfill migration re-encrypts existing plaintext values and fills missing ones. - Reject webhook deliveries when the stored secret is empty (GitHub, GitLab, Bitbucket, Gitea manual endpoints). - Bitbucket: require the sha256 algorithm prefix on X-Hub-Signature instead of trusting the client-supplied algo. - GitLab: drop the ?? '' fallback on the token comparison. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/Webhook/Bitbucket.php | 23 +- app/Http/Controllers/Webhook/Gitea.php | 9 + app/Http/Controllers/Webhook/Github.php | 9 + app/Http/Controllers/Webhook/Gitlab.php | 11 +- app/Models/Application.php | 23 +- ...0_backfill_and_encrypt_webhook_secrets.php | 59 +++ tests/Feature/Webhook/WebhookHmacTest.php | 338 ++++++++++++++++++ 7 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php create mode 100644 tests/Feature/Webhook/WebhookHmacTest.php diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 183186711..ffa71b55a 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -57,10 +57,29 @@ public function manual(Request $request) } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } $payload = $request->getContent(); - [$algo, $hash] = explode('=', $x_bitbucket_token, 2); - $payloadHash = hash_hmac($algo, $payload, $webhook_secret); + $parts = explode('=', $x_bitbucket_token, 2); + if (count($parts) !== 2 || $parts[0] !== 'sha256') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Invalid signature.', + ]); + + continue; + } + $hash = $parts[1]; + $payloadHash = hash_hmac('sha256', $payload, $webhook_secret); if (! hash_equals($hash, $payloadHash) && ! isDev()) { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index a9d65eae6..62adf5410 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -67,6 +67,15 @@ public function manual(Request $request) } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitea'); + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { $return_payloads->push([ diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index fe49369ea..4158016d0 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -81,6 +81,15 @@ public function manual(Request $request) foreach ($applicationsByServer as $serverId => $serverApplications) { foreach ($serverApplications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_github'); + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { $return_payloads->push([ diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 08e5d7162..4453a0e7a 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -100,7 +100,16 @@ public function manual(Request $request) } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_gitlab'); - if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) { + if (empty($webhook_secret)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Webhook secret not configured.', + ]); + + continue; + } + if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) { $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', diff --git a/app/Models/Application.php b/app/Models/Application.php index fef6f6e4c..85e94bfd6 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -215,14 +215,27 @@ class Application extends BaseModel protected $appends = ['server_status']; - protected $casts = [ - 'http_basic_auth_password' => 'encrypted', - 'restart_count' => 'integer', - 'last_restart_at' => 'datetime', - ]; + protected function casts(): array + { + return [ + 'http_basic_auth_password' => 'encrypted', + 'manual_webhook_secret_github' => 'encrypted', + 'manual_webhook_secret_gitlab' => 'encrypted', + 'manual_webhook_secret_bitbucket' => 'encrypted', + 'manual_webhook_secret_gitea' => 'encrypted', + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + ]; + } protected static function booted() { + static::creating(function ($application) { + $application->manual_webhook_secret_github ??= Str::random(40); + $application->manual_webhook_secret_gitlab ??= Str::random(40); + $application->manual_webhook_secret_bitbucket ??= Str::random(40); + $application->manual_webhook_secret_gitea ??= Str::random(40); + }); static::addGlobalScope('withRelations', function ($builder) { $builder->withCount([ 'additional_servers', diff --git a/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php new file mode 100644 index 000000000..47ee6e30a --- /dev/null +++ b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php @@ -0,0 +1,59 @@ +text($col)->nullable()->change(); + } + }); + + try { + DB::table('applications')->chunkById(100, function ($apps) use ($columns) { + foreach ($apps as $app) { + $updates = []; + foreach ($columns as $col) { + $current = $app->{$col}; + + if (empty($current)) { + $updates[$col] = Crypt::encryptString(Str::random(40)); + + continue; + } + + try { + Crypt::decryptString($current); + + continue; + } catch (Exception) { + // Not encrypted yet + } + + $updates[$col] = Crypt::encryptString($current); + } + if ($updates !== []) { + DB::table('applications')->where('id', $app->id)->update($updates); + } + } + }); + } catch (Exception $e) { + echo 'Backfilling and encrypting webhook secrets failed.'; + echo $e->getMessage(); + } + } +} diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php new file mode 100644 index 000000000..a06e85309 --- /dev/null +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -0,0 +1,338 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create(array_merge([ + 'name' => 'webhook-test-app', + 'git_repository' => "https://github.com/{$repo}", + 'git_branch' => $branch, + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ], $overrides)); +} + +describe('GitHub Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_github' => null, + ]); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with forged hash', function () { + $app = createApplicationWithWebhook(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid hash', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_github; + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $hmac = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => "sha256={$hmac}", + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('GitLab Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_gitlab' => null, + ]); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'attacker-supplied-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with wrong token', function () { + $app = createApplicationWithWebhook(); + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => 'wrong-token', + ]); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid token', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_gitlab; + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => $secret, + ]); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('Bitbucket Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_bitbucket' => null, + ]); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, ''), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with non-sha256 algorithm', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_bitbucket; + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha1='.hash_hmac('sha1', $payload, $secret), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('rejects push with forged hash', function () { + $app = createApplicationWithWebhook(); + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid sha256 hash', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_bitbucket; + + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test-repo'], + ]); + + $hmac = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => "sha256={$hmac}", + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('Gitea Manual Webhook HMAC', function () { + test('rejects push when secret is empty', function () { + $app = createApplicationWithWebhook(); + DB::table('applications')->where('id', $app->id)->update([ + 'manual_webhook_secret_gitea' => null, + ]); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Webhook secret not configured'); + }); + + test('rejects push with forged hash', function () { + $app = createApplicationWithWebhook(); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue', + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + }); + + test('accepts push with valid hash', function () { + $app = createApplicationWithWebhook(); + $secret = $app->manual_webhook_secret_gitea; + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => 'abc123', + 'commits' => [], + ]); + + $hmac = hash_hmac('sha256', $payload, $secret); + + $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [ + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => "sha256={$hmac}", + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->not->toContain('Invalid signature'); + expect($content)->not->toContain('Webhook secret not configured'); + }); +}); + +describe('Webhook Secret Auto-Generation', function () { + test('auto-generates webhook secrets on application creation', function () { + $app = createApplicationWithWebhook(); + + expect($app->manual_webhook_secret_github)->not->toBeEmpty(); + expect($app->manual_webhook_secret_gitlab)->not->toBeEmpty(); + expect($app->manual_webhook_secret_bitbucket)->not->toBeEmpty(); + expect($app->manual_webhook_secret_gitea)->not->toBeEmpty(); + expect(strlen($app->manual_webhook_secret_github))->toBe(40); + expect(strlen($app->manual_webhook_secret_gitlab))->toBe(40); + expect(strlen($app->manual_webhook_secret_bitbucket))->toBe(40); + expect(strlen($app->manual_webhook_secret_gitea))->toBe(40); + }); + + test('encrypts webhook secrets at rest', function () { + $app = createApplicationWithWebhook(); + $plaintext = $app->manual_webhook_secret_github; + + $raw = DB::table('applications')->where('id', $app->id)->first(); + + expect($raw->manual_webhook_secret_github)->not->toBe($plaintext); + expect($app->manual_webhook_secret_github)->toBe($plaintext); + }); +});