From 47812cfca90e4fa1ee614ed8952ceb8d632fe343 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 13:31:50 +0200 Subject: [PATCH] Authorize the console's live feed on the operator guard, not the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /broadcasting/auth carries only the 'web' middleware group (Laravel's own withBroadcasting() registers it that way), so PusherBroadcaster::auth() resolved retrieveUser() on the DEFAULT guard before the admin.runs channel's own Auth::guard('operator') check ever ran — found nobody, since an operator is never signed in on 'web', and threw straight from there. The guard was correct but unreachable. Fixed by naming both guards on the channel registration itself. Also adds broadcasting/auth to RestrictAdminHost::SHARED: in exclusive mode it was neither admin.* by name nor on the shared list, so it 404'd on the console host too — degrading silently to wire:poll.4s rather than breaking outright, which is why nothing screamed. --- app/Http/Middleware/RestrictAdminHost.php | 6 ++ routes/channels.php | 14 ++- tests/Feature/Admin/BroadcastAuthTest.php | 86 +++++++++++++++++++ .../Admin/ConsoleHostSeparationTest.php | 2 +- 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/Admin/BroadcastAuthTest.php diff --git a/app/Http/Middleware/RestrictAdminHost.php b/app/Http/Middleware/RestrictAdminHost.php index ce8e147..5ebb939 100644 --- a/app/Http/Middleware/RestrictAdminHost.php +++ b/app/Http/Middleware/RestrictAdminHost.php @@ -49,6 +49,12 @@ class RestrictAdminHost private const SHARED = [ 'livewire/*', 'up', + // The console's own live-provisioning feed authorizes here (see + // routes/channels.php, admin.runs) — a route Laravel registers + // without any admin.* name, so isConsoleRoute() alone would not + // exempt it, and in exclusive mode it would 404 on the console host + // exactly like a stranger's request would. + 'broadcasting/auth', ]; public function handle(Request $request, Closure $next): Response diff --git a/routes/channels.php b/routes/channels.php index fbc4f3f..d1e7315 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -8,7 +8,19 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) { }); // Operator console live provisioning feed — operators only. -Broadcast::channel('admin.runs', fn () => (bool) Auth::guard('operator')->user()?->can('console.view')); +// +// 'guards' is not decoration: POST /broadcasting/auth carries only the 'web' +// middleware group (Laravel registers it that way, see withBroadcasting()), +// so without this, PusherBroadcaster::auth() resolves the user on the +// DEFAULT guard before this callback ever runs — finds nobody, since an +// operator is never signed in on 'web' — and throws AccessDeniedHttpException +// straight from retrieveUser(). The Auth::guard('operator') check below is +// correct but unreachable without this option. +Broadcast::channel( + 'admin.runs', + fn () => (bool) Auth::guard('operator')->user()?->can('console.view'), + ['guards' => ['operator', 'web']], +); // A customer's own provisioning feed — bridged from the auth user by email. Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) { diff --git a/tests/Feature/Admin/BroadcastAuthTest.php b/tests/Feature/Admin/BroadcastAuthTest.php new file mode 100644 index 0000000..da4985b --- /dev/null +++ b/tests/Feature/Admin/BroadcastAuthTest.php @@ -0,0 +1,86 @@ + driver(), which reads + * config('broadcasting.default') and builds its OWN instance — a container + * binding for BroadcasterContract is a separate, unrelated seam that + * driver() never consults. No live Reverb server is needed: auth() is pure + * PHP — channel authorization is an HMAC signature computed locally, never a + * call over the network. + */ +function bindRealBroadcaster(): void +{ + config()->set('broadcasting.default', 'reverb'); + config()->set('broadcasting.connections.reverb.key', 'test-key'); + config()->set('broadcasting.connections.reverb.secret', 'test-secret'); + config()->set('broadcasting.connections.reverb.app_id', 'test-app-id'); + config()->set('broadcasting.connections.reverb.options', [ + 'host' => '127.0.0.1', 'port' => 6001, 'scheme' => 'http', 'useTLS' => false, + ]); + + // routes/channels.php ran once already, at boot, registering admin.runs + // against whichever broadcaster 'null' (this suite's forced default) + // resolved to at the time. Switching the default config above builds a + // brand new driver instance on next use — it does not retroactively + // register anything on it. Re-requiring the routes file does. + require base_path('routes/channels.php'); +} + +function authorizeAdminRunsChannel() +{ + return test()->post('/broadcasting/auth', [ + 'channel_name' => 'private-admin.runs', + 'socket_id' => '1234.1234', + ]); +} + +it('lets an operator authorize the console live-feed channel on the operator guard', function () { + bindRealBroadcaster(); + $operator = Operator::factory()->role('Owner')->create(); + + $this->actingAs($operator, 'operator'); + // actingAs() also flips Auth's DEFAULT guard to 'operator' (Auth:: + // shouldUse()) — nothing does that for a real request to this route in + // production (/broadcasting/auth carries only the 'web' middleware + // group, never auth:operator,web), so left alone this would make the + // bare/default guard resolve the operator too and mask exactly the bug + // this test exists to catch. Reset so only the 'operator' GUARD INSTANCE + // (already given its user by actingAs() above, untouched by shouldUse()) + // has the operator signed in — matching a real session. + Auth::shouldUse('web'); + + authorizeAdminRunsChannel()->assertOk(); +}); + +it('refuses a guest on the console live-feed channel', function () { + bindRealBroadcaster(); + + authorizeAdminRunsChannel()->assertForbidden(); +}); + +it('refuses a signed-in portal customer who is not an operator', function () { + bindRealBroadcaster(); + $user = User::factory()->create(); + + $this->actingAs($user); + + // Signed in on 'web', not 'operator' — retrieveUser() now finds THIS + // user (guards => ['operator', 'web']) and reaches the channel's own + // callback, which must still refuse them: they hold no operator role. + authorizeAdminRunsChannel()->assertForbidden(); +}); diff --git a/tests/Feature/Admin/ConsoleHostSeparationTest.php b/tests/Feature/Admin/ConsoleHostSeparationTest.php index dcbf502..f26dd39 100644 --- a/tests/Feature/Admin/ConsoleHostSeparationTest.php +++ b/tests/Feature/Admin/ConsoleHostSeparationTest.php @@ -97,7 +97,7 @@ describe('exclusive host', function () { // isConsoleRoute(), not through this nameless allowlist. If this list // is wrong the symptom is "the console is broken", so it is written // out rather than inferred. - foreach (['/livewire/update', '/livewire/livewire.min.js', '/up'] as $path) { + foreach (['/livewire/update', '/livewire/livewire.min.js', '/up', '/broadcasting/auth'] as $path) { expect(guard('admin.example.test', $path, null))->toBe('through', $path); } });