fix(webhooks): validate preview repository metadata

This commit is contained in:
Andras Bacsai
2026-09-21 16:53:50 +02:00
parent 20a72f0f80
commit bfb732bbe8
5 changed files with 140 additions and 2 deletions
+13 -2
View File
@@ -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,
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Webhook\Concerns;
trait ValidatesPreviewDeploymentRepository
{
protected function isPreviewDeploymentRepositoryTrusted(
mixed $sourceRepository,
mixed $targetRepository,
mixed $webhookRepository,
bool $publicPreviewsEnabled,
): bool {
$identities = [$sourceRepository, $targetRepository, $webhookRepository];
if (collect($identities)->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;
}
}
+11
View File
@@ -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,
+20
View File
@@ -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,
@@ -0,0 +1,67 @@
<?php
use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository;
function previewRepositoryIsTrusted(mixed $source, mixed $target, mixed $webhookRepository, bool $publicEnabled = false): bool
{
return (new class
{
use ValidatesPreviewDeploymentRepository;
public function check(mixed $source, mixed $target, mixed $webhookRepository, bool $publicEnabled): bool
{
return $this->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],
]);