diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index ee7f25431..d37ba7cee 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook; use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -14,6 +15,7 @@ use Visus\Cuid2\Cuid2; class Bitbucket extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -62,8 +64,14 @@ class Bitbucket extends Controller $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $commit = data_get($payload, 'pullrequest.source.commit.hash'); } - $applications = Application::where('git_repository', 'like', "%$full_name%"); - $applications = $applications->where('git_branch', $branch)->get(); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + return response([ + 'status' => 'failed', + 'message' => 'Nothing to do. Invalid repository.', + ]); + } + $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { return response([ 'status' => 'failed', @@ -79,11 +87,7 @@ class Bitbucket extends Controller 'repository' => $full_name ?? null, 'event' => $x_bitbucket_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -97,11 +101,7 @@ class Bitbucket extends Controller 'repository' => $full_name ?? null, 'event' => $x_bitbucket_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -114,11 +114,7 @@ class Bitbucket extends Controller 'repository' => $full_name ?? null, 'event' => $x_bitbucket_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php new file mode 100644 index 000000000..e3a5e9890 --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -0,0 +1,98 @@ +normalizeManualWebhookRepositoryPath($fullName); + } + + /** + * @return Collection + */ + protected function manualWebhookApplications(Builder $query, string $fullName): Collection + { + return $query->get() + ->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName)) + ->values(); + } + + protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool + { + $repositoryPath = $this->canonicalManualWebhookRepository($gitRepository); + + return $repositoryPath !== null && hash_equals($fullName, $repositoryPath); + } + + /** + * @return array{status: string, message: string} + */ + protected function unauthenticatedManualWebhookFailurePayload(): array + { + return [ + 'status' => 'failed', + 'message' => 'Invalid signature.', + ]; + } + + protected function canonicalManualWebhookRepository(?string $gitRepository): ?string + { + if (! is_string($gitRepository)) { + return null; + } + + $gitRepository = trim($gitRepository); + + if ($gitRepository === '') { + return null; + } + + $path = null; + $parts = parse_url($gitRepository); + + if (is_array($parts) && isset($parts['scheme'])) { + $path = data_get($parts, 'path'); + } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { + $path = Str::after($gitRepository, ':'); + } else { + $path = $gitRepository; + } + + if (! is_string($path) || $path === '') { + return null; + } + + return $this->normalizeManualWebhookRepositoryPath($path); + } + + protected function normalizeManualWebhookRepositoryPath(string $path): string + { + $path = trim($path); + $path = strtok($path, '?#') ?: $path; + $path = trim($path, '/'); + $path = preg_replace('/\.git\z/i', '', $path) ?? $path; + + return $path; + } +} diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 64807d694..be064e380 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook; use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,6 +16,7 @@ use Visus\Cuid2\Cuid2; class Gitea extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -58,15 +60,19 @@ class Gitea extends Controller if (! $branch) { return response('Nothing to do. No branch found in the request.'); } - $applications = Application::where('git_repository', 'like', "%$full_name%"); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + return response('Nothing to do. Invalid repository.'); + } + $applications = Application::query(); if ($x_gitea_event === 'push') { - $applications = $applications->where('git_branch', $branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); } } if ($x_gitea_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with branch '$base_branch'."); } @@ -80,11 +86,7 @@ class Gitea extends Controller 'repository' => $full_name ?? null, 'event' => $x_gitea_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -96,11 +98,7 @@ class Gitea extends Controller 'repository' => $full_name ?? null, 'event' => $x_gitea_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index b0e11f60c..84d7b81f0 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Jobs\GithubAppPermissionJob; use App\Jobs\ProcessGithubPullRequestWebhook; use App\Models\Application; @@ -18,6 +19,7 @@ use Visus\Cuid2\Cuid2; class Github extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -66,15 +68,19 @@ class Github extends Controller if (! $branch) { return response('Nothing to do. No branch found in the request.'); } - $applications = Application::where('git_repository', 'like', "%$full_name%"); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + return response('Nothing to do. Invalid repository.'); + } + $applications = Application::query(); if ($x_github_event === 'push') { - $applications = $applications->where('git_branch', $branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name."); } } if ($x_github_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); } @@ -93,11 +99,7 @@ class Github extends Controller 'repository' => $full_name ?? null, 'mode' => 'manual', ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -109,11 +111,7 @@ class Github extends Controller 'repository' => $full_name ?? null, 'mode' => 'manual', ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 205bede8f..231a0b6e5 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook; use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; +use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,6 +16,7 @@ use Visus\Cuid2\Cuid2; class Gitlab extends Controller { use DetectsSkipDeployCommits; + use MatchesManualWebhookApplications; public function manual(Request $request) { @@ -85,9 +87,18 @@ class Gitlab extends Controller return response($return_payloads); } } - $applications = Application::where('git_repository', 'like', "%$full_name%"); + $full_name = $this->manualWebhookRepositoryFullName($full_name); + if ($full_name === null) { + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Nothing to do. Invalid repository.', + ]); + + return response($return_payloads); + } + $applications = Application::query(); if ($x_gitlab_event === 'push') { - $applications = $applications->where('git_branch', $branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { $return_payloads->push([ 'status' => 'failed', @@ -98,7 +109,7 @@ class Gitlab extends Controller } } if ($x_gitlab_event === 'merge_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { $return_payloads->push([ 'status' => 'failed', @@ -117,11 +128,7 @@ class Gitlab extends Controller 'repository' => $full_name ?? null, 'event' => $x_gitlab_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Webhook secret not configured.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } @@ -132,11 +139,7 @@ class Gitlab extends Controller 'repository' => $full_name ?? null, 'event' => $x_gitlab_event, ]); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload()); continue; } diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index a06e85309..24d9ed72a 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -51,7 +51,7 @@ describe('GitHub Manual Webhook HMAC', function () { ], $payload); $response->assertOk(); - expect($response->getContent())->toContain('Webhook secret not configured'); + expect($response->getContent())->toContain('Invalid signature'); }); test('rejects push with forged hash', function () { @@ -118,7 +118,7 @@ describe('GitLab Manual Webhook HMAC', function () { ]); $response->assertOk(); - expect($response->getContent())->toContain('Webhook secret not configured'); + expect($response->getContent())->toContain('Invalid signature'); }); test('rejects push with wrong token', function () { @@ -178,7 +178,7 @@ describe('Bitbucket Manual Webhook HMAC', function () { ], $payload); $response->assertOk(); - expect($response->getContent())->toContain('Webhook secret not configured'); + expect($response->getContent())->toContain('Invalid signature'); }); test('rejects push with non-sha256 algorithm', function () { @@ -263,7 +263,7 @@ describe('Gitea Manual Webhook HMAC', function () { ], $payload); $response->assertOk(); - expect($response->getContent())->toContain('Webhook secret not configured'); + expect($response->getContent())->toContain('Invalid signature'); }); test('rejects push with forged hash', function () { @@ -312,6 +312,204 @@ describe('Gitea Manual Webhook HMAC', function () { }); }); +describe('Manual Webhook Repository Matching', function () { + test('github rejects empty repository without leaking applications', function () { + $app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => ''], + '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(); + $content = $response->getContent(); + expect($content)->toContain('Invalid repository') + ->not->toContain('secret-github-app') + ->not->toContain($app->uuid); + }); + + test('github does not match repository substrings', function () { + $app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']); + + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test'], + '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(); + $content = $response->getContent(); + expect($content)->toContain('No applications found') + ->not->toContain('secret-github-app') + ->not->toContain($app->uuid); + }); + + test('github invalid signature does not leak matched application identifiers', function () { + $app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']); + + $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(); + $content = $response->getContent(); + expect($content)->toContain('Invalid signature') + ->not->toContain('secret-github-app') + ->not->toContain($app->uuid) + ->not->toContain('application_uuid') + ->not->toContain('application_name'); + }); + + test('manual webhooks reject empty repositories for every provider without leaking applications', function (string $provider, string $uri, array $payload, array $headers) { + $app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]); + $body = json_encode($payload); + + $server = ['CONTENT_TYPE' => 'application/json']; + foreach ($headers as $name => $value) { + $server[$name] = $value; + } + + $response = $this->call('POST', $uri, [], [], [], $server, $body); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->toContain('Invalid repository') + ->not->toContain("secret-{$provider}-app") + ->not->toContain($app->uuid); + })->with([ + 'gitlab' => [ + 'gitlab', + '/webhooks/source/gitlab/events/manual', + [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => ''], + 'after' => 'abc123', + 'commits' => [], + ], + ['HTTP_X-Gitlab-Token' => 'wrong-token'], + ], + 'bitbucket' => [ + 'bitbucket', + '/webhooks/source/bitbucket/events/manual', + [ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => ''], + ], + ['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'], + ], + 'gitea' => [ + 'gitea', + '/webhooks/source/gitea/events/manual', + [ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => ''], + 'after' => 'abc123', + 'commits' => [], + ], + ['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'], + ], + ]); + + test('manual webhooks do not match repository substrings for every provider', function (string $provider, string $uri, array $payload, array $headers) { + $app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]); + $body = json_encode($payload); + + $server = ['CONTENT_TYPE' => 'application/json']; + foreach ($headers as $name => $value) { + $server[$name] = $value; + } + + $response = $this->call('POST', $uri, [], [], [], $server, $body); + + $response->assertOk(); + $content = $response->getContent(); + expect($content)->toContain('No applications found') + ->not->toContain("secret-{$provider}-app") + ->not->toContain($app->uuid); + })->with([ + 'gitlab' => [ + 'gitlab', + '/webhooks/source/gitlab/events/manual', + [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test'], + 'after' => 'abc123', + 'commits' => [], + ], + ['HTTP_X-Gitlab-Token' => 'wrong-token'], + ], + 'bitbucket' => [ + 'bitbucket', + '/webhooks/source/bitbucket/events/manual', + [ + 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]], + 'repository' => ['full_name' => 'test-org/test'], + ], + ['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'], + ], + 'gitea' => [ + 'gitea', + '/webhooks/source/gitea/events/manual', + [ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test'], + 'after' => 'abc123', + 'commits' => [], + ], + ['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'], + ], + ]); + + test('github matches ssh git repository URL exactly', function () { + $app = createApplicationWithWebhook(overrides: [ + 'git_repository' => 'git@github.com:test-org/test-repo.git', + ]); + $secret = $app->manual_webhook_secret_github; + + $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, $secret), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect($response->getContent())->not->toContain('No applications found'); + }); +}); + describe('Webhook Secret Auto-Generation', function () { test('auto-generates webhook secrets on application creation', function () { $app = createApplicationWithWebhook();