From 7324b5ca7b4ccf134cce3cf57d1c2d475541a096 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:54:34 +0100 Subject: [PATCH] fix(webhooks): filter manual webhook applications by repository in SQL Co-Authored-By: Claude Opus 5.5 --- .../MatchesManualWebhookApplications.php | 8 +- .../ManualWebhookApplicationLookupTest.php | 204 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Webhook/ManualWebhookApplicationLookupTest.php diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index decb6705fc..9fb910b49d 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -35,7 +35,13 @@ trait MatchesManualWebhookApplications */ protected function manualWebhookApplications(Builder $query, string $fullName): Collection { - return $query->get() + // Every repository form that manualWebhookRepositoryMatches() accepts + // contains the repository name, so the database discards unrelated + // applications that share the branch. The full match stays in PHP. + $pattern = '%'.str_replace('_', '\\_', mb_strtolower($fullName)).'%'; + + return $query->whereRaw("LOWER(git_repository) LIKE ? ESCAPE '\\'", [$pattern]) + ->get() ->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName)) ->values(); } diff --git a/tests/Feature/Webhook/ManualWebhookApplicationLookupTest.php b/tests/Feature/Webhook/ManualWebhookApplicationLookupTest.php new file mode 100644 index 0000000000..cc9f9f634a --- /dev/null +++ b/tests/Feature/Webhook/ManualWebhookApplicationLookupTest.php @@ -0,0 +1,204 @@ +retrievedApplicationIds = collect(); + Event::listen('eloquent.retrieved: '.Application::class, fn (Application $application) => $this->retrievedApplicationIds->push($application->id)); +}); + +function manualLookupApplication(string $gitRepository, string $branch = 'main'): Application +{ + $team = Team::factory()->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]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create([ + 'name' => 'manual-lookup-app', + 'git_repository' => $gitRepository, + 'git_branch' => $branch, + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ])->refresh(); +} + +function sendManualLookupPush(TestCase $test, string $provider, string $secret, string $repository = 'acme/app', string $branch = 'main'): TestResponse +{ + $server = ['REMOTE_ADDR' => '203.0.113.20', 'CONTENT_TYPE' => 'application/json']; + + if ($provider === 'gitlab') { + $payload = json_encode([ + 'object_kind' => 'push', + 'ref' => "refs/heads/{$branch}", + 'project' => ['path_with_namespace' => $repository], + 'after' => 'abc1234', + 'commits' => [], + ]); + + return $test->call('POST', '/webhooks/source/gitlab/events/manual', [], [], [], $server + ['HTTP_X-Gitlab-Token' => $secret], $payload); + } + + if ($provider === 'bitbucket') { + $payload = json_encode([ + 'push' => ['changes' => [['new' => ['name' => $branch, 'target' => ['hash' => 'abc1234']]]]], + 'repository' => ['full_name' => $repository], + ]); + + return $test->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], $server + [ + 'HTTP_X-Event-Key' => 'repo:push', + 'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, $secret), + ], $payload); + } + + $payload = json_encode([ + 'ref' => "refs/heads/{$branch}", + 'repository' => ['full_name' => $repository], + 'after' => 'abc1234', + 'commits' => [], + ]); + + return $test->call('POST', "/webhooks/source/{$provider}/events/manual", [], [], [], $server + [ + $provider === 'github' ? 'HTTP_X-GitHub-Event' : 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret), + ], $payload); +} + +function sendManualLookupGithubPullRequest(TestCase $test, Application $application, string $action, string $baseBranch = 'main'): TestResponse +{ + $payload = json_encode([ + 'action' => $action, + 'number' => 42, + 'repository' => ['id' => 987654321, 'full_name' => 'acme/app'], + 'pull_request' => [ + 'html_url' => 'https://github.com/acme/app/pull/42', + 'title' => 'Change', + 'head' => ['ref' => 'feature', 'sha' => 'e83c5163316f89bfbde7d9ab23ca2e25604af290', 'repo' => ['id' => 987654321]], + 'base' => ['ref' => $baseBranch, 'repo' => ['id' => 987654321]], + ], + ], JSON_THROW_ON_ERROR); + + return $test->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $application->manual_webhook_secret_github), + 'CONTENT_TYPE' => 'application/json', + ], $payload); +} + +function manualLookupDeployed(Application $application): bool +{ + return ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists(); +} + +test('the candidate query filters by repository before applications are loaded', function (string $provider) { + $application = manualLookupApplication('https://github.com/acme/app'); + $unrelated = manualLookupApplication('https://github.com/other/service'); + $similarName = manualLookupApplication('https://github.com/acme/app-legacy'); + $this->retrievedApplicationIds = collect(); + + DB::enableQueryLog(); + sendManualLookupPush($this, $provider, $application->{"manual_webhook_secret_{$provider}"})->assertOk(); + $candidateQuery = collect(DB::getQueryLog())->first(fn (array $query): bool => str_contains($query['query'], 'from "applications" where "git_branch" = ?')); + + expect($candidateQuery['query'])->toContain('LOWER(git_repository) LIKE ?') + ->and($candidateQuery['bindings'])->toContain('%acme/app%') + ->and($this->retrievedApplicationIds)->not->toContain($unrelated->id) + ->and(manualLookupDeployed($application))->toBeTrue() + ->and(manualLookupDeployed($similarName))->toBeFalse(); +})->with(['github', 'gitlab', 'bitbucket', 'gitea']); + +test('every application of the pushed repository and branch is deployed', function (string $provider) { + $applications = collect([ + 'https://github.com/acme/app.git', + 'git@github.com:Acme/App.git', + 'ssh://git@git.example.com:2222/acme/app', + 'acme/app', + ])->map(fn (string $gitRepository): Application => manualLookupApplication($gitRepository)); + $secret = $applications->first()->{"manual_webhook_secret_{$provider}"}; + $applications->each(fn (Application $application) => $application->forceFill(["manual_webhook_secret_{$provider}" => $secret])->save()); + + sendManualLookupPush($this, $provider, $secret)->assertOk(); + + $applications->each(fn (Application $application) => expect(manualLookupDeployed($application))->toBeTrue()); +})->with(['github', 'gitlab', 'bitbucket', 'gitea']); + +test('identically named repositories on different git hosts deploy only the application whose secret signs the delivery', function () { + $githubApplication = manualLookupApplication('https://github.com/acme/app'); + $giteaApplication = manualLookupApplication('https://gitea.example.com/acme/app.git'); + + sendManualLookupPush($this, 'gitea', $giteaApplication->manual_webhook_secret_gitea)->assertOk(); + + expect(manualLookupDeployed($giteaApplication))->toBeTrue() + ->and(manualLookupDeployed($githubApplication))->toBeFalse(); +}); + +test('an underscore in the repository name matches only an underscore', function () { + $application = manualLookupApplication('https://github.com/acme/my_app'); + $wildcardMatch = manualLookupApplication('https://github.com/acme/myXapp'); + $this->retrievedApplicationIds = collect(); + + sendManualLookupPush($this, 'github', $application->manual_webhook_secret_github, 'acme/my_app')->assertOk(); + + expect(manualLookupDeployed($application))->toBeTrue() + ->and($this->retrievedApplicationIds)->not->toContain($wildcardMatch->id); +}); + +test('pushes that must not deploy an application', function (string $case, string $expectedMessage) { + $application = manualLookupApplication('https://github.com/acme/app', $case === 'other branch' ? 'develop' : 'main'); + $secret = $application->manual_webhook_secret_github; + match ($case) { + 'deleted application' => DB::table('applications')->where('id', $application->id)->update(['deleted_at' => now()]), + 'auto deploy disabled' => $application->settings->update(['is_auto_deploy_enabled' => false]), + 'invalid signature' => $secret = 'wrong-secret', + default => null, + }; + + $response = sendManualLookupPush($this, 'github', $secret)->assertOk(); + + expect($response->getContent())->toContain($expectedMessage) + ->and(manualLookupDeployed($application))->toBeFalse(); +})->with([ + 'other branch' => ['other branch', 'No matching application or invalid signature.'], + 'deleted application' => ['deleted application', 'No matching application or invalid signature.'], + 'auto deploy disabled' => ['auto deploy disabled', 'Deployments disabled.'], + 'invalid signature' => ['invalid signature', 'No matching application or invalid signature.'], +]); + +test('pull request events dispatch only applications of the repository', function (string $action) { + $application = manualLookupApplication('https://github.com/acme/app'); + $application->settings->update(['is_preview_deployments_enabled' => true]); + $unrelated = manualLookupApplication('https://github.com/other/service'); + $this->retrievedApplicationIds = collect(); + + sendManualLookupGithubPullRequest($this, $application, $action)->assertOk(); + + Queue::assertPushed(ProcessGithubPullRequestWebhook::class, 1); + Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->applicationId === $application->id && $job->action === $action); + expect($this->retrievedApplicationIds)->not->toContain($unrelated->id); +})->with(['opened', 'closed']);