fix: enforce webhook lockouts and stop cancelled runner provisioning

- Manual webhooks (GitHub, GitLab, Gitea, Bitbucket) now reject all
  deliveries for a locked-out scope before reading secrets or verifying
  signatures, including correctly signed ones, until the window expires.
- ProvisionGithubRunnerJob checks whether the execution has already
  finished before and after each provisioning step, and only sets
  ready_at or Failed while it is still occupying/provisioning. A runner
  that is cancelled, timed out or completed mid-provisioning is no longer
  started, retried or overwritten.
- VolumeCloneJob creates bind-volume clones as plain local volumes
  without the source driver options, so the clone never mounts the
  source host directory.
- Image pulls for additional servers no longer ignore docker pull errors.
- Add tests for each change.
This commit is contained in:
Andras Bacsai
2026-10-02 18:24:52 +02:00
parent 5fd7191d4a
commit f95ddd97fa
12 changed files with 244 additions and 36 deletions
@@ -82,6 +82,10 @@ 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);
@@ -18,9 +18,9 @@ use Illuminate\Support\Facades\RateLimiter;
* 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.
* After the failure limit is reached, the scope rejects all deliveries before
* reading application secrets or verifying signatures. Correctly signed
* deliveries in that scope must wait until the failure window expires.
*
* 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
+4
View File
@@ -74,6 +74,10 @@ 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();
+4
View File
@@ -95,6 +95,10 @@ 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();
+4
View File
@@ -430,6 +430,10 @@ 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();