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) <noreply@anthropic.com>feat/v1-foundation
parent
8ef907d429
commit
bd6ba03c3c
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsureSecurityOnboarded;
|
||||||
use App\Services\DeploymentService;
|
use App\Services\DeploymentService;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -27,6 +29,15 @@ class AppServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function boot(DeploymentService $deployment): void
|
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([
|
config([
|
||||||
'broadcasting.connections.reverb.options.host' => 'reverb',
|
'broadcasting.connections.reverb.options.host' => 'reverb',
|
||||||
'broadcasting.connections.reverb.options.port' => 8080,
|
'broadcasting.connections.reverb.options.port' => 8080,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\FleetService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defense-in-depth: EnsureSecurityOnboarded must be re-applied to Livewire component
|
||||||
|
* updates, not just the initial page GET. Livewire only re-runs route middleware that is
|
||||||
|
* registered as "persistent"; otherwise a component mounted while onboarded keeps running
|
||||||
|
* destructive actions on the /livewire/update endpoint even after the account is reverted
|
||||||
|
* to must_change_password mid-session (Codex finding on the SSH-key-provision feature).
|
||||||
|
*
|
||||||
|
* This exercises the REAL update endpoint — Livewire skips persistent middleware for
|
||||||
|
* Livewire::test() fake requests, so a faithful test has to replay a page-mounted snapshot
|
||||||
|
* through a genuine POST /livewire/update.
|
||||||
|
*/
|
||||||
|
class PersistentSecurityGateTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reverted_user_cannot_run_destructive_livewire_action_on_a_mounted_component(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue