fix(webhooks): handle pushes without commits and count only distinct failures

- GitLab, GitHub, and Gitea crashed with a 500 on push payloads without
  a commit list, and other malformed payloads (missing repository,
  project, ref, Bitbucket changes, non-string values, unsupported
  Gitea events) also returned 500. They now get a clean response; a
  push with an unknown file list still deploys, and a branch deletion
  does not deploy.
- The manual webhook lockout counts only distinct failed attempts: the
  same wrong GitLab token, or an identical HMAC redelivery, counts
  once, so a misconfigured hook no longer locks out a valid one. Every
  new guess still counts (30 per scope and minute). Attempts are
  stored only as HMAC hashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-27 13:47:01 +02:00
co-authored by Claude Opus 5.5
parent 5b2724621a
commit ebfee2ec3f
10 changed files with 690 additions and 108 deletions
+21 -10
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\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);
}
@@ -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<int, string|null> $messages
* @param array<int, mixed> $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<int, string|null> $messages
* @param array<int, mixed> $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);
@@ -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);
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Webhook\Concerns;
use App\Models\Application;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Reads push webhook payloads defensively.
*
* Git hosts omit the commit list (or send null) for some pushes, for example
* branch creation, branch deletion and some system hooks. Such payloads must
* not crash the handler and must not skip a deployment because of watch paths.
*/
trait ReadsWebhookPushPayload
{
/**
* Branch name from a ref such as "refs/heads/main", or null when the ref is
* missing or not a string.
*/
protected function webhookPushBranch(mixed $ref): ?string
{
if (! is_string($ref) || $ref === '') {
return null;
}
$branch = Str::after($ref, 'refs/heads/');
return $branch === '' ? null : $branch;
}
/**
* A non-empty string from the payload, or null for any other value.
*/
protected function webhookString(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
/**
* Files that the commits of a push add, remove or modify.
*
* Returns null when the payload has no commit list. The changed files are
* then unknown, and watch paths must not skip the deployment.
*
* @return Collection<int, string>|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<int, string>
*/
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<int, string>|null $changedFiles
*/
protected function webhookPushMatchesWatchPaths(Application $application, ?Collection $changedFiles): bool
{
if (blank($application->watch_paths) || $changedFiles === null) {
return true;
}
return $application->isWatchPathsTriggered($changedFiles);
}
}
@@ -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'));
}
}
+24 -19
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\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);
}
+27 -29
View File
@@ -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,
+33 -29
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\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);
}
@@ -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.'];
+390 -7
View File
@@ -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, mixed>|string $payload
* @param array<string, string> $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<string, mixed>
*/
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']);
});