fix(security): console and account checks now survive a Livewire action
tests / pest (push) Successful in 7m8s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details

Livewire re-applies only the middleware on its persistent list when an action
posts to /livewire/update. Its defaults cover auth; ours were not on the list —
only RestrictAdminHost had been added. So EnsureAdmin and EnsureCustomerActive
guarded the PAGE and not the actions, and the page is not where the actions run:
a signed-in non-operator who could reach the admin host could drive console
components, and a suspended customer could keep driving the portal.

Found by Codex while reviewing the domain-separation design, and confirmed
against Livewire's own default list in vendor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design tested-20260726-1819-5c2e481
nexxo 2026-07-26 20:04:34 +02:00
parent 5d64da1cb3
commit 5c2e481985
2 changed files with 26 additions and 1 deletions

View File

@ -17,6 +17,8 @@ use App\Services\Traefik\SshTraefikWriter;
use App\Services\Traefik\TraefikWriter;
use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\WireguardHub;
use App\Http\Middleware\EnsureAdmin;
use App\Http\Middleware\EnsureCustomerActive;
use App\Http\Middleware\RestrictAdminHost;
use App\Mail\MaintenanceCancelledMail;
use App\Models\MaintenanceNotification;
@ -57,7 +59,17 @@ class AppServiceProvider extends ServiceProvider
// 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);
// Livewire re-applies only the middleware on this list when an action
// posts to /livewire/update. Its own defaults cover `auth` — but not
// ours. Without these two, a signed-in NON-operator could drive console
// components, and a suspended customer could keep driving the portal:
// the page would never load for them, but the page is not where the
// actions run.
Livewire::addPersistentMiddleware([
RestrictAdminHost::class,
EnsureAdmin::class,
EnsureCustomerActive::class,
]);
// Send-time guard for maintenance mail (X-CP-Notification carries the
// ledger id). Deliberately READ-ONLY — it never marks the row sent or

View File

@ -44,3 +44,16 @@ it('lets Support retry provisioning but not manage datacenters', function () {
// ...but cannot manage datacenters.
Livewire::actingAs(operator('Support'))->test(Datacenters::class)->call('toggle', 'x')->assertForbidden();
});
it('replays the operator and account checks on every Livewire action', function () {
// Livewire re-applies only the middleware on this list when an action posts
// to /livewire/update, and its defaults cover auth but not ours. Without
// these, the console page would refuse a non-operator while the console's
// actions still answered them — the page is not where the actions run.
$persistent = app(\Livewire\Mechanisms\PersistentMiddleware\PersistentMiddleware::class)
->getPersistentMiddleware();
expect($persistent)->toContain(\App\Http\Middleware\EnsureAdmin::class)
->and($persistent)->toContain(\App\Http\Middleware\EnsureCustomerActive::class)
->and($persistent)->toContain(\App\Http\Middleware\RestrictAdminHost::class);
});