Files
coolify/tests/Feature/AdvisorySecurityRegressionTest.php
T
Andras Bacsai 554b79e8dd feat: add Traefik ACME cert UI and shared managed DNS record ownership
- 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
2026-09-25 19:00:00 +02:00

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');
});