From bd6ba03c3c14bcf2731a50002ba6ce86adbeb487 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 22:40:31 +0200 Subject: [PATCH] fix(security): register EnsureSecurityOnboarded as persistent Livewire middleware Re-applies the onboarding gate to /livewire/update component calls, not just the initial page GET, so a component mounted while onboarded cannot run destructive actions after the account is reverted to must_change_password mid-session. Addresses the codebase-wide Codex finding flagged during the SSH-key-provision review. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Providers/AppServiceProvider.php | 11 +++ tests/Feature/PersistentSecurityGateTest.php | 89 ++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 tests/Feature/PersistentSecurityGateTest.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 28b9831..8e6dad5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,10 @@ namespace App\Providers; +use App\Http\Middleware\EnsureSecurityOnboarded; use App\Services\DeploymentService; use Illuminate\Support\ServiceProvider; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -27,6 +29,15 @@ class AppServiceProvider extends ServiceProvider */ public function boot(DeploymentService $deployment): void { + // Re-apply the onboarding gate to EVERY Livewire component update, not just the + // initial page GET. Livewire only re-runs route middleware that is registered as + // persistent; without this, a component mounted while onboarded could keep running + // destructive actions (hardening, SSH-key provisioning, credential changes) over + // /livewire/update after the account is reverted to must_change_password mid-session. + Livewire::addPersistentMiddleware([ + EnsureSecurityOnboarded::class, + ]); + config([ 'broadcasting.connections.reverb.options.host' => 'reverb', 'broadcasting.connections.reverb.options.port' => 8080, diff --git a/tests/Feature/PersistentSecurityGateTest.php b/tests/Feature/PersistentSecurityGateTest.php new file mode 100644 index 0000000..4cfa904 --- /dev/null +++ b/tests/Feature/PersistentSecurityGateTest.php @@ -0,0 +1,89 @@ +create(['must_change_password' => false]); + $server = Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + + // The destructive SSH action must never be reached once the gate re-runs. + $fleet = Mockery::mock(FleetService::class)->shouldIgnoreMissing(); + $fleet->shouldReceive('removeAuthorizedKey')->never(); + app()->instance(FleetService::class, $fleet); + + // 1. Mount the panel page while fully onboarded — the rendered snapshot carries + // memo.path = servers/{uuid}, the route that bears EnsureSecurityOnboarded. + $html = $this->actingAs($user) + ->get(route('servers.show', $server)) + ->assertOk() + ->getContent(); + + $snapshot = $this->extractSnapshot($html, 'servers.show'); + + // 2. The account is reverted to must_change_password AFTER the component mounted. + $user->forceFill(['must_change_password' => true])->save(); + + // 3. Replay a destructive call against the still-valid snapshot, exactly as the + // browser would on the next interaction. + $response = $this->actingAs($user)->postJson('/livewire/update', [ + 'components' => [[ + 'snapshot' => $snapshot, + 'updates' => [], + 'calls' => [[ + 'path' => '', + 'method' => 'removeKey', + 'params' => ['SHA256:'.str_repeat('a', 43)], + ]], + ]], + ], ['X-Livewire' => 'true']); + + // The persistent gate redirects to the password change before removeKey runs; + // the ->never() expectation above proves the destructive call was blocked. + $response->assertRedirect(route('password.change')); + } + + /** Pull the wire:snapshot JSON for a specific component out of rendered page HTML. */ + private function extractSnapshot(string $html, string $name): string + { + preg_match_all('/wire:snapshot="([^"]*)"/', $html, $matches); + + foreach ($matches[1] as $encoded) { + $json = htmlspecialchars_decode($encoded, ENT_QUOTES | ENT_SUBSTITUTE); + + if ((json_decode($json, true)['memo']['name'] ?? null) === $name) { + return $json; + } + } + + $this->fail("No wire:snapshot found for component [{$name}] in the rendered page."); + } +}