mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 16:17:10 +00:00
feat: harden auth flows and server mobile navigation
Add normalized email identity rate limiting for registration and forgot-password requests, and refresh Sentinel status from restart broadcasts. Rework server sidebars and navbar for mobile menus and active status visibility.
This commit is contained in:
1 parent
94079c90f2
commit
b0f0f7d8d0
42 files changed
+1064
-128
No files matched your search
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->forceCreate(['id' => 0]);
|
||||
});
|
||||
|
||||
it('rate limits repeated forgot password attempts from the same ip', function () {
|
||||
foreach (range(1, 3) as $attempt) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.20'])
|
||||
->post('/forgot-password', [
|
||||
'email' => "user{$attempt}@example.com",
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.20'])
|
||||
->post('/forgot-password', [
|
||||
'email' => 'blocked@example.com',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
||||
it('rate limits dotted plus-address forgot password variants of the same email identity across ips', function () {
|
||||
$emails = [
|
||||
'ke.vinmcfadden+one@btinternet.com',
|
||||
'kevin.mcfadden+two@btinternet.com',
|
||||
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 30)])
|
||||
->post('/forgot-password', [
|
||||
'email' => $email,
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
|
||||
->post('/forgot-password', [
|
||||
'email' => 'k.evin.mcfadden+four@btinternet.com',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
@@ -143,4 +143,107 @@ it('keeps configuration sidebars hidden until desktop breakpoint', function () {
|
||||
|
||||
expect(file_get_contents(resource_path('views/components/service-database/sidebar.blade.php')))
|
||||
->toContain('sub-menu-wrapper hidden md:flex');
|
||||
|
||||
$serverSidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
|
||||
|
||||
expect($serverSidebar)
|
||||
->toContain('server-mobile-section-label')
|
||||
->toContain('server-mobile-section')
|
||||
->toContain('Section')
|
||||
->toContain('select w-full')
|
||||
->toContain('aria-label="Server menu"')
|
||||
->toContain('border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200')
|
||||
->toContain('<optgroup label="Server">')
|
||||
->toContain('<optgroup label="Configuration">')
|
||||
->toContain("'route' => 'server.proxy'")
|
||||
->toContain("'navigate' => false")
|
||||
->toContain("value.startsWith('location|')")
|
||||
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
|
||||
->toContain('x-on:livewire:navigated.window="syncFromLocation()"')
|
||||
->toContain('sub-menu-wrapper hidden md:flex')
|
||||
->not->toContain('sub-menu-wrapper">');
|
||||
|
||||
$serverNavbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||
|
||||
expect($serverNavbar)
|
||||
->toContain('server-mobile-actions')
|
||||
->toContain('Actions')
|
||||
->toContain('pb-0 md:pb-6')
|
||||
->toContain('navbar-main')
|
||||
->toContain('server-mobile-actions" class="mt-2 mb-3')
|
||||
->toContain('hidden min-h-10')
|
||||
->toContain('md:flex')
|
||||
->toContain('md:hidden')
|
||||
->toContain('flex flex-nowrap gap-2 overflow-x-auto')
|
||||
->toContain('server-mobile-restart-proxy-trigger')
|
||||
->toContain('server-mobile-stop-proxy-trigger')
|
||||
->toContain('class="button shrink-0"')
|
||||
->toContain('hidden gap-2 md:flex');
|
||||
|
||||
$serverMobileActions = serverMobileActionsMarkup($serverNavbar);
|
||||
|
||||
expect(strpos($serverMobileActions, 'Restart Proxy'))
|
||||
->toBeLessThan(strpos($serverMobileActions, 'Stop Proxy'))
|
||||
->toBeLessThan(strpos($serverMobileActions, 'Traefik Dashboard'));
|
||||
|
||||
foreach ([
|
||||
'views/components/server/sidebar-proxy.blade.php' => 'Proxy menu',
|
||||
'views/components/server/sidebar-sentinel.blade.php' => 'Sentinel menu',
|
||||
'views/components/server/sidebar-security.blade.php' => 'Security menu',
|
||||
] as $view => $label) {
|
||||
$sidebar = file_get_contents(resource_path($view));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain('server-mobile-section-label')
|
||||
->toContain('server-mobile-section')
|
||||
->toContain('Section')
|
||||
->toContain('select w-full')
|
||||
->toContain('aria-label="'.$label.'"')
|
||||
->toContain('border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200')
|
||||
->toContain('<optgroup label="Server">')
|
||||
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
|
||||
->toContain('x-on:livewire:navigated.window="syncFromLocation()"')
|
||||
->toContain('sub-menu-wrapper hidden md:flex')
|
||||
->not->toContain('sub-menu-wrapper">');
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'views/livewire/server/show.blade.php',
|
||||
'views/livewire/server/advanced.blade.php',
|
||||
'views/livewire/server/private-key/show.blade.php',
|
||||
'views/livewire/server/ca-certificate/show.blade.php',
|
||||
'views/livewire/server/cloud-provider-token/show.blade.php',
|
||||
'views/livewire/server/cloudflare-tunnel.blade.php',
|
||||
'views/livewire/server/docker-cleanup.blade.php',
|
||||
'views/livewire/server/destinations.blade.php',
|
||||
'views/livewire/server/log-drains.blade.php',
|
||||
'views/livewire/server/charts.blade.php',
|
||||
'views/livewire/server/swarm.blade.php',
|
||||
'views/livewire/server/delete.blade.php',
|
||||
'views/livewire/server/proxy/show.blade.php',
|
||||
'views/livewire/server/proxy/logs.blade.php',
|
||||
'views/livewire/server/proxy/dynamic-configurations.blade.php',
|
||||
'views/livewire/server/sentinel/show.blade.php',
|
||||
'views/livewire/server/sentinel/logs.blade.php',
|
||||
'views/livewire/server/security/patches.blade.php',
|
||||
'views/livewire/server/security/terminal-access.blade.php',
|
||||
] as $view) {
|
||||
expect(file_get_contents(resource_path($view)))
|
||||
->toContain('gap-4 md:gap-8 md:flex-row')
|
||||
->toContain('md:flex-row')
|
||||
->not->toContain('sm:flex-row')
|
||||
->not->toContain('class="flex flex-col h-full gap-8 md:flex-row');
|
||||
}
|
||||
});
|
||||
|
||||
function serverMobileActionsMarkup(string $navbar): string
|
||||
{
|
||||
$actionsPosition = strpos($navbar, 'id="server-mobile-actions"');
|
||||
$restartModalPosition = strpos($navbar, '<x-modal-confirmation', $actionsPosition);
|
||||
|
||||
if ($actionsPosition === false || $restartModalPosition === false || $actionsPosition > $restartModalPosition) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return substr($navbar, $actionsPosition, $restartModalPosition - $actionsPosition);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->forceCreate([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
|
||||
User::factory()->create(['id' => 0, 'email' => 'root@example.com']);
|
||||
});
|
||||
|
||||
it('rate limits repeated registration attempts from the same ip', function () {
|
||||
foreach (range(1, 3) as $attempt) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
|
||||
->post('/register', [
|
||||
'name' => "Attack User {$attempt}",
|
||||
'email' => "attacker{$attempt}@example.com",
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
auth()->logout();
|
||||
$this->flushSession();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
|
||||
->post('/register', [
|
||||
'name' => 'Blocked User',
|
||||
'email' => 'blocked@example.com',
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
||||
it('rate limits dotted plus-address variants of the same email identity across ips', function () {
|
||||
$emails = [
|
||||
'ke.vinmcfadden+one@btinternet.com',
|
||||
'kevin.mcfadden+two@btinternet.com',
|
||||
'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 10)])
|
||||
->post('/register', [
|
||||
'name' => "Attack User {$index}",
|
||||
'email' => $email,
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
auth()->logout();
|
||||
$this->flushSession();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
|
||||
->post('/register', [
|
||||
'name' => 'Blocked User',
|
||||
'email' => 'k.evin.mcfadden+four@btinternet.com',
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
@@ -82,18 +82,9 @@ it('skips the job when the second push is identical', function () use ($running)
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
});
|
||||
|
||||
it('only audits sentinel pushes that dispatch a state update', function () use ($running) {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('sentinel.metrics_pushed', Mockery::on(function (array $context) {
|
||||
return $context['server_uuid'] === $this->server->uuid
|
||||
&& $context['team_id'] === $this->team->id;
|
||||
}));
|
||||
it('does not audit successful sentinel pushes', function () use ($running) {
|
||||
Log::shouldReceive('channel')->with('audit')->never();
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\Navbar;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
@@ -52,3 +53,31 @@ it('shows sentinel sync status after the server is validated', function () {
|
||||
->assertSee('Sentinel')
|
||||
->assertSee('In sync');
|
||||
});
|
||||
|
||||
it('listens for sentinel restarted broadcasts', function () {
|
||||
[$server, , $team] = makeNavbarServer(isFunctional: true);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $server])
|
||||
->assertSet('server.uuid', $server->uuid);
|
||||
|
||||
expect(app(Navbar::class)->getListeners())
|
||||
->toHaveKey("echo-private:team.{$team->id},SentinelRestarted", 'refreshSentinelStatus');
|
||||
});
|
||||
|
||||
it('refreshes sentinel status when sentinel restarts for the server', function () {
|
||||
[$server] = makeNavbarServer(isFunctional: true);
|
||||
$server->forceFill(['sentinel_updated_at' => now()->subDay()])->save();
|
||||
|
||||
expect($server->fresh()->isSentinelLive())->toBeFalse();
|
||||
|
||||
$component = Livewire::test('server.navbar', ['server' => $server->fresh()])
|
||||
->assertSee('Sentinel')
|
||||
->assertSee('Out of sync');
|
||||
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
$component
|
||||
->call('refreshSentinelStatus', ['serverUuid' => $server->uuid])
|
||||
->assertSee('In sync')
|
||||
->assertDontSee('Out of sync');
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
it('normalizes plus and dotted email identity variants', function () {
|
||||
expect(normalize_email_identity('Ke.VinMcFadden+one@BTInternet.com'))->toBe('kevinmcfadden@btinternet.com');
|
||||
expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com');
|
||||
});
|
||||
|
||||
it('returns null for blank or malformed email identities', function (?string $email) {
|
||||
expect(normalize_email_identity($email))->toBeNull();
|
||||
})->with([
|
||||
null,
|
||||
'',
|
||||
'not-an-email',
|
||||
'@example.com',
|
||||
'local@',
|
||||
]);
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
test('server header renders subtitle before status badges', function () {
|
||||
$navbar = file_get_contents(__DIR__.'/../../resources/views/livewire/server/navbar.blade.php');
|
||||
|
||||
$titlePosition = strpos($navbar, '<h1>Server</h1>');
|
||||
$subtitlePosition = strpos($navbar, 'data-testid="server-subtitle"');
|
||||
$statusPosition = strpos($navbar, 'data-testid="server-status-summary"');
|
||||
|
||||
expect($navbar)->not->toContain('class="subtitle"')
|
||||
->not->toContain('text-neutral-300')
|
||||
->toContain('text-neutral-600 dark:text-neutral-400');
|
||||
|
||||
expect($titlePosition)->not->toBeFalse()
|
||||
->and($subtitlePosition)->not->toBeFalse()
|
||||
->and($statusPosition)->not->toBeFalse()
|
||||
->and($titlePosition)->toBeLessThan($subtitlePosition)
|
||||
->and($subtitlePosition)->toBeLessThan($statusPosition);
|
||||
});
|
||||
Reference in new issue
Block a user