mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-07 14:36:44 -04:00
Failed manual webhook deliveries were counted per provider and source IP. On Coolify Cloud many customers share the Git provider IP, so one repository with a wrong secret locked out valid deliveries of all other repositories for 60 seconds. The failure key now also contains the repository and branch. Guessing the secret of one application stays limited to 30 tries per minute, and a lockout rejects all deliveries in that scope, also correct ones. A GitLab request without a token no longer counts as a failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
5.6 KiB
PHP
135 lines
5.6 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)->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')->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');
|
|
});
|