From 7ec2c065bb6de4f7a69bab8fdfccf566903e4ce7 Mon Sep 17 00:00:00 2001 From: boban Date: Thu, 25 Jun 2026 00:41:50 +0200 Subject: [PATCH] fix(hardening): guard against root-login self-lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disabling SSH root login (PermitRootLogin no) severs Clusev's OWN access when it connects AS root — afterwards neither key nor password reaches root. The password-auth toggle already guards against self-lockout; the root toggle did not. Refuse to disable root login while the active credential's username is root, with a clear message: add a non-root sudo user + key and switch Clusev to it first. Co-Authored-By: Claude Opus 4.8 --- app/Services/HardeningService.php | 7 +++++ lang/de/backend.php | 1 + lang/en/backend.php | 1 + tests/Feature/HardeningServiceTest.php | 40 ++++++++++++++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index 365b9e3..498714f 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -206,6 +206,13 @@ class HardeningService return ['ok' => $res['ok'], 'output' => $res['output']]; } + // Lock-out guard: disabling root login (PermitRootLogin no) severs Clusev's OWN access when + // it connects AS root — no key or password can reach root afterwards. Refuse until Clusev is + // switched to a non-root credential (a sudo user with a key), then root login can be disabled. + if ($action === 'ssh_root' && ! $enable && $server->credential?->username === 'root') { + return ['ok' => false, 'output' => __('backend.ssh_root_self_lockout')]; + } + // Lock-out guard: never disable password auth without a usable key path. if ($action === 'ssh_password' && ! $enable) { if ($server->credential?->auth_type === 'password') { diff --git a/lang/de/backend.php b/lang/de/backend.php index 5dcf614..94ad94d 100644 --- a/lang/de/backend.php +++ b/lang/de/backend.php @@ -41,6 +41,7 @@ return [ 'ssh_probe_no_exec' => 'SSH-Anmeldung erfolgreich, aber es lassen sich keine Befehle ausführen.', 'ssh_password_self_lockout' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.', 'ssh_password_no_key' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).', + 'ssh_root_self_lockout' => 'Clusev verbindet sich als root — Root-Login kann nicht deaktiviert werden, sonst sperrt sich Clusev selbst aus. Lege zuerst einen Nicht-Root-Benutzer (mit sudo + SSH-Key) an und stelle Clusev darauf um.', // ── FirewallService: guards / reasons / errors ─────────────────────── 'firewalld_read_only' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.', diff --git a/lang/en/backend.php b/lang/en/backend.php index fe588ef..bd26a00 100644 --- a/lang/en/backend.php +++ b/lang/en/backend.php @@ -41,6 +41,7 @@ return [ 'ssh_probe_no_exec' => 'SSH login succeeded, but commands could not be executed.', 'ssh_password_self_lockout' => 'Clusev connects itself via password — password login cannot be disabled, otherwise Clusev loses access. Set up SSH key access first.', 'ssh_password_no_key' => 'No SSH key in place — password login cannot be disabled (lockout risk).', + 'ssh_root_self_lockout' => 'Clusev connects as root — root login cannot be disabled, otherwise Clusev locks itself out. Add a non-root user (sudo + SSH key) first and switch Clusev to it.', // ── FirewallService: guards / reasons / errors ─────────────────────── 'firewalld_read_only' => 'Rule management for firewalld is read-only in this version — please configure it on the server.', diff --git a/tests/Feature/HardeningServiceTest.php b/tests/Feature/HardeningServiceTest.php index 244935a..6f38d17 100644 --- a/tests/Feature/HardeningServiceTest.php +++ b/tests/Feature/HardeningServiceTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Models\Server; +use App\Models\SshCredential; use App\Services\FirewallService; use App\Services\FleetService; use App\Services\HardeningService; @@ -89,4 +90,43 @@ class HardeningServiceTest extends TestCase $this->assertTrue($au['neutral'] ?? false, 'auto-updates row must be flagged neutral'); $this->assertFalse($au['featureOn'], 'inactive auto-updates → toggle shows enable'); } + + /** + * Lock-out guard: when Clusev connects AS root, disabling SSH root login (PermitRootLogin no) + * would sever its own access — it must be refused before any command runs. + */ + public function test_disabling_root_login_is_refused_when_clusev_connects_as_root(): void + { + $detector = Mockery::mock(OsDetector::class); + $detector->shouldReceive('detect')->andReturn($this->debianProfile()); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldNotReceive('runPrivileged'); // guard must short-circuit before any command + + $server = new Server; + $server->setRelation('credential', new SshCredential(['username' => 'root'])); + + $service = new HardeningService($fleet, Mockery::mock(FirewallService::class), $detector); + $res = $service->apply($server, 'ssh_root', false); + + $this->assertFalse($res['ok']); + $this->assertSame(__('backend.ssh_root_self_lockout'), $res['output']); + } + + public function test_disabling_root_login_proceeds_for_a_non_root_credential(): void + { + $detector = Mockery::mock(OsDetector::class); + $detector->shouldReceive('detect')->andReturn($this->debianProfile()); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPrivileged')->once()->andReturn(['ok' => true, 'output' => '']); + + $server = new Server; + $server->setRelation('credential', new SshCredential(['username' => 'deploy'])); + + $service = new HardeningService($fleet, Mockery::mock(FirewallService::class), $detector); + $res = $service->apply($server, 'ssh_root', false); + + $this->assertTrue($res['ok']); + } }