fix(security): close the Livewire bypass of the admin host restriction

Admin actions post to /livewire/update, which a path-scoped guard skips — an
operator session could drive admin components through a PUBLIC hostname despite
ADMIN_HOSTS. The restriction is now registered as Livewire-persistent middleware
and listed on the admin route group, so Livewire re-applies it from the
component snapshot.

Proven by replaying a snapshot taken from the real rendered page: identical
payload -> 404 on a public host, 200 on the allowed host (positive control, so
the test cannot pass for the wrong reason).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 20:50:22 +02:00
parent 1f42c05648
commit f5f4d2a8cd
4 changed files with 62 additions and 3 deletions

View File

@ -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

View File

@ -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,
]);

View File

@ -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');

View File

@ -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();