fix(webhooks): never lock out manual webhook deliveries with a valid signature

The failed-authentication lockout for manual webhooks was checked before
the signature, and the bucket used the client IP from X-Forwarded-For.
An unauthenticated client could send failed attempts with the Git host's
address for a public repository and branch, and correctly signed
deliveries then got 429, so deployments were lost.

The lockout now applies only to deliveries that fail authentication; a
delivery with a valid secret for a matched application is always
processed. Failures are counted per connection address (REMOTE_ADDR, or
CF-Connecting-IP on cloud), so rotating X-Forwarded-For no longer gives
a new bucket.
This commit is contained in:
Andras Bacsai
2026-10-01 07:16:27 +02:00
parent 7ef7fee59a
commit 13a57cc059
7 changed files with 100 additions and 28 deletions
@@ -82,9 +82,6 @@ class Bitbucket extends Controller
]);
}
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'bitbucket', $full_name, $branch);
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);
@@ -66,7 +66,8 @@ trait MatchesManualWebhookApplications
/**
* Respond to a delivery that could not be authenticated (no matching
* application or no signature) and count it as a failed attempt.
* application or no valid signature). A locked scope gets a 429 without
* counting again; otherwise the delivery is counted as a failed attempt.
*
* Deliveries without a matching application are counted too: the failure
* key is scoped to the repository and branch, so this cannot lock out other
@@ -77,12 +78,19 @@ trait MatchesManualWebhookApplications
*/
protected function unauthenticatedManualWebhookResponse(string $failureKey, string $attempt): Response
{
if ($this->hasTooManyManualWebhookFailures($failureKey)) {
return $this->tooManyManualWebhookFailuresResponse($failureKey);
}
$this->recordManualWebhookFailure($failureKey, $attempt);
return response([$this->unauthenticatedManualWebhookFailurePayload()]);
}
/**
* Return the authorized payloads. When no matched application authorized
* the delivery, respond as an unauthenticated delivery.
*
* @param string $attempt Attempt identity from manualWebhookTokenAttempt() or manualWebhookSignedPayloadAttempt().
*/
protected function manualWebhookResponse(Collection $payloads, string $failureKey, string $attempt): Response
@@ -11,12 +11,16 @@ use Illuminate\Support\Facades\RateLimiter;
/**
* Throttles manual webhook deliveries that fail authentication.
*
* Failures are counted per provider, client IP, repository and branch filter.
* The repository and branch come from the payload and select the applications
* whose secrets are checked, so a guesser can only exhaust the bucket of the
* 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.
* Failures are counted per provider, client address, repository and branch
* filter. The repository and branch come from the payload and select the
* applications whose secrets are checked, so a guesser can only exhaust the
* bucket of the 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.
*
* The lockout applies only to deliveries that fail authentication. A delivery
* with a valid secret for a matched application is always processed, so failed
* attempts from anyone sharing the scope cannot block real deliveries.
*
* 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
@@ -43,7 +47,23 @@ trait ThrottlesManualWebhookFailures
is_scalar($branch) ? (string) $branch : null,
]);
return "manual-webhook-failures:{$provider}:".auth_rate_limit_ip($request).':'.hash('sha256', (string) $scope);
return "manual-webhook-failures:{$provider}:".$this->manualWebhookFailureClientAddress($request).':'.hash('sha256', (string) $scope);
}
/**
* The connection address of the client. X-Forwarded-For is ignored, since
* any client can set it and rotate it to get a new bucket. On cloud, a
* valid CF-Connecting-IP is used, as in auth_rate_limit_ip().
*/
protected function manualWebhookFailureClientAddress(Request $request): string
{
$cloudflareIp = $request->header('CF-Connecting-IP');
if (isCloud() && is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false) {
return $cloudflareIp;
}
return (string) $request->server('REMOTE_ADDR');
}
/**
-3
View File
@@ -74,9 +74,6 @@ class Gitea extends Controller
}
$matched_branch = $x_gitea_event === 'pull_request' ? $base_branch : $branch;
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'gitea', $full_name, $matched_branch);
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();
-3
View File
@@ -93,9 +93,6 @@ class Github extends Controller
default => $base_branch,
};
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'github', $full_name, $matched_branch);
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();
-3
View File
@@ -430,9 +430,6 @@ class Gitlab extends Controller
}
$matched_branch = $x_gitlab_event === 'merge_request' ? $base_branch : $branch;
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'gitlab', $full_name, $matched_branch);
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();
+64 -8
View File
@@ -38,10 +38,10 @@ test('manual webhook routes are not rate limited per request', function (string
* 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 = 'abc1234'): TestResponse
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 = 'abc1234', array $extraServer = []): TestResponse
{
$secret = $validSignature ? $application->{"manual_webhook_secret_{$provider}"} : ($wrongSecret ?? 'wrong-secret-'.Str::random(24));
$server = ['REMOTE_ADDR' => $ip, 'CONTENT_TYPE' => 'application/json'];
$server = $extraServer + ['REMOTE_ADDR' => $ip, 'CONTENT_TYPE' => 'application/json'];
if ($provider === 'gitlab') {
$payload = json_encode([
@@ -137,6 +137,61 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () {
expect(RateLimiter::attempts(manualWebhookFailureKey($provider)))->toBe(0);
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('a signed delivery is processed while the scope is locked by failed attempts', function (string $provider) {
Queue::fake();
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
lockOutManualWebhookRepository($this, $provider, $application);
$response = sendManualWebhookPush($this, $provider, $application);
$response->assertOk();
expect($response->getContent())->toContain('Deployment queued');
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeTrue();
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('a locked scope keeps rejecting invalid signatures without counting them again', function (string $provider) {
$application = createApplicationWithWebhook();
lockOutManualWebhookRepository($this, $provider, $application);
$attempts = RateLimiter::attempts(manualWebhookFailureKey($provider));
sendManualWebhookPush($this, $provider, $application)->assertOk();
sendManualWebhookPush($this, $provider, $application, validSignature: false)
->assertStatus(429)
->assertHeader('Retry-After')
->assertJson(['status' => 'failed', 'message' => 'Too many failed webhook authentication attempts. Try again later.']);
expect(RateLimiter::attempts(manualWebhookFailureKey($provider)))->toBe($attempts);
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('failed attempts are counted per connection address, not per forwarded address', function (string $provider) {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 30; $i++) {
$response = sendManualWebhookPush($this, $provider, $application, validSignature: false, extraServer: ['HTTP_X_FORWARDED_FOR' => "198.51.100.{$i}"]);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
}
sendManualWebhookPush($this, $provider, $application, validSignature: false, extraServer: ['HTTP_X_FORWARDED_FOR' => '198.51.100.200'])->assertStatus(429);
expect(RateLimiter::tooManyAttempts(manualWebhookFailureKey($provider), 30))->toBeTrue();
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('failed attempts use the Cloudflare client address on cloud', function () {
config()->set('constants.coolify.self_hosted', false);
$application = createApplicationWithWebhook();
for ($i = 0; $i < 30; $i++) {
sendManualWebhookPush($this, 'github', $application, validSignature: false, extraServer: ['HTTP_CF_CONNECTING_IP' => '198.51.100.7'])->assertOk();
}
sendManualWebhookPush($this, 'github', $application, validSignature: false, extraServer: ['HTTP_CF_CONNECTING_IP' => '198.51.100.7'])->assertStatus(429);
// Another Cloudflare client behind the same edge address has its own bucket.
sendManualWebhookPush($this, 'github', $application, validSignature: false, extraServer: ['HTTP_CF_CONNECTING_IP' => '198.51.100.8'])->assertOk();
});
test('repeated invalid signatures are throttled after 30 failures', function (string $provider) {
$application = createApplicationWithWebhook();
@@ -145,16 +200,13 @@ describe('Manual Webhook Failed Authentication Rate Limiting', function () {
expect(RateLimiter::tooManyAttempts(manualWebhookFailureKey($provider), 30))->toBeTrue();
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('a locked out repository rejects every delivery for that repository and branch before verification', function (string $provider) {
test('a locked out repository rejects failed deliveries for that repository and branch', function (string $provider) {
Queue::fake();
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
lockOutManualWebhookRepository($this, $provider, $application);
sendManualWebhookPush($this, $provider, $application, validSignature: false)->assertStatus(429);
// A correct guess during the lockout must not succeed, otherwise the
// lockout does not limit how fast a secret can be guessed.
sendManualWebhookPush($this, $provider, $application)->assertStatus(429);
sendManualWebhookPush($this, $provider, $application, validSignature: false, repository: 'TEST-ORG/Test-Repo.git')->assertStatus(429);
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
@@ -1270,11 +1322,13 @@ describe('Manual Webhook Repeated Failed Deliveries', function () {
}
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();
// The correct token is still processed during the lockout.
sendManualWebhookPush($this, 'gitlab', $application)->assertOk()->assertSee('Deployment queued');
});
test('repeated tokens do not extend the guess limit', function () {
@@ -1353,8 +1407,10 @@ describe('Manual Webhook Repeated Failed Deliveries', function () {
}
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();
// A correctly signed delivery is still processed during the lockout.
sendManualWebhookPush($this, $provider, $application)->assertOk()->assertSee('Deployment queued');
})->with(['github', 'bitbucket', 'gitea']);
test('the same wrong signature for different payloads counts every payload', function (string $provider) {