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
parent
1f42c05648
commit
f5f4d2a8cd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue