Files
coolify/tests/Feature/AdvisorySecurityRegressionTest.php
T
Andras BacsaiandClaude Opus 5.5 ebfee2ec3f 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>
2026-09-27 13:47:01 +02:00

135 lines
5.7 KiB
PHP

<?php
use App\Http\Controllers\Api\DatabasesController;
use App\Http\Controllers\Api\ServiceApplicationsController;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Http\Middleware\ApiAllowed;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
uses(RefreshDatabase::class);
it('limits login attempts by normalized email independent of IP', function () {
$limiter = RateLimiter::limiter('login');
$first = Request::create('/login', 'POST', ['email' => 'First.Name+tag@gmail.com'], [], [], ['REMOTE_ADDR' => '192.0.2.10']);
$second = Request::create('/login', 'POST', ['email' => 'firstname@gmail.com'], [], [], ['REMOTE_ADDR' => '198.51.100.10']);
$firstLimits = $limiter($first);
$secondLimits = $limiter($second);
expect($firstLimits)->toHaveCount(2)
->and($firstLimits[0]->key)->not->toBe($secondLimits[0]->key)
->and($firstLimits[1]->key)->toBe($secondLimits[1]->key);
});
it('restricts user broadcast channels to their own ID', function () {
$callback = Broadcast::getChannels()['user.{userId}'];
$user = User::factory()->create();
expect($callback($user, (int) $user->id))->toBeTrue()
->and($callback($user, (int) $user->id + 1))->toBeFalse();
});
it('does not require email verification for protected web routes', function () {
InstanceSettings::forceCreate(['id' => 0]);
$user = User::factory()->create(['email_verified_at' => null]);
Team::query()->update(['show_boarding' => false]);
Cache::flush();
$this->actingAs($user)->get('/analytics')->assertOk();
});
it('does not apply the REST API allowlist to MCP and MCP switch routes', function () {
$mcp = Route::getRoutes()->match(Request::create('/mcp', 'POST'));
$enable = Route::getRoutes()->match(Request::create('/api/v1/mcp/enable', 'POST'));
$disable = Route::getRoutes()->match(Request::create('/api/v1/mcp/disable', 'POST'));
expect($mcp)->not->toBeNull()
->and($mcp->gatherMiddleware())->not->toContain(ApiAllowed::class)
->and($enable->gatherMiddleware())->not->toContain(ApiAllowed::class)
->and($disable->gatherMiddleware())->not->toContain(ApiAllowed::class);
});
it('throttles only failed authentication on manual webhook routes', function (string $provider) {
$route = Route::getRoutes()->match(Request::create("/webhooks/source/{$provider}/events/manual", 'POST'));
$request = Request::create("/webhooks/source/{$provider}/events/manual", 'POST', server: ['REMOTE_ADDR' => '192.0.2.44']);
$helper = new class
{
use MatchesManualWebhookApplications;
public function key(Request $request, string $provider): string
{
return $this->manualWebhookFailureRateLimitKey($request, $provider, 'test-org/test-repo', 'main');
}
public function reply(array $payloads, string $failureKey): int
{
return $this->manualWebhookResponse(collect($payloads), $failureKey, $this->manualWebhookTokenAttempt('wrong-token'))->getStatusCode();
}
};
$failureKey = $helper->key($request, $provider);
expect($route->gatherMiddleware())->not->toContain('throttle:60,1');
expect($failureKey)->toStartWith("manual-webhook-failures:{$provider}:192.0.2.44:");
$helper->reply([['status' => 'success', 'message' => 'queued']], $failureKey);
expect(RateLimiter::attempts($failureKey))->toBe(0);
$helper->reply([['status' => 'failed', 'message' => 'Invalid signature.']], $failureKey);
expect(RateLimiter::attempts($failureKey))->toBe(1);
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
it('does not reveal how many applications share a manual webhook repository', function () {
$helper = new class
{
use MatchesManualWebhookApplications;
public function reply(array $payloads): string
{
return $this->manualWebhookResponse(collect($payloads), 'manual-webhook-failures:test', $this->manualWebhookTokenAttempt('wrong-token'))->getContent();
}
};
$failure = ['status' => 'failed', 'message' => 'Invalid signature.'];
expect($helper->reply([$failure, $failure]))->toBe($helper->reply([$failure]));
expect($helper->reply([$failure, ['status' => 'success', 'message' => 'queued']]))
->not->toContain('Invalid signature.');
});
it('never exposes a shown-once database variable even with sensitive read access', function () {
$variable = new EnvironmentVariable;
$variable->forceFill([
'key' => 'SECRET',
'value' => 'secret-value',
'is_shown_once' => true,
]);
request()->attributes->set('can_read_sensitive', true);
$method = new ReflectionMethod(DatabasesController::class, 'removeSensitiveEnvData');
$result = $method->invoke(new DatabasesController, $variable);
expect($result)->not->toHaveKey('value')
->not->toHaveKey('real_value');
});
it('does not include nested service and server details in a service application response', function () {
$application = new ServiceApplication;
$application->forceFill(['name' => 'app']);
$application->setRelation('service', (new Service)->forceFill(['name' => 'service']));
$method = new ReflectionMethod(ServiceApplicationsController::class, 'removeSensitiveData');
$result = $method->invoke(new ServiceApplicationsController, $application);
expect($result)->not->toHaveKey('service');
});