mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-07 06:26:10 -04:00
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:
co-authored by
Claude Opus 5.5
parent
5b2724621a
commit
ebfee2ec3f
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.'];
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user