mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-08 15:06:51 -04:00
- 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>
135 lines
5.7 KiB
PHP
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');
|
|
});
|