diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index fea55586bc..03b253acc0 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -6,6 +6,7 @@ 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\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,6 +16,7 @@ class Bitbucket extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ValidatesPreviewDeploymentRepository; public function manual(Request $request) { @@ -90,7 +92,7 @@ class Bitbucket extends Controller continue; } - $payload = $request->getContent(); + $rawPayload = $request->getContent(); $parts = explode('=', $x_bitbucket_token, 2); if (count($parts) !== 2 || $parts[0] !== 'sha256') { @@ -105,7 +107,7 @@ class Bitbucket extends Controller continue; } $hash = $parts[1]; - $payloadHash = hash_hmac('sha256', $payload, $webhook_secret); + $payloadHash = hash_hmac('sha256', $rawPayload, $webhook_secret); if (! hash_equals($hash, $payloadHash) && ! isDev()) { auditLogWebhookFailure('bitbucket', 'invalid_signature', [ 'application_uuid' => $application->uuid, @@ -182,6 +184,15 @@ class Bitbucket extends Controller } if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') { if ($application->isPRDeployable()) { + if (! $this->isPreviewDeploymentRepositoryTrusted( + data_get($payload, 'pullrequest.source.repository.uuid'), + data_get($payload, 'pullrequest.destination.repository.uuid'), + data_get($payload, 'repository.uuid'), + $application->settings->is_pr_deployments_public_enabled, + )) { + continue; + } + if ($skip_deploy_pr ?? false) { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Concerns/ValidatesPreviewDeploymentRepository.php b/app/Http/Controllers/Webhook/Concerns/ValidatesPreviewDeploymentRepository.php new file mode 100644 index 0000000000..0c5692049b --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/ValidatesPreviewDeploymentRepository.php @@ -0,0 +1,29 @@ +contains(fn (mixed $identity): bool => ! is_scalar($identity) || trim((string) $identity) === '')) { + return false; + } + + $sourceRepository = trim((string) $sourceRepository); + $targetRepository = trim((string) $targetRepository); + $webhookRepository = trim((string) $webhookRepository); + + if (! hash_equals($targetRepository, $webhookRepository)) { + return false; + } + + return hash_equals($sourceRepository, $targetRepository) || $publicPreviewsEnabled; + } +} diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index a59cb54982..9c40107217 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -6,6 +6,7 @@ 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\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -16,6 +17,7 @@ class Gitea extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ValidatesPreviewDeploymentRepository; public function manual(Request $request) { @@ -184,6 +186,15 @@ class Gitea extends Controller if ($x_gitea_event === 'pull_request') { if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') { if ($application->isPRDeployable()) { + if (! $this->isPreviewDeploymentRepositoryTrusted( + data_get($payload, 'pull_request.head.repo.id'), + data_get($payload, 'pull_request.base.repo.id'), + data_get($payload, 'repository.id'), + $application->settings->is_pr_deployments_public_enabled, + )) { + continue; + } + if ($skip_deploy_pr ?? false) { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index c9a554e2a5..eb24b460f7 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -6,6 +6,7 @@ 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\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; use App\Livewire\Source\Gitlab\Change as GitlabSource; use App\Models\Application; use App\Models\ApplicationPreview; @@ -21,6 +22,7 @@ class Gitlab extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ValidatesPreviewDeploymentRepository; public function redirect(Request $request) { @@ -245,6 +247,15 @@ class Gitlab extends Controller continue; } + if (! $this->isPreviewDeploymentRepositoryTrusted( + data_get($payload, 'object_attributes.source_project_id'), + data_get($payload, 'object_attributes.target_project_id'), + data_get($payload, 'project.id'), + $application->settings->is_pr_deployments_public_enabled, + )) { + continue; + } + if ($skip_deploy_pr) { $return_payloads->push([ 'application' => $application->name, @@ -532,6 +543,15 @@ class Gitlab extends Controller if ($x_gitlab_event === 'merge_request') { if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') { if ($application->isPRDeployable()) { + if (! $this->isPreviewDeploymentRepositoryTrusted( + data_get($payload, 'object_attributes.source_project_id'), + data_get($payload, 'object_attributes.target_project_id'), + data_get($payload, 'project.id'), + $application->settings->is_pr_deployments_public_enabled, + )) { + continue; + } + if ($skip_deploy_pr ?? false) { $return_payloads->push([ 'application' => $application->name, diff --git a/tests/Unit/PreviewDeploymentRepositoryTrustTest.php b/tests/Unit/PreviewDeploymentRepositoryTrustTest.php new file mode 100644 index 0000000000..b1a7ac017a --- /dev/null +++ b/tests/Unit/PreviewDeploymentRepositoryTrustTest.php @@ -0,0 +1,67 @@ +isPreviewDeploymentRepositoryTrusted($source, $target, $webhookRepository, $publicEnabled); + } + })->check($source, $target, $webhookRepository, $publicEnabled); +} + +it('allows a trusted same-repository preview', function () { + expect(previewRepositoryIsTrusted(55, 55, 55))->toBeTrue(); +}); + +it('denies fork previews for each provider when public previews are disabled', function (string $provider, mixed $source, mixed $target, mixed $repository) { + expect(previewRepositoryIsTrusted($source, $target, $repository))->toBeFalse($provider); +})->with([ + 'GitLab merge request' => ['gitlab', 999, 55, 55], + 'Gitea pull request' => ['gitea', 999, 55, 55], + 'Bitbucket pull request' => ['bitbucket', '{fork}', '{base}', '{base}'], +]); + +it('allows an explicitly approved fork preview', function () { + expect(previewRepositoryIsTrusted(999, 55, 55, true))->toBeTrue(); +}); + +it('denies missing or ambiguous repository identity', function (mixed $source, mixed $target, mixed $repository) { + expect(previewRepositoryIsTrusted($source, $target, $repository, true))->toBeFalse(); +})->with([ + 'missing source' => [null, 55, 55], + 'missing target' => [55, null, 55], + 'missing webhook repository' => [55, 55, null], + 'empty identity' => ['', 55, 55], +]); + +it('denies spoofed target repository metadata', function () { + expect(previewRepositoryIsTrusted(55, 55, 999, true))->toBeFalse(); +}); + +it('gates every non-GitHub webhook preview before it creates or queues a deployment', function (string $controller, int $expectedChecks) { + $source = file_get_contents(dirname(__DIR__, 2)."/app/Http/Controllers/Webhook/{$controller}.php"); + + expect(substr_count($source, 'isPreviewDeploymentRepositoryTrusted('))->toBe($expectedChecks); + + $offset = 0; + for ($check = 0; $check < $expectedChecks; $check++) { + $trustCheck = strpos($source, 'isPreviewDeploymentRepositoryTrusted(', $offset); + $nextQueue = strpos($source, 'queue_application_deployment(', $trustCheck); + + expect($trustCheck)->not->toBeFalse() + ->and($nextQueue)->not->toBeFalse() + ->and($trustCheck)->toBeLessThan($nextQueue); + + $offset = $trustCheck + 1; + } +})->with([ + 'GitLab normal and manual webhooks' => ['Gitlab', 2], + 'Gitea signed manual webhook' => ['Gitea', 1], + 'Bitbucket signed manual webhook' => ['Bitbucket', 1], +]);