mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 01:10:30 -04:00
fix(webhooks): validate preview repository metadata
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
Reference in New Issue
Block a user