diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index ced9a7a05c..01a210d47b 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\ReadsWebhookPushPayload; use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; use App\Models\Application; use App\Models\ApplicationPreview; @@ -16,6 +17,7 @@ class Bitbucket extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ReadsWebhookPushPayload; use ValidatesPreviewDeploymentRepository; public function manual(Request $request) @@ -34,16 +36,16 @@ class Bitbucket extends Controller ]); } if ($x_bitbucket_event === 'repo:push') { - $branch = data_get($payload, 'push.changes.0.new.name'); + // A deleted branch has no "new" state, so no branch is found. + $branch = $this->webhookString(data_get($payload, 'push.changes.0.new.name')); $full_name = data_get($payload, 'repository.full_name'); - $commit = data_get($payload, 'push.changes.0.new.target.hash'); + $commit = $this->webhookString(data_get($payload, 'push.changes.0.new.target.hash')); // Bitbucket webhooks ship up to 5 commits per change. Larger pushes // are evaluated only on the visible 5. + $changes = data_get($payload, 'push.changes'); $skip_deploy_commits = self::shouldSkipDeploy( - collect(data_get($payload, 'push.changes', [])) - ->flatMap(fn ($change) => data_get($change, 'commits', [])) - ->pluck('message') - ->filter() + collect(is_array($changes) ? $changes : []) + ->flatMap(fn (mixed $change): array => $this->webhookPushCommitMessages($change)) ->values() ->all() ); @@ -56,14 +58,21 @@ class Bitbucket extends Controller } } if ($x_bitbucket_event === 'pullrequest:updated' || $x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') { - $branch = data_get($payload, 'pullrequest.destination.branch.name'); - $base_branch = data_get($payload, 'pullrequest.source.branch.name'); + $branch = $this->webhookString(data_get($payload, 'pullrequest.destination.branch.name')); + $base_branch = $this->webhookString(data_get($payload, 'pullrequest.source.branch.name')); $full_name = data_get($payload, 'repository.full_name'); $pull_request_id = data_get($payload, 'pullrequest.id'); $pull_request_html_url = data_get($payload, 'pullrequest.links.html.href'); $pull_request_title = data_get($payload, 'pullrequest.title'); $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); $commit = data_get($payload, 'pullrequest.source.commit.hash'); + + if (! $branch) { + return response([ + 'status' => 'failed', + 'message' => 'Nothing to do. No branch found in the request.', + ]); + } } $full_name = $this->manualWebhookRepositoryFullName($full_name); if ($full_name === null) { @@ -76,9 +85,11 @@ class Bitbucket extends Controller if ($this->hasTooManyManualWebhookFailures($failure_key)) { return $this->tooManyManualWebhookFailuresResponse($failure_key); } + // A redelivery of the same signed payload is one guess. + $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_bitbucket_token); $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($failure_key); + return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt); } foreach ($applications as $application) { $webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket'); @@ -279,7 +290,7 @@ class Bitbucket extends Controller } } - return $this->manualWebhookResponse($return_payloads, $failure_key); + return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php index 69695e99b2..2c63d48742 100644 --- a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php +++ b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php @@ -8,14 +8,14 @@ trait DetectsSkipDeployCommits * Returns true if there is at least one non-empty message and every message * contains [skip cd] or [skip ci] (case-insensitive). * - * Accepts commit messages from a push payload. Null/empty entries are - * filtered before evaluation. + * Accepts commit messages from a push payload. Null/empty entries and + * values that are not strings are filtered before evaluation. * - * @param array $messages + * @param array $messages */ public static function shouldSkipDeploy(array $messages): bool { - $messages = array_values(array_filter($messages, fn ($m) => filled($m))); + $messages = array_values(array_filter($messages, fn ($m) => is_string($m) && filled($m))); if (empty($messages)) { return false; @@ -34,14 +34,14 @@ trait DetectsSkipDeployCommits /** * Returns true if at least one non-empty message contains [skip cd] or * [skip ci]. Used for PR/MR title + latest-commit signals where any one - * marker should trigger the skip. + * marker should trigger the skip. Values that are not strings are ignored. * - * @param array $messages + * @param array $messages */ public static function shouldSkipDeployAny(array $messages): bool { foreach ($messages as $message) { - if (! filled($message)) { + if (! is_string($message) || ! filled($message)) { continue; } $lower = strtolower((string) $message); diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index b987da88c5..b62d5e873d 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -72,20 +72,25 @@ trait MatchesManualWebhookApplications * key is scoped to the repository and branch, so this cannot lock out other * applications, and it keeps the 429 response from revealing which * repositories exist in this instance. + * + * @param string $attempt Attempt identity from manualWebhookTokenAttempt() or manualWebhookSignedPayloadAttempt(). */ - protected function unauthenticatedManualWebhookResponse(string $failureKey): Response + protected function unauthenticatedManualWebhookResponse(string $failureKey, string $attempt): Response { - $this->recordManualWebhookFailure($failureKey); + $this->recordManualWebhookFailure($failureKey, $attempt); return response([$this->unauthenticatedManualWebhookFailurePayload()]); } - protected function manualWebhookResponse(Collection $payloads, string $failureKey): Response + /** + * @param string $attempt Attempt identity from manualWebhookTokenAttempt() or manualWebhookSignedPayloadAttempt(). + */ + protected function manualWebhookResponse(Collection $payloads, string $failureKey, string $attempt): Response { $failure = $this->unauthenticatedManualWebhookFailurePayload(); $authorizedPayloads = $payloads->reject(fn (array $payload): bool => $payload === $failure)->values(); if ($authorizedPayloads->isEmpty() && $payloads->isNotEmpty()) { - return $this->unauthenticatedManualWebhookResponse($failureKey); + return $this->unauthenticatedManualWebhookResponse($failureKey, $attempt); } return response($authorizedPayloads); diff --git a/app/Http/Controllers/Webhook/Concerns/ReadsWebhookPushPayload.php b/app/Http/Controllers/Webhook/Concerns/ReadsWebhookPushPayload.php new file mode 100644 index 0000000000..c41ab3c04f --- /dev/null +++ b/app/Http/Controllers/Webhook/Concerns/ReadsWebhookPushPayload.php @@ -0,0 +1,113 @@ +|null + */ + protected function webhookPushChangedFiles(mixed $payload): ?Collection + { + $commits = data_get($payload, 'commits'); + if (! is_array($commits)) { + return null; + } + + return collect($commits) + ->filter(fn (mixed $commit): bool => is_array($commit)) + ->flatMap(fn (array $commit): array => collect(['added', 'removed', 'modified']) + ->flatMap(fn (string $type): array => is_array($commit[$type] ?? null) ? $commit[$type] : []) + ->all()) + ->filter(fn (mixed $file): bool => is_string($file) && $file !== '') + ->unique() + ->values(); + } + + /** + * Commit messages of a push. Values that are not strings are ignored. + * + * @return array + */ + protected function webhookPushCommitMessages(mixed $payload, string $commitsPath = 'commits'): array + { + $commits = data_get($payload, $commitsPath); + if (! is_array($commits)) { + return []; + } + + return collect($commits) + ->map(fn (mixed $commit): mixed => is_array($commit) ? ($commit['message'] ?? null) : null) + ->filter(fn (mixed $message): bool => is_string($message)) + ->values() + ->all(); + } + + /** + * True when the push deletes the branch. Such a push has no commit to deploy. + */ + protected function isWebhookBranchDeletionPush(mixed $payload): bool + { + if (data_get($payload, 'deleted') === true) { + return true; + } + + $after = data_get($payload, 'after'); + + return is_string($after) && preg_match('/\A0+\z/', $after) === 1; + } + + /** + * True when the push must deploy the application with respect to its watch + * paths. Unknown changed files (no commit list) do not skip the deployment. + * + * @param Collection|null $changedFiles + */ + protected function webhookPushMatchesWatchPaths(Application $application, ?Collection $changedFiles): bool + { + if (blank($application->watch_paths) || $changedFiles === null) { + return true; + } + + return $application->isWatchPathsTriggered($changedFiles); + } +} diff --git a/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php index e86684b9f6..3da4cac2f4 100644 --- a/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php +++ b/app/Http/Controllers/Webhook/Concerns/ThrottlesManualWebhookFailures.php @@ -2,8 +2,10 @@ namespace App\Http\Controllers\Webhook\Concerns; +use Illuminate\Contracts\Cache\Repository; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\RateLimiter; /** @@ -15,6 +17,14 @@ use Illuminate\Support\Facades\RateLimiter; * applications it targets. Git hosts deliver from shared egress IPs; one * misconfigured repository (wrong secret, deleted application, untracked * branch) therefore cannot lock out deliveries for other repositories. + * + * In one failure window, only distinct failed attempts are counted. An attempt + * is identified by what the secret check depends on: the GitLab token, or the + * pair (payload, signature) for HMAC providers. The check result for a repeated + * attempt is already known, so a repeat does not test a new secret. A + * misconfigured hook that sends the same wrong token, or a redelivery of the + * same signed payload, therefore counts once, while every new guess still + * counts. Attempts are stored only as keyed hashes, never as raw values. */ trait ThrottlesManualWebhookFailures { @@ -36,6 +46,23 @@ trait ThrottlesManualWebhookFailures return "manual-webhook-failures:{$provider}:".auth_rate_limit_ip($request).':'.hash('sha256', (string) $scope); } + /** + * Attempt identity for a static token, such as the GitLab X-Gitlab-Token. + */ + protected function manualWebhookTokenAttempt(string $token): string + { + return 'token:'.$token; + } + + /** + * Attempt identity for an HMAC signature. The signature depends on the raw + * payload, so the same signature for another payload is a new attempt. + */ + protected function manualWebhookSignedPayloadAttempt(Request $request, string $signature): string + { + return 'hmac:'.hash('sha256', $request->getContent()).':'.$signature; + } + protected function hasTooManyManualWebhookFailures(string $failureKey): bool { return RateLimiter::tooManyAttempts($failureKey, self::MANUAL_WEBHOOK_MAX_FAILURES); @@ -51,8 +78,44 @@ trait ThrottlesManualWebhookFailures ], 429)->header('Retry-After', (string) max($retryAfter, 1)); } - protected function recordManualWebhookFailure(string $failureKey): void + /** + * Count a failed attempt, unless the same attempt was already counted in + * the current failure window. + * + * The rate limiter counter stays the source of truth. The set of seen + * attempts only suppresses repeats. It is reset when a new window starts + * and holds at most MANUAL_WEBHOOK_MAX_FAILURES entries, because the scope + * is locked when the counter reaches that value. A lost update of the set + * (concurrent requests) can only count a repeat again, never skip a new + * attempt. + */ + protected function recordManualWebhookFailure(string $failureKey, string $attempt): void { + $store = $this->manualWebhookFailureStore(); + $seenKey = $failureKey.':seen'; + $marker = hash_hmac('sha256', 'manual-webhook-failure:'.$attempt, (string) config('app.key')); + + $seen = RateLimiter::attempts($failureKey) > 0 ? $store->get($seenKey) : null; + $seen = is_array($seen) ? $seen : []; + + if (in_array($marker, $seen, true)) { + return; + } + RateLimiter::hit($failureKey, self::MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS); + + if (count($seen) < self::MANUAL_WEBHOOK_MAX_FAILURES) { + $seen[] = $marker; + $store->put($seenKey, $seen, self::MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS); + } + } + + /** + * The cache store of the rate limiter, so the seen attempts live next to + * the counter. + */ + protected function manualWebhookFailureStore(): Repository + { + return Cache::store(config('cache.limiter')); } } diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 40f015b41d..9109d047eb 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\ReadsWebhookPushPayload; use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; use App\Models\Application; use App\Models\ApplicationPreview; @@ -17,6 +18,7 @@ class Gitea extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ReadsWebhookPushPayload; use ValidatesPreviewDeploymentRepository; public function manual(Request $request) @@ -24,29 +26,27 @@ class Gitea extends Controller try { $return_payloads = collect([]); $x_gitea_delivery = request()->header('X-Gitea-Delivery'); - $x_gitea_event = Str::lower($request->header('X-Gitea-Event')); - $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); + $x_gitea_event = Str::lower((string) $request->header('X-Gitea-Event')); + $x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); $payload = $request->collect(); if ($x_gitea_event === 'ping') { // Just pong return response('pong'); } + if (! in_array($x_gitea_event, ['push', 'pull_request'], true)) { + return response("Nothing to do. Event '$x_gitea_event' is not supported."); + } if ($content_type !== 'application/json') { - $payload = json_decode(data_get($payload, 'payload'), true); + $form_payload = data_get($payload, 'payload'); + $payload = is_string($form_payload) ? json_decode($form_payload, true) : null; } if ($x_gitea_event === 'push') { - $branch = data_get($payload, 'ref'); + $branch = $this->webhookPushBranch(data_get($payload, 'ref')); $full_name = data_get($payload, 'repository.full_name'); - if (Str::isMatch('/refs\/heads\/*/', $branch)) { - $branch = Str::after($branch, 'refs/heads/'); - } - $added_files = data_get($payload, 'commits.*.added'); - $removed_files = data_get($payload, 'commits.*.removed'); - $modified_files = data_get($payload, 'commits.*.modified'); - $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); - $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); + $changed_files = $this->webhookPushChangedFiles($payload); + $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); } if ($x_gitea_event === 'pull_request') { $action = data_get($payload, 'action'); @@ -55,12 +55,16 @@ class Gitea extends Controller $pull_request_html_url = data_get($payload, 'pull_request.html_url'); $pull_request_title = data_get($payload, 'pull_request.title'); $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]); - $branch = data_get($payload, 'pull_request.head.ref'); - $base_branch = data_get($payload, 'pull_request.base.ref'); + $branch = $this->webhookString(data_get($payload, 'pull_request.head.ref')); + $base_branch = $this->webhookString(data_get($payload, 'pull_request.base.ref')); } if (! $branch) { return response('Nothing to do. No branch found in the request.'); } + // A deleted branch has no commit to deploy. No secret is checked here. + if ($x_gitea_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) { + return response('Nothing to do. Branch deleted.'); + } $full_name = $this->manualWebhookRepositoryFullName($full_name); if ($full_name === null) { return response('Nothing to do. Invalid repository.'); @@ -70,17 +74,19 @@ class Gitea extends Controller if ($this->hasTooManyManualWebhookFailures($failure_key)) { return $this->tooManyManualWebhookFailuresResponse($failure_key); } + // A redelivery of the same signed payload is one guess. + $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_hub_signature_256); $applications = Application::query(); if ($x_gitea_event === 'push') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($failure_key); + return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt); } } if ($x_gitea_event === 'pull_request') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($failure_key); + return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt); } } foreach ($applications as $application) { @@ -120,8 +126,7 @@ class Gitea extends Controller } if ($x_gitea_event === 'push') { if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) { if ($skip_deploy_commits ?? false) { $return_payloads->push([ 'application' => $application->name, @@ -286,7 +291,7 @@ class Gitea extends Controller } } - return $this->manualWebhookResponse($return_payloads, $failure_key); + return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt); } catch (Exception $e) { return handleError($e); } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index fb85b148ef..2f56ad3fb8 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -5,6 +5,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\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload; use App\Jobs\GithubAppPermissionJob; use App\Jobs\ProcessGithubPullRequestWebhook; use App\Models\Application; @@ -22,14 +23,15 @@ class Github extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ReadsWebhookPushPayload; public function manual(Request $request) { try { $return_payloads = collect([]); $x_github_delivery = request()->header('X-GitHub-Delivery'); - $x_github_event = Str::lower($request->header('X-GitHub-Event')); - $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); + $x_github_event = Str::lower((string) $request->header('X-GitHub-Event')); + $x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); $payload = $request->collect(); if ($x_github_event === 'ping') { @@ -38,19 +40,14 @@ class Github extends Controller } if ($content_type !== 'application/json') { - $payload = json_decode(data_get($payload, 'payload'), true); + $form_payload = data_get($payload, 'payload'); + $payload = is_string($form_payload) ? json_decode($form_payload, true) : null; } if ($x_github_event === 'push') { - $branch = data_get($payload, 'ref'); + $branch = $this->webhookPushBranch(data_get($payload, 'ref')); $full_name = data_get($payload, 'repository.full_name'); - if (Str::isMatch('/refs\/heads\/*/', $branch)) { - $branch = Str::after($branch, 'refs/heads/'); - } - $added_files = data_get($payload, 'commits.*.added'); - $removed_files = data_get($payload, 'commits.*.removed'); - $modified_files = data_get($payload, 'commits.*.modified'); - $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); - $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); + $changed_files = $this->webhookPushChangedFiles($payload); + $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); } if ($x_github_event === 'pull_request') { $action = data_get($payload, 'action'); @@ -58,8 +55,8 @@ class Github extends Controller $pull_request_id = data_get($payload, 'number'); $pull_request_html_url = data_get($payload, 'pull_request.html_url'); $pull_request_title = data_get($payload, 'pull_request.title'); - $branch = data_get($payload, 'pull_request.head.ref'); - $base_branch = data_get($payload, 'pull_request.base.ref'); + $branch = $this->webhookString(data_get($payload, 'pull_request.head.ref')); + $base_branch = $this->webhookString(data_get($payload, 'pull_request.base.ref')); $before_sha = data_get($payload, 'before'); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); @@ -71,6 +68,10 @@ class Github extends Controller if (! $branch) { return response('Nothing to do. No branch found in the request.'); } + // A deleted branch has no commit to deploy. No secret is checked here. + if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) { + return response('Nothing to do. Branch deleted.'); + } $full_name = $this->manualWebhookRepositoryFullName($full_name); if ($full_name === null) { return response('Nothing to do. Invalid repository.'); @@ -84,13 +85,15 @@ class Github extends Controller if ($this->hasTooManyManualWebhookFailures($failure_key)) { return $this->tooManyManualWebhookFailuresResponse($failure_key); } + // A redelivery of the same signed payload is one guess. + $failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_hub_signature_256); $applications = Application::query(); if ($x_github_event === 'push' || $action !== 'closed') { $applications->where('git_branch', $matched_branch); } $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($failure_key); + return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt); } $applicationsByServer = $applications->groupBy(function ($app) { return $app->destination->server_id; @@ -134,8 +137,7 @@ class Github extends Controller } if ($x_github_event === 'push') { if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) { if ($skip_deploy_commits ?? false) { $return_payloads->push([ 'application' => $application->name, @@ -240,7 +242,7 @@ class Github extends Controller } } - return $this->manualWebhookResponse($return_payloads, $failure_key); + return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt); } catch (Exception $e) { return handleError($e); } @@ -299,15 +301,9 @@ class Github extends Controller } if ($x_github_event === 'push') { $id = data_get($payload, 'repository.id'); - $branch = data_get($payload, 'ref'); - if (Str::isMatch('/refs\/heads\/*/', $branch)) { - $branch = Str::after($branch, 'refs/heads/'); - } - $added_files = data_get($payload, 'commits.*.added'); - $removed_files = data_get($payload, 'commits.*.removed'); - $modified_files = data_get($payload, 'commits.*.modified'); - $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); - $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); + $branch = $this->webhookPushBranch(data_get($payload, 'ref')); + $changed_files = $this->webhookPushChangedFiles($payload); + $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); } if ($x_github_event === 'pull_request') { $action = data_get($payload, 'action'); @@ -328,6 +324,9 @@ class Github extends Controller if (! $id || ! $branch) { return response('Nothing to do. No id or branch found.'); } + if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) { + return response('Nothing to do. Branch deleted.'); + } $applications = Application::where('repository_project_id', $id) ->where('source_id', $github_app->id) ->whereRelation('source', 'is_public', false); @@ -365,8 +364,7 @@ class Github extends Controller } if ($x_github_event === 'push') { if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) { if ($skip_deploy_commits ?? false) { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 2eec721bb9..4d26d3f5ac 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\ReadsWebhookPushPayload; use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; use App\Livewire\Source\Gitlab\Change as GitlabSource; use App\Models\Application; @@ -15,13 +16,13 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; class Gitlab extends Controller { use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + use ReadsWebhookPushPayload; use ValidatesPreviewDeploymentRepository; public function redirect(Request $request) @@ -123,23 +124,23 @@ class Gitlab extends Controller ->where('repository_project_id', $project_id); if ($object_kind === 'push') { - $branch = data_get($payload, 'ref'); - if (Str::isMatch('/refs\/heads\/*/', $branch)) { - $branch = Str::after($branch, 'refs/heads/'); - } + $branch = $this->webhookPushBranch(data_get($payload, 'ref')); if (! $branch) { return response([ 'status' => 'failed', 'message' => 'No branch found in the request.', ]); } + if ($this->isWebhookBranchDeletionPush($payload)) { + return response([ + 'status' => 'skipped', + 'message' => 'Nothing to do. Branch deleted.', + ]); + } $applications = $applications->where('git_branch', $branch)->get(); - $added_files = data_get($payload, 'commits.*.added'); - $removed_files = data_get($payload, 'commits.*.removed'); - $modified_files = data_get($payload, 'commits.*.modified'); - $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); - $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); + $changed_files = $this->webhookPushChangedFiles($payload); + $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); foreach ($applications as $application) { if (! $application->destination->server->isFunctional()) { @@ -162,8 +163,7 @@ class Gitlab extends Controller continue; } - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if (! $is_watch_path_triggered && ! blank($application->watch_paths)) { + if (! $this->webhookPushMatchesWatchPaths($application, $changed_files)) { $return_payloads->push([ 'application' => $application->name, 'status' => 'failed', @@ -363,11 +363,8 @@ class Gitlab extends Controller } if ($x_gitlab_event === 'push') { - $branch = data_get($payload, 'ref'); + $branch = $this->webhookPushBranch(data_get($payload, 'ref')); $full_name = data_get($payload, 'project.path_with_namespace'); - if (Str::isMatch('/refs\/heads\/*/', $branch)) { - $branch = Str::after($branch, 'refs/heads/'); - } if (! $branch) { $return_payloads->push([ 'status' => 'failed', @@ -376,23 +373,29 @@ class Gitlab extends Controller return response($return_payloads); } - $added_files = data_get($payload, 'commits.*.added'); - $removed_files = data_get($payload, 'commits.*.removed'); - $modified_files = data_get($payload, 'commits.*.modified'); - $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); - $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); + // A deleted branch has no commit to deploy. No secret is checked here. + if ($this->isWebhookBranchDeletionPush($payload)) { + $return_payloads->push([ + 'status' => 'skipped', + 'message' => 'Nothing to do. Branch deleted.', + ]); + + return response($return_payloads); + } + $changed_files = $this->webhookPushChangedFiles($payload); + $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); } if ($x_gitlab_event === 'merge_request') { $action = data_get($payload, 'object_attributes.action'); - $branch = data_get($payload, 'object_attributes.source_branch'); - $base_branch = data_get($payload, 'object_attributes.target_branch'); + $branch = $this->webhookString(data_get($payload, 'object_attributes.source_branch')); + $base_branch = $this->webhookString(data_get($payload, 'object_attributes.target_branch')); $full_name = data_get($payload, 'project.path_with_namespace'); $pull_request_id = data_get($payload, 'object_attributes.iid'); $pull_request_html_url = data_get($payload, 'object_attributes.url'); $pull_request_title = data_get($payload, 'object_attributes.title'); $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message'); $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]); - if (! $branch) { + if (! $branch || ! $base_branch) { $return_payloads->push([ 'status' => 'failed', 'message' => 'Nothing to do. No branch found in the request.', @@ -415,17 +418,19 @@ class Gitlab extends Controller if ($this->hasTooManyManualWebhookFailures($failure_key)) { return $this->tooManyManualWebhookFailuresResponse($failure_key); } + // GitLab sends a static token. A repeated wrong token is one guess. + $failure_attempt = $this->manualWebhookTokenAttempt($x_gitlab_token); $applications = Application::query(); if ($x_gitlab_event === 'push') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($failure_key); + return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt); } } if ($x_gitlab_event === 'merge_request') { $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); if ($applications->isEmpty()) { - return $this->unauthenticatedManualWebhookResponse($failure_key); + return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt); } } foreach ($applications as $application) { @@ -464,8 +469,7 @@ class Gitlab extends Controller } if ($x_gitlab_event === 'push') { if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || blank($application->watch_paths)) { + if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) { if ($skip_deploy_commits ?? false) { $return_payloads->push([ 'application' => $application->name, @@ -634,7 +638,7 @@ class Gitlab extends Controller } } - return $this->manualWebhookResponse($return_payloads, $failure_key); + return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt); } catch (Exception $e) { return handleError($e); } diff --git a/tests/Feature/AdvisorySecurityRegressionTest.php b/tests/Feature/AdvisorySecurityRegressionTest.php index c9d9469bb7..5e7e49281a 100644 --- a/tests/Feature/AdvisorySecurityRegressionTest.php +++ b/tests/Feature/AdvisorySecurityRegressionTest.php @@ -74,7 +74,7 @@ it('throttles only failed authentication on manual webhook routes', function (st public function reply(array $payloads, string $failureKey): int { - return $this->manualWebhookResponse(collect($payloads), $failureKey)->getStatusCode(); + return $this->manualWebhookResponse(collect($payloads), $failureKey, $this->manualWebhookTokenAttempt('wrong-token'))->getStatusCode(); } }; $failureKey = $helper->key($request, $provider); @@ -96,7 +96,7 @@ it('does not reveal how many applications share a manual webhook repository', fu public function reply(array $payloads): string { - return $this->manualWebhookResponse(collect($payloads), 'manual-webhook-failures:test')->getContent(); + return $this->manualWebhookResponse(collect($payloads), 'manual-webhook-failures:test', $this->manualWebhookTokenAttempt('wrong-token'))->getContent(); } }; $failure = ['status' => 'failed', 'message' => 'Invalid signature.']; diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index 557569f31d..c928d361ec 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -5,15 +5,18 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\Project; use App\Models\Server; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Str; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -31,9 +34,13 @@ test('manual webhook routes are not rate limited per request', function (string expect(collect($route->gatherMiddleware())->filter(fn (mixed $middleware): bool => is_string($middleware) && str_starts_with($middleware, 'throttle')))->toBeEmpty(); })->with(['github', 'gitlab', 'bitbucket', 'gitea']); -function sendManualWebhookPush(TestCase $test, string $provider, Application $application, bool $validSignature = true, string $ip = '203.0.113.10', string $repository = 'test-org/test-repo', string $branch = 'main'): TestResponse +/** + * An invalid delivery uses a new random wrong secret unless $wrongSecret is set, + * so each invalid delivery is a new guess (a new token or a new signature). + */ +function sendManualWebhookPush(TestCase $test, string $provider, Application $application, bool $validSignature = true, string $ip = '203.0.113.10', string $repository = 'test-org/test-repo', string $branch = 'main', ?string $wrongSecret = null, string $commit = 'abc123'): TestResponse { - $secret = $validSignature ? $application->{"manual_webhook_secret_{$provider}"} : 'wrong-secret'; + $secret = $validSignature ? $application->{"manual_webhook_secret_{$provider}"} : ($wrongSecret ?? 'wrong-secret-'.Str::random(24)); $server = ['REMOTE_ADDR' => $ip, 'CONTENT_TYPE' => 'application/json']; if ($provider === 'gitlab') { @@ -41,7 +48,7 @@ function sendManualWebhookPush(TestCase $test, string $provider, Application $ap 'object_kind' => 'push', 'ref' => "refs/heads/{$branch}", 'project' => ['path_with_namespace' => $repository], - 'after' => 'abc123', + 'after' => $commit, 'commits' => [], ]); @@ -52,7 +59,7 @@ function sendManualWebhookPush(TestCase $test, string $provider, Application $ap if ($provider === 'bitbucket') { $payload = json_encode([ - 'push' => ['changes' => [['new' => ['name' => $branch, 'target' => ['hash' => 'abc123']]]]], + 'push' => ['changes' => [['new' => ['name' => $branch, 'target' => ['hash' => $commit]]]]], 'repository' => ['full_name' => $repository], ]); @@ -65,7 +72,7 @@ function sendManualWebhookPush(TestCase $test, string $provider, Application $ap $payload = json_encode([ 'ref' => "refs/heads/{$branch}", 'repository' => ['full_name' => $repository], - 'after' => 'abc123', + 'after' => $commit, 'commits' => [], ]); $eventHeader = $provider === 'github' ? 'HTTP_X-GitHub-Event' : 'HTTP_X-Gitea-Event'; @@ -203,14 +210,15 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () { test('unknown repositories respond like invalid signatures and are still throttled', function () { $application = createApplicationWithWebhook(); + // Each delivery has a different payload. Identical redeliveries count once. for ($i = 0; $i < 30; $i++) { - $response = sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo'); + $response = sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: "commit-{$i}"); $response->assertOk(); expect($response->getContent())->toContain('Invalid signature'); } - sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo')->assertStatus(429); + sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: 'commit-30')->assertStatus(429); }); test('valid deliveries do not count when another matching application has a different secret', function () { @@ -956,3 +964,378 @@ describe('Webhook Secret Auto-Generation', function () { expect($app->manual_webhook_secret_github)->toBe($plaintext); }); }); + +/** + * Send a manual webhook with any payload. The signature or token is valid for + * $application. The payload is the raw body when it is a string. + * + * @param array|string $payload + * @param array $server + */ +function sendSignedManualWebhook(TestCase $test, string $provider, Application $application, array|string $payload, array $server = []): TestResponse +{ + $body = is_string($payload) ? $payload : json_encode($payload); + $secret = $application->{"manual_webhook_secret_{$provider}"}; + $signature = 'sha256='.hash_hmac('sha256', $body, $secret); + $headers = match ($provider) { + 'gitlab' => ['HTTP_X-Gitlab-Token' => $secret], + 'bitbucket' => ['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => $signature], + 'github' => ['HTTP_X-GitHub-Event' => 'push', 'HTTP_X-Hub-Signature-256' => $signature], + 'gitea' => ['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => $signature], + }; + + return $test->call('POST', "/webhooks/source/{$provider}/events/manual", [], [], [], $server + $headers + [ + 'REMOTE_ADDR' => '203.0.113.10', + 'CONTENT_TYPE' => 'application/json', + ], $body); +} + +/** + * A push payload for the provider, without a commit list. + * + * @return array + */ +function manualWebhookPushPayloadWithoutCommits(string $provider, string $after = 'abc123'): array +{ + if ($provider === 'gitlab') { + return [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['path_with_namespace' => 'test-org/test-repo'], + 'after' => $after, + ]; + } + + return [ + 'ref' => 'refs/heads/main', + 'repository' => ['full_name' => 'test-org/test-repo'], + 'after' => $after, + ]; +} + +/** + * Serialized content of the array cache store, used by the rate limiter in tests. + */ +function serializedWebhookCacheStore(): string +{ + $store = Cache::store()->getStore(); + $storage = (new ReflectionProperty($store, 'storage'))->getValue($store); + + return serialize($storage); +} + +describe('Manual Webhook Push Payloads Without Commits', function () { + test('a push without a commit list is deployed', function (string $provider, string $variant, ?string $watchPaths) { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook(overrides: ['watch_paths' => $watchPaths])); + $payload = manualWebhookPushPayloadWithoutCommits($provider); + if ($variant === 'null') { + $payload['commits'] = null; + } + + $response = sendSignedManualWebhook($this, $provider, $application, $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Deployment queued'); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeTrue(); + })->with(['github', 'gitlab', 'gitea']) + ->with(['missing', 'null']) + ->with(['no watch paths' => null, 'watch paths' => 'src/**']); + + test('a push with an empty commit list still honours watch paths', function (string $provider) { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook(overrides: ['watch_paths' => 'src/**'])); + $payload = manualWebhookPushPayloadWithoutCommits($provider) + ['commits' => []]; + + $response = sendSignedManualWebhook($this, $provider, $application, $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Changed files do not match watch paths'); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + })->with(['github', 'gitlab', 'gitea']); + + test('a push that deletes the branch does not queue a deployment', function (string $provider, ?string $watchPaths) { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook(overrides: ['watch_paths' => $watchPaths])); + $payload = manualWebhookPushPayloadWithoutCommits($provider, after: str_repeat('0', 40)); + + $response = sendSignedManualWebhook($this, $provider, $application, $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Branch deleted'); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + })->with(['github', 'gitlab', 'gitea']) + ->with(['no watch paths' => null, 'watch paths' => 'src/**']); + + test('a github push marked as deleted does not queue a deployment', function () { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); + $payload = manualWebhookPushPayloadWithoutCommits('github', after: 'abc123') + ['deleted' => true, 'commits' => []]; + + $response = sendSignedManualWebhook($this, 'github', $application, $payload); + + $response->assertOk(); + expect($response->getContent())->toContain('Branch deleted'); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + }); +}); + +describe('Manual Webhook Malformed Payloads', function () { + test('a malformed push payload gets a clean response', function (string $provider, array $payload, ?string $expected) { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); + + $response = sendSignedManualWebhook($this, $provider, $application, $payload); + + $response->assertOk(); + if ($expected !== null) { + expect($response->getContent())->toContain($expected); + } + })->with([ + 'github without repository' => ['github', ['ref' => 'refs/heads/main', 'commits' => []], 'Invalid repository'], + 'github without ref' => ['github', ['repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], + 'github with an array ref' => ['github', ['ref' => ['main'], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], + 'github with a string commit list' => ['github', ['ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], 'commits' => 'none'], 'Deployment queued'], + 'github with malformed commits' => ['github', ['ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], 'head_commit' => null, 'commits' => ['text', ['message' => ['x'], 'added' => [['nested']], 'modified' => 'README.md']]], 'Deployment queued'], + 'gitea without repository' => ['gitea', ['ref' => 'refs/heads/main'], 'Invalid repository'], + 'gitea without ref' => ['gitea', ['repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], + 'gitea with an array ref' => ['gitea', ['ref' => ['main'], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], + 'gitea with malformed commits' => ['gitea', ['ref' => 'refs/heads/main', 'repository' => ['full_name' => 'test-org/test-repo'], 'commits' => [['message' => ['x'], 'removed' => [1, null]]]], 'Deployment queued'], + 'gitlab without project' => ['gitlab', ['object_kind' => 'push', 'ref' => 'refs/heads/main'], 'Invalid repository'], + 'gitlab without ref' => ['gitlab', ['object_kind' => 'push', 'project' => ['path_with_namespace' => 'test-org/test-repo']], 'No branch'], + 'gitlab with an array ref' => ['gitlab', ['object_kind' => 'push', 'ref' => ['main'], 'project' => ['path_with_namespace' => 'test-org/test-repo']], 'No branch'], + 'gitlab with a string commit list' => ['gitlab', ['object_kind' => 'push', 'ref' => 'refs/heads/main', 'project' => ['path_with_namespace' => 'test-org/test-repo'], 'commits' => 'none'], 'Deployment queued'], + 'gitlab with malformed commits' => ['gitlab', ['object_kind' => 'push', 'ref' => 'refs/heads/main', 'project' => ['path_with_namespace' => 'test-org/test-repo'], 'commits' => [['message' => ['x'], 'added' => ['a' => ['b']]]]], 'Deployment queued'], + 'gitlab merge request without attributes' => ['gitlab', ['object_kind' => 'merge_request', 'project' => ['path_with_namespace' => 'test-org/test-repo']], 'No branch'], + 'bitbucket without changes' => ['bitbucket', ['push' => [], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], + 'bitbucket with null changes' => ['bitbucket', ['push' => ['changes' => null], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], + 'bitbucket branch deletion' => ['bitbucket', ['push' => ['changes' => [['new' => null, 'old' => ['name' => 'main']]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], + 'bitbucket with an array branch' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => ['main']]]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'No branch'], + 'bitbucket without repository' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => 'main']]]]], 'Invalid repository'], + 'bitbucket with malformed commits' => ['bitbucket', ['push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']], 'commits' => [['message' => ['x']], 'text']]]], 'repository' => ['full_name' => 'test-org/test-repo']], 'Deployment queued'], + ]); + + test('gitea deliveries for unsupported events get a clean response', function () { + $application = createApplicationWithWebhook(); + + $response = sendSignedManualWebhook($this, 'gitea', $application, ['action' => 'opened'], ['HTTP_X-Gitea-Event' => 'issues']); + + $response->assertOk(); + expect($response->getContent())->toContain('not supported'); + }); + + test('form encoded deliveries with a malformed payload field get a clean response', function (string $provider) { + $application = createApplicationWithWebhook(); + $body = 'payload[]=x'; + + $response = sendSignedManualWebhook($this, $provider, $application, $body, ['CONTENT_TYPE' => 'application/x-www-form-urlencoded']); + + $response->assertOk(); + expect($response->getContent())->toContain('No branch'); + })->with(['github', 'gitea']); +}); + +describe('App Webhook Push Payloads Without Commits', function () { + test('github app push without a commit list is deployed', function () { + Queue::fake(); + $team = Team::factory()->create(); + $githubApp = GithubApp::create([ + 'uuid' => (string) str()->uuid(), + 'name' => 'github-app-commits-test', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234567891, + 'webhook_secret' => 'app-secret', + 'team_id' => $team->id, + 'is_public' => false, + ]); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook(overrides: [ + 'source_id' => $githubApp->id, + 'source_type' => GithubApp::class, + 'repository_project_id' => 987654321, + 'watch_paths' => 'src/**', + ])); + $payload = json_encode([ + 'ref' => 'refs/heads/main', + 'repository' => ['id' => 987654321], + 'after' => 'abc123', + ]); + + $response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'push', + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567891', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, 'app-secret'), + 'CONTENT_TYPE' => 'application/json', + ], $payload); + + $response->assertOk(); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeTrue(); + }); + + test('gitlab app push without a commit list is deployed', function (string $variant) { + Queue::fake(); + $team = Team::factory()->create(); + $gitlabApp = GitlabApp::create([ + 'name' => 'gitlab-app-commits-test', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'webhook_token' => 'gitlab-app-token', + 'team_id' => $team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook(overrides: [ + 'source_id' => $gitlabApp->id, + 'source_type' => GitlabApp::class, + 'repository_project_id' => 4242, + 'watch_paths' => 'src/**', + ])); + $payload = [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'project' => ['id' => 4242], + 'after' => 'abc123', + ]; + if ($variant === 'null') { + $payload['commits'] = null; + } + + $response = $this->postJson('/webhooks/source/gitlab/events', $payload, ['X-Gitlab-Token' => 'gitlab-app-token']); + + $response->assertOk(); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeTrue(); + })->with(['missing', 'null']); +}); + +describe('Manual Webhook Repeated Failed Deliveries', function () { + test('gitlab deliveries that repeat the same wrong token count once', function () { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); + + for ($i = 0; $i < 40; $i++) { + $response = sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: 'old-hook-token'); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + } + + expect(RateLimiter::attempts(manualWebhookFailureKey('gitlab')))->toBe(1); + + $response = sendManualWebhookPush($this, 'gitlab', $application); + + $response->assertOk(); + expect($response->getContent())->toContain('Deployment queued'); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeTrue(); + }); + + test('gitlab deliveries with distinct wrong tokens lock the scope after 30', function () { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); + + for ($i = 0; $i < 30; $i++) { + $response = sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: "guess-{$i}"); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + } + + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: 'guess-30')->assertStatus(429); + sendManualWebhookPush($this, 'gitlab', $application)->assertStatus(429); + // A token that was already counted is also rejected during the lockout. + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: 'guess-0')->assertStatus(429); + + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + }); + + test('repeated tokens do not extend the guess limit', function () { + $application = createApplicationWithWebhook(); + + // Repeats of counted tokens between new guesses do not reset or skip the count. + for ($i = 0; $i < 29; $i++) { + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: "guess-{$i}")->assertOk(); + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: 'guess-0')->assertOk(); + } + expect(RateLimiter::attempts(manualWebhookFailureKey('gitlab')))->toBe(29); + + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: 'guess-29')->assertOk(); + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: 'guess-30')->assertStatus(429); + }); + + test('failure tracking stores no raw gitlab token and stays bounded', function () { + $application = createApplicationWithWebhook(); + $rawToken = 'raw-token-that-must-never-be-stored'; + + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: $rawToken); + for ($i = 0; $i < 40; $i++) { + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: "{$rawToken}-{$i}"); + } + + $stored = serializedWebhookCacheStore(); + expect($stored)->not->toContain($rawToken); + expect($stored)->not->toContain($application->manual_webhook_secret_gitlab); + + $seen = Cache::get(manualWebhookFailureKey('gitlab').':seen'); + expect($seen)->toBeArray(); + expect(count($seen))->toBeLessThanOrEqual(30); + foreach ($seen as $marker) { + expect($marker)->toMatch('/\A[0-9a-f]{64}\z/'); + } + }); + + test('a repeated wrong token counts again in a new failure window', function () { + $application = createApplicationWithWebhook(); + + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: 'old-hook-token'); + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: 'old-hook-token'); + expect(RateLimiter::attempts(manualWebhookFailureKey('gitlab')))->toBe(1); + + $this->travel(61)->seconds(); + + sendManualWebhookPush($this, 'gitlab', $application, validSignature: false, wrongSecret: 'old-hook-token'); + expect(RateLimiter::attempts(manualWebhookFailureKey('gitlab')))->toBe(1); + }); + + test('identical redeliveries of a signed payload count once', function (string $provider) { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); + + for ($i = 0; $i < 40; $i++) { + $response = sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret'); + + $response->assertOk(); + expect($response->getContent())->toContain('Invalid signature'); + } + + expect(RateLimiter::attempts(manualWebhookFailureKey($provider)))->toBe(1); + + $response = sendManualWebhookPush($this, $provider, $application); + + $response->assertOk(); + expect($response->getContent())->toContain('Deployment queued'); + })->with(['github', 'bitbucket', 'gitea']); + + test('different wrong signatures for the same payload still lock after 30', function (string $provider) { + Queue::fake(); + $application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook()); + + for ($i = 0; $i < 30; $i++) { + sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: "guess-{$i}")->assertOk(); + } + + sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'guess-30')->assertStatus(429); + sendManualWebhookPush($this, $provider, $application)->assertStatus(429); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + })->with(['github', 'bitbucket', 'gitea']); + + test('the same wrong signature for different payloads counts every payload', function (string $provider) { + $application = createApplicationWithWebhook(); + + for ($i = 0; $i < 30; $i++) { + sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: "commit-{$i}")->assertOk(); + } + + sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: 'commit-30')->assertStatus(429); + })->with(['github', 'bitbucket', 'gitea']); +});