mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-08 15:06:51 -04:00
- Proxy: list and delete Traefik ACME certificates from the server proxy page via new TraefikAcmeService and Get/DeleteTraefikCertificate actions - DNS: track ownership and cross-resource references for managed DNS records so records are only deleted when no longer referenced; release records asynchronously on resource deletion via ReleaseManagedDnsRecordsJob and ManagedDnsRecordCleanup; harden Cloudflare provider deletion results - Databases: fail closed on start when prerequisites or the CA certificate are missing (DatabaseStartException, Server::ensureCaCertificate) and clean up stale start activities via ResourceStartActivity - Webhooks: throttle repeated manual webhook signature failures for GitHub, GitLab, Gitea and Bitbucket - Deployments: improve compose build-context handling and compose file load error reporting - Install scripts: rework terminal UI output in install.sh (stable and nightly) - Misc: settings sidebar accordion fixes, log drain toggle rollback, add Serverside to README sponsors - Add migrations and tests covering the above
128 lines
5.4 KiB
PHP
128 lines
5.4 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 reply(array $payloads, Request $request, string $provider): int
|
|
{
|
|
return $this->manualWebhookResponse(collect($payloads), $request, $provider)->getStatusCode();
|
|
}
|
|
};
|
|
|
|
expect($route->gatherMiddleware())->not->toContain('throttle:60,1');
|
|
|
|
$helper->reply([['status' => 'success', 'message' => 'queued']], $request, $provider);
|
|
expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:192.0.2.44"))->toBe(0);
|
|
|
|
$helper->reply([['status' => 'failed', 'message' => 'Invalid signature.']], $request, $provider);
|
|
expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:192.0.2.44"))->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), Request::create('/webhooks/source/github/events/manual', 'POST'), 'github')->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');
|
|
});
|