diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6e1962e..74662a1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,6 +15,7 @@ use App\Services\Traefik\SshTraefikWriter; use App\Services\Traefik\TraefikWriter; use App\Services\Wireguard\LocalWireguardHub; use App\Services\Wireguard\WireguardHub; +use App\Http\Middleware\RestrictAdminHost; use App\Mail\MaintenanceCancelledMail; use App\Models\MaintenanceNotification; use App\Services\Maintenance\MaintenanceNotifier; @@ -22,6 +23,7 @@ use Illuminate\Mail\Events\MessageSending; use Illuminate\Mail\Events\MessageSent; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -48,6 +50,12 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + // Livewire posts every component action to /livewire/update, which a + // path-based guard would skip. Marking the host restriction persistent + // makes Livewire re-apply it from the component's original route, so an + // admin action cannot be driven through a public hostname. + Livewire::addPersistentMiddleware(RestrictAdminHost::class); + // Send-time guard for maintenance mail (X-CP-Notification carries the // ledger id). Deliberately READ-ONLY — it never marks the row sent or // claimed, because returning false makes Laravel treat the send as a diff --git a/bootstrap/app.php b/bootstrap/app.php index 9386d44..4473a76 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,6 +15,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'admin' => \App\Http\Middleware\EnsureAdmin::class, + 'admin.host' => \App\Http\Middleware\RestrictAdminHost::class, 'customer.active' => \App\Http\Middleware\EnsureCustomerActive::class, ]); diff --git a/routes/web.php b/routes/web.php index be5ee5c..3a3e4e4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -58,9 +58,11 @@ Route::middleware(['auth', 'customer.active'])->group(function () { }); // Admin / operator console — dark theme, gated to is_admin users (R1/R2, R13). -// Host restriction runs ahead of this stack (RestrictAdminHost, prepended to the -// `web` group in bootstrap/app.php): on a public hostname /admin 404s outright. -Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { +// RestrictAdminHost is BOTH prepended to the `web` group (deterministic 404 on +// direct hits, even for guests) AND listed here so Livewire records it on the +// component snapshot and re-applies it to /livewire/update requests — otherwise +// admin actions could be invoked through a public hostname. +Route::middleware(['admin.host', 'auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { Route::get('/', Admin\Overview::class)->name('overview'); Route::get('/customers', Admin\Customers::class)->name('customers'); Route::get('/instances', Admin\Instances::class)->name('instances'); diff --git a/tests/Feature/Admin/AdminHostRestrictionTest.php b/tests/Feature/Admin/AdminHostRestrictionTest.php index 21cf069..5beeb54 100644 --- a/tests/Feature/Admin/AdminHostRestrictionTest.php +++ b/tests/Feature/Admin/AdminHostRestrictionTest.php @@ -44,6 +44,54 @@ it('404s before revealing a login redirect to guests on a public host', function $this->get('http://www.dev.clupilot.com/admin')->assertNotFound(); }); +it('blocks admin Livewire actions posted through a public hostname', function () { + // Admin actions go to /livewire/update, which a path-based guard would skip. + // Livewire must re-apply the host restriction from the component's route. + config()->set('admin_access.hosts', ['admin.dev.clupilot.com']); + $owner = operator('Owner'); + + // Render the real page on an ALLOWED host so Livewire records the admin + // route's middleware on the snapshot (Livewire::test() skips HTTP routing + // and would not, which would make this test pass for the wrong reason). + $html = $this->actingAs($owner) + ->get('http://admin.dev.clupilot.com/admin/datacenters') + ->assertOk() + ->getContent(); + + expect($html)->toMatch('/wire:snapshot=/'); + preg_match('/wire:snapshot="([^"]+)"/', $html, $m); + $snapshot = html_entity_decode($m[1], ENT_QUOTES); + + $payload = fn () => [ + '_token' => csrf_token(), + 'components' => [[ + 'snapshot' => $snapshot, + 'updates' => [], + 'calls' => [['path' => '', 'method' => 'save', 'params' => []]], + ]], + ]; + + // Replayed against a PUBLIC hostname → must not execute. + $this->actingAs($owner) + ->post('http://www.dev.clupilot.com/livewire/update', $payload()) + ->assertNotFound(); + + // Positive control: the very same payload works on the allowed host, so the + // 404 above is the restriction — not a malformed request. + $this->actingAs($owner) + ->post('http://admin.dev.clupilot.com/livewire/update', $payload()) + ->assertOk(); +}); + +it('registers the host restriction as Livewire-persistent middleware', function () { + // Guards the wiring above: without this, /livewire/update bypasses ADMIN_HOSTS. + $persistent = (new ReflectionClass(\Livewire\Mechanisms\PersistentMiddleware\PersistentMiddleware::class)) + ->getProperty('persistentMiddleware'); + + expect($persistent->getValue(app(\Livewire\Mechanisms\PersistentMiddleware\PersistentMiddleware::class))) + ->toContain(\App\Http\Middleware\RestrictAdminHost::class); +}); + it('keeps the customer portal reachable on public hosts', function () { config()->set('admin_access.hosts', ['admin.dev.clupilot.com']); $user = User::factory()->create();