From 9cbc8e682a72b7e5965a3ddcd31e023e28864966 Mon Sep 17 00:00:00 2001 From: jwed Date: Tue, 28 Jul 2026 15:03:53 -0400 Subject: [PATCH] SECURITY: the guest Wi-Fi card poll had no authorization at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /app/unifi/guest-card/data carried `web,auth` and nothing else, and GuestCardController::data() checked nobody. So ANY signed-in account could fetch the live guest Wi-Fi password by requesting the URL — on this dev database that is 663 students and 418 parents, none of whom can see the card in the UI. The route comment asserted the protection that did not exist: // ── Guest Wi-Fi home-card poll (any logged-in user; card visibility is // grant-gated by the shell). … Card visibility IS grant-gated, but that gate is PRESENTATION only — DashboardController decides which cards to render for a user; it has never constrained the endpoints those cards poll. So the comment described the card, and the endpoint was open. Now enforced server-side: you may poll it iff you may see the card (DashboardCardGrant::accessibleCardKeysFor), with the usual super-admin pass. Kept as the card gate rather than a permission so the widget stays grantable to non-unifi staff, which is the whole point of a home-page card. Verified: a non-super user holding only the MyDocuments card is refused for unifi.guest_wifi, and an explicit grant admits the same user. Found while auditing whether the bespoke page-grant middleware could be deleted. This route sits inside that middleware's prefix match, so deleting it would have removed the last non-auth check on the endpoint — it needed closing first either way. Two things nearby that are NOT fixed here and want their own look: • unifi.portal.callback (`auth` only, no permission) checks only that somebody is logged in. • unifi.api.wifi.current-password is token-protected via hash_equals, which is fine, but it is the same secret behind a different door — worth confirming that token's rotation story. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg --- src/Http/Controllers/GuestCardController.php | 31 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/GuestCardController.php b/src/Http/Controllers/GuestCardController.php index 187841b..a13f5d7 100644 --- a/src/Http/Controllers/GuestCardController.php +++ b/src/Http/Controllers/GuestCardController.php @@ -4,6 +4,7 @@ namespace Dashboard\Unifi\Http\Controllers; use App\Models\Setting; use App\Models\User; +use Illuminate\Http\Request; use Illuminate\Routing\Controller; /** @@ -44,9 +45,35 @@ class GuestCardController extends Controller ]; } - /** Live poll for the card (any logged-in user who can see the card). */ - public function data() + /** The dashboard card this endpoint feeds — must match UnifiServiceProvider. */ + private const CARD_KEY = 'unifi.guest_wifi'; + + /** + * SECURITY: authorize against the card's own visibility. + * + * This was `web,auth` only, and the route comment claimed "card visibility is + * grant-gated by the shell" — but that gate is PRESENTATION only + * (DashboardController decides which cards to render). The endpoint itself was + * unchecked, so any signed-in account — every student and parent included — + * could fetch the live guest Wi-Fi password by requesting the URL directly. + * + * The endpoint now enforces what the comment always claimed: you may poll it iff + * you may see the card. Super admins pass, as everywhere else. Kept as the card + * gate rather than a permission so the widget stays grantable to non-unifi staff, + * which is the point of a home-page card. + */ + public function data(Request $request) { + $user = $request->user(); + abort_unless($user, 403); + + if (! $user->is_super_admin) { + $allowed = \App\Models\DashboardCardGrant::accessibleCardKeysFor($user) + ->contains(self::CARD_KEY); + + abort_unless($allowed, 403); + } + return response()->json(self::payload() ?? ['title' => null]); }