Files
coolify/tests/Feature/Webhook/WebhookHmacTest.php
T
Andras Bacsai 554bd354bf fix: support host storage paths and clean up cancelled deployments
Allow validated host file and directory mounts outside resource directories
while protecting external paths from deletion.

Clean up cancelled deployment containers and clear tracked process IDs.
Preserve legacy KeyDB and Dragonfly password parsing, accept server backup
paths without extension restrictions, and allow Compose variable proxy ports.
2026-10-05 11:08:52 +02:00

1442 lines
64 KiB
PHP

<?php
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
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;
uses(RefreshDatabase::class);
beforeEach(function () {
// Tests below change server settings; the static Server identity map must
// not carry a cached server (same id after rollback) into the next test.
Server::flushIdentityMap();
});
test('manual webhook routes are not rate limited per request', function (string $provider) {
$route = Route::getRoutes()->match(Request::create("/webhooks/source/{$provider}/events/manual", 'POST'));
expect(collect($route->gatherMiddleware())->filter(fn (mixed $middleware): bool => is_string($middleware) && str_starts_with($middleware, 'throttle')))->toBeEmpty();
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
/**
* 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', array $extraServer = []): TestResponse
{
$secret = $validSignature ? $application->{"manual_webhook_secret_{$provider}"} : ($wrongSecret ?? 'wrong-secret-'.Str::random(24));
$server = $extraServer + ['REMOTE_ADDR' => $ip, 'CONTENT_TYPE' => 'application/json'];
if ($provider === 'gitlab') {
$payload = json_encode([
'object_kind' => 'push',
'ref' => "refs/heads/{$branch}",
'project' => ['path_with_namespace' => $repository],
'after' => $commit,
'commits' => [],
]);
return $test->call('POST', '/webhooks/source/gitlab/events/manual', [], [], [], $server + [
'HTTP_X-Gitlab-Token' => $secret,
], $payload);
}
if ($provider === 'bitbucket') {
$payload = json_encode([
'push' => ['changes' => [['new' => ['name' => $branch, 'target' => ['hash' => $commit]]]]],
'repository' => ['full_name' => $repository],
]);
return $test->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], $server + [
'HTTP_X-Event-Key' => 'repo:push',
'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, $secret),
], $payload);
}
$payload = json_encode([
'ref' => "refs/heads/{$branch}",
'repository' => ['full_name' => $repository],
'after' => $commit,
'commits' => [],
]);
$eventHeader = $provider === 'github' ? 'HTTP_X-GitHub-Event' : 'HTTP_X-Gitea-Event';
return $test->call('POST', "/webhooks/source/{$provider}/events/manual", [], [], [], $server + [
$eventHeader => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
], $payload);
}
function manualWebhookFailureKey(string $provider, string $ip = '203.0.113.10', string $repository = 'test-org/test-repo', ?string $branch = 'main'): string
{
$helper = new class
{
use MatchesManualWebhookApplications;
public function key(Request $request, string $provider, string $repository, ?string $branch): string
{
return $this->manualWebhookFailureRateLimitKey($request, $provider, $this->manualWebhookRepositoryFullName($repository), $branch);
}
};
return $helper->key(Request::create('/', 'POST', server: ['REMOTE_ADDR' => $ip]), $provider, $repository, $branch);
}
function lockOutManualWebhookRepository(TestCase $test, string $provider, Application $application, string $repository = 'test-org/test-repo', string $branch = 'main'): void
{
for ($i = 0; $i < 30; $i++) {
$response = sendManualWebhookPush($test, $provider, $application, validSignature: false, repository: $repository, branch: $branch);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
}
sendManualWebhookPush($test, $provider, $application, validSignature: false, repository: $repository, branch: $branch)
->assertStatus(429)
->assertHeader('Retry-After');
}
function makeWebhookApplicationServerFunctional(Application $application): Application
{
$application->destination->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
return $application->refresh();
}
describe('Manual Webhook Failed Authentication Rate Limiting', function () {
test('a locked failure scope rejects invalid deliveries', function (string $provider) {
Queue::fake();
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
lockOutManualWebhookRepository($this, $provider, $application);
sendManualWebhookPush($this, $provider, $application, validSignature: false)
->assertStatus(429)
->assertHeader('Retry-After');
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('valid signed deliveries do not consume the failure limit', function (string $provider) {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 80; $i++) {
$response = sendManualWebhookPush($this, $provider, $application);
expect($response->getStatusCode())->toBe(200);
expect($response->getContent())->not->toContain('Invalid signature');
}
expect(RateLimiter::attempts(manualWebhookFailureKey($provider)))->toBe(0);
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('a signed delivery is processed while failed deliveries from the same proxy address lock the scope', function (string $provider) {
Queue::fake();
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
// On self-hosted instances every delivery reaches PHP from the proxy container.
$proxyAddress = '10.0.1.5';
for ($i = 0; $i < 31; $i++) {
sendManualWebhookPush($this, $provider, $application, validSignature: false, ip: $proxyAddress, extraServer: ['HTTP_X_FORWARDED_FOR' => '198.51.100.66']);
}
sendManualWebhookPush($this, $provider, $application, validSignature: false, ip: $proxyAddress)->assertStatus(429);
$response = sendManualWebhookPush($this, $provider, $application, ip: $proxyAddress, extraServer: ['HTTP_X_FORWARDED_FOR' => '140.82.115.1']);
$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();
lockOutManualWebhookRepository($this, $provider, $application);
expect(RateLimiter::tooManyAttempts(manualWebhookFailureKey($provider), 30))->toBeTrue();
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
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);
sendManualWebhookPush($this, $provider, $application, validSignature: false, repository: 'TEST-ORG/Test-Repo.git')->assertStatus(429);
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('a wrong secret for one repository does not block a signed delivery for another repository from the same IP', function (string $provider) {
Queue::fake();
$misconfigured = createApplicationWithWebhook(repo: 'other-tenant/misconfigured-repo', overrides: ['name' => 'misconfigured-app']);
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
lockOutManualWebhookRepository($this, $provider, $misconfigured, repository: 'other-tenant/misconfigured-repo');
$response = sendManualWebhookPush($this, $provider, $application);
$response->assertOk();
expect($response->getContent())->toContain('Deployment queued');
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeTrue();
sendManualWebhookPush($this, $provider, $misconfigured, validSignature: false, repository: 'other-tenant/misconfigured-repo')->assertStatus(429);
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('deliveries for unknown repositories do not block signed deliveries for known repositories', function (string $provider) {
Queue::fake();
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
for ($i = 0; $i < 40; $i++) {
$response = sendManualWebhookPush($this, $provider, $application, validSignature: false, repository: 'deleted-org/deleted-repo');
expect($response->getStatusCode())->toBeIn([200, 429]);
}
$response = sendManualWebhookPush($this, $provider, $application);
$response->assertOk();
expect($response->getContent())->toContain('Deployment queued');
expect(RateLimiter::attempts(manualWebhookFailureKey($provider)))->toBe(0);
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('pushes to untracked branches do not block the deployed branch', function (string $provider) {
Queue::fake();
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
for ($i = 0; $i < 40; $i++) {
sendManualWebhookPush($this, $provider, $application, branch: 'feature/untracked');
}
$response = sendManualWebhookPush($this, $provider, $application);
$response->assertOk();
expect($response->getContent())->toContain('Deployment queued');
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
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', commit: sprintf('abc%04d', $i));
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
}
sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo', commit: 'abc0030')->assertStatus(429);
});
test('valid deliveries do not count when another matching application has a different secret', function () {
$application = createApplicationWithWebhook();
createApplicationWithWebhook(overrides: ['name' => 'second-webhook-test-app']);
for ($i = 0; $i < 35; $i++) {
$response = sendManualWebhookPush($this, 'github', $application);
$response->assertOk();
expect($response->getContent())->not->toContain('Invalid signature');
}
expect(RateLimiter::attempts(manualWebhookFailureKey('github')))->toBe(0);
});
test('gitlab deliveries without a token are rejected and do not count as failed authentication', function () {
createApplicationWithWebhook();
for ($i = 0; $i < 40; $i++) {
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test-repo'],
]);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
}
expect(RateLimiter::attempts(manualWebhookFailureKey('gitlab', '127.0.0.1')))->toBe(0);
});
test('ping deliveries do not count as failures', function () {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 40; $i++) {
$this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_X-GitHub-Event' => 'ping',
'CONTENT_TYPE' => 'application/json',
], '{}')->assertOk();
}
$response = sendManualWebhookPush($this, 'github', $application);
$response->assertOk();
expect($response->getContent())->not->toContain('Invalid signature');
});
test('failures on one provider do not block another provider', function () {
$application = createApplicationWithWebhook();
lockOutManualWebhookRepository($this, 'github', $application);
foreach (['gitlab', 'bitbucket', 'gitea'] as $provider) {
$response = sendManualWebhookPush($this, $provider, $application);
$response->assertOk();
expect($response->getContent())->not->toContain('Invalid signature');
}
});
test('valid deliveries from a different client IP are not blocked', function () {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 30; $i++) {
sendManualWebhookPush($this, 'github', $application, validSignature: false, ip: '198.51.100.7');
}
sendManualWebhookPush($this, 'github', $application, validSignature: false, ip: '198.51.100.7')->assertStatus(429);
$response = sendManualWebhookPush($this, 'github', $application, ip: '203.0.113.10');
$response->assertOk();
expect($response->getContent())->not->toContain('Invalid signature');
});
});
function createApplicationWithWebhook(string $repo = 'test-org/test-repo', string $branch = 'main', array $overrides = []): Application
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
return Application::create(array_merge([
'name' => 'webhook-test-app',
'git_repository' => "https://github.com/{$repo}",
'git_branch' => $branch,
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
], $overrides));
}
describe('GitHub Manual Webhook HMAC', function () {
test('rejects push when secret is empty', function () {
$app = createApplicationWithWebhook();
DB::table('applications')->where('id', $app->id)->update([
'manual_webhook_secret_github' => null,
]);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with forged hash', function () {
$app = createApplicationWithWebhook();
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('accepts push with valid hash', function () {
$app = createApplicationWithWebhook();
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$hmac = hash_hmac('sha256', $payload, $secret);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->not->toContain('Invalid signature');
expect($content)->not->toContain('Webhook secret not configured');
});
});
describe('GitHub App Webhook HMAC', function () {
test('rejects push when app webhook secret is empty', function () {
$team = Team::factory()->create();
GithubApp::create([
'uuid' => (string) str()->uuid(),
'name' => 'github-app-webhook-test',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 1234567890,
'webhook_secret' => null,
'team_id' => $team->id,
]);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['id' => 987654321],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
});
describe('GitLab Manual Webhook HMAC', function () {
test('rejects push when secret is empty', function () {
$app = createApplicationWithWebhook();
DB::table('applications')->where('id', $app->id)->update([
'manual_webhook_secret_gitlab' => null,
]);
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
], [
'X-Gitlab-Token' => 'attacker-supplied-token',
]);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with wrong token', function () {
$app = createApplicationWithWebhook();
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
], [
'X-Gitlab-Token' => 'wrong-token',
]);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('accepts push with valid token', function () {
$app = createApplicationWithWebhook();
$secret = $app->manual_webhook_secret_gitlab;
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
], [
'X-Gitlab-Token' => $secret,
]);
$response->assertOk();
$content = $response->getContent();
expect($content)->not->toContain('Invalid signature');
expect($content)->not->toContain('Webhook secret not configured');
});
});
describe('Bitbucket Manual Webhook HMAC', function () {
test('rejects push when secret is empty', function () {
$app = createApplicationWithWebhook();
DB::table('applications')->where('id', $app->id)->update([
'manual_webhook_secret_bitbucket' => null,
]);
$payload = json_encode([
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
'repository' => ['full_name' => 'test-org/test-repo'],
]);
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
'HTTP_X-Event-Key' => 'repo:push',
'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, ''),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with non-sha256 algorithm', function () {
$app = createApplicationWithWebhook();
$secret = $app->manual_webhook_secret_bitbucket;
$payload = json_encode([
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
'repository' => ['full_name' => 'test-org/test-repo'],
]);
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
'HTTP_X-Event-Key' => 'repo:push',
'HTTP_X-Hub-Signature' => 'sha1='.hash_hmac('sha1', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with forged hash', function () {
$app = createApplicationWithWebhook();
$payload = json_encode([
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
'repository' => ['full_name' => 'test-org/test-repo'],
]);
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
'HTTP_X-Event-Key' => 'repo:push',
'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('accepts push with valid sha256 hash', function () {
$app = createApplicationWithWebhook();
$secret = $app->manual_webhook_secret_bitbucket;
$payload = json_encode([
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
'repository' => ['full_name' => 'test-org/test-repo'],
]);
$hmac = hash_hmac('sha256', $payload, $secret);
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
'HTTP_X-Event-Key' => 'repo:push',
'HTTP_X-Hub-Signature' => "sha256={$hmac}",
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->not->toContain('Invalid signature');
expect($content)->not->toContain('Webhook secret not configured');
});
});
describe('Gitea Manual Webhook HMAC', function () {
test('rejects push when secret is empty', function () {
$app = createApplicationWithWebhook();
DB::table('applications')->where('id', $app->id)->update([
'manual_webhook_secret_gitea' => null,
]);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
'HTTP_X-Gitea-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('rejects push with forged hash', function () {
$app = createApplicationWithWebhook();
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
'HTTP_X-Gitea-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
});
test('accepts push with valid hash', function () {
$app = createApplicationWithWebhook();
$secret = $app->manual_webhook_secret_gitea;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$hmac = hash_hmac('sha256', $payload, $secret);
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
'HTTP_X-Gitea-Event' => 'push',
'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->not->toContain('Invalid signature');
expect($content)->not->toContain('Webhook secret not configured');
});
});
describe('Manual Webhook HMAC in the local environment', function () {
test('rejects an invalid signature when APP_ENV is local', function (string $provider) {
config(['app.env' => 'local']);
Queue::fake();
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
$response = sendManualWebhookPush($this, $provider, $application, validSignature: false);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature')
->not->toContain('Deployment queued');
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
})->with(['github', 'gitea', 'bitbucket']);
test('accepts a valid signature when APP_ENV is local', function (string $provider) {
config(['app.env' => 'local']);
Queue::fake();
$application = makeWebhookApplicationServerFunctional(createApplicationWithWebhook());
$response = sendManualWebhookPush($this, $provider, $application);
$response->assertOk();
expect($response->getContent())->toContain('Deployment queued')
->not->toContain('Invalid signature');
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeTrue();
})->with(['github', 'gitea', 'bitbucket']);
});
describe('Manual Webhook Repository Matching', function () {
test('github rejects empty repository without leaking applications', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => ''],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid repository')
->not->toContain('secret-github-app')
->not->toContain($app->uuid);
});
test('github does not match repository substrings', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid signature.')
->not->toContain('secret-github-app')
->not->toContain($app->uuid);
});
test('github invalid signature does not leak matched application identifiers', function () {
$app = createApplicationWithWebhook(overrides: ['name' => 'secret-github-app']);
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid signature')
->not->toContain('secret-github-app')
->not->toContain($app->uuid)
->not->toContain('application_uuid')
->not->toContain('application_name');
});
test('manual webhooks reject empty repositories for every provider without leaking applications', function (string $provider, string $uri, array $payload, array $headers) {
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
$body = json_encode($payload);
$server = ['CONTENT_TYPE' => 'application/json'];
foreach ($headers as $name => $value) {
$server[$name] = $value;
}
$response = $this->call('POST', $uri, [], [], [], $server, $body);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid repository')
->not->toContain("secret-{$provider}-app")
->not->toContain($app->uuid);
})->with([
'gitlab' => [
'gitlab',
'/webhooks/source/gitlab/events/manual',
[
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => ''],
'after' => 'abc1234',
'commits' => [],
],
['HTTP_X-Gitlab-Token' => 'wrong-token'],
],
'bitbucket' => [
'bitbucket',
'/webhooks/source/bitbucket/events/manual',
[
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
'repository' => ['full_name' => ''],
],
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
],
'gitea' => [
'gitea',
'/webhooks/source/gitea/events/manual',
[
'ref' => 'refs/heads/main',
'repository' => ['full_name' => ''],
'after' => 'abc1234',
'commits' => [],
],
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
],
]);
test('manual webhooks do not match repository substrings for every provider', function (string $provider, string $uri, array $payload, array $headers) {
$app = createApplicationWithWebhook(overrides: ['name' => "secret-{$provider}-app"]);
$body = json_encode($payload);
$server = ['CONTENT_TYPE' => 'application/json'];
foreach ($headers as $name => $value) {
$server[$name] = $value;
}
$response = $this->call('POST', $uri, [], [], [], $server, $body);
$response->assertOk();
$content = $response->getContent();
expect($content)->toContain('Invalid signature.')
->not->toContain("secret-{$provider}-app")
->not->toContain($app->uuid);
})->with([
'gitlab' => [
'gitlab',
'/webhooks/source/gitlab/events/manual',
[
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test'],
'after' => 'abc1234',
'commits' => [],
],
['HTTP_X-Gitlab-Token' => 'wrong-token'],
],
'bitbucket' => [
'bitbucket',
'/webhooks/source/bitbucket/events/manual',
[
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc1234']]]]],
'repository' => ['full_name' => 'test-org/test'],
],
['HTTP_X-Event-Key' => 'repo:push', 'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue'],
],
'gitea' => [
'gitea',
'/webhooks/source/gitea/events/manual',
[
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test'],
'after' => 'abc1234',
'commits' => [],
],
['HTTP_X-Gitea-Event' => 'push', 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue'],
],
]);
test('github matches ssh git repository URL exactly', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@github.com:test-org/test-repo.git',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('github matches an ssh repository URL with a non-git username', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'custom-user@git.example.com:test-org/test-repo.git',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('github matches an ssh repository URL with a non-git username and custom port', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'custom-user@git.example.com:2222/test-org/test-repo.git',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('gitlab matches scp-style ssh repository URL with custom port', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@gitlab.example.com:2222/services/xyz.git',
'git_branch' => 'master',
]);
$secret = $app->manual_webhook_secret_gitlab;
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/master',
'project' => ['path_with_namespace' => 'services/xyz'],
'after' => 'abc1234',
'commits' => [],
], [
'X-Gitlab-Token' => $secret,
]);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('gitlab matches scp-style ssh repository URL without port', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'git@gitlab.example.com:services/xyz.git',
'git_branch' => 'master',
]);
$secret = $app->manual_webhook_secret_gitlab;
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/master',
'project' => ['path_with_namespace' => 'services/xyz'],
'after' => 'abc1234',
'commits' => [],
], [
'X-Gitlab-Token' => $secret,
]);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
test('github matches repository case-insensitively', function () {
$app = createApplicationWithWebhook(overrides: [
'git_repository' => 'https://github.com/Test-Org/Test-Repo.git',
]);
$secret = $app->manual_webhook_secret_github;
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => 'test-org/test-repo'],
'after' => 'abc1234',
'commits' => [],
]);
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response->assertOk();
expect($response->getContent())->not->toContain('No applications found');
});
});
describe('Webhook Secret Auto-Generation', function () {
test('auto-generates webhook secrets on application creation', function () {
$app = createApplicationWithWebhook();
expect($app->manual_webhook_secret_github)->not->toBeEmpty();
expect($app->manual_webhook_secret_gitlab)->not->toBeEmpty();
expect($app->manual_webhook_secret_bitbucket)->not->toBeEmpty();
expect($app->manual_webhook_secret_gitea)->not->toBeEmpty();
expect(strlen($app->manual_webhook_secret_github))->toBe(40);
expect(strlen($app->manual_webhook_secret_gitlab))->toBe(40);
expect(strlen($app->manual_webhook_secret_bitbucket))->toBe(40);
expect(strlen($app->manual_webhook_secret_gitea))->toBe(40);
});
test('encrypts webhook secrets at rest', function () {
$app = createApplicationWithWebhook();
$plaintext = $app->manual_webhook_secret_github;
$raw = DB::table('applications')->where('id', $app->id)->first();
expect($raw->manual_webhook_secret_github)->not->toBe($plaintext);
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 = 'abc1234'): 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: 'abc1234') + ['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' => 'abc1234']], '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' => 'abc1234',
]);
$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' => 'abc1234',
];
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);
// 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();
sendManualWebhookPush($this, 'gitlab', $application)->assertOk()->assertSee('Deployment queued');
});
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);
expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse();
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) {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 30; $i++) {
sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: sprintf('abc%04d', $i))->assertOk();
}
sendManualWebhookPush($this, $provider, $application, validSignature: false, wrongSecret: 'old-hook-secret', commit: 'abc0030')->assertStatus(429);
})->with(['github', 'bitbucket', 'gitea']);
});