Files
central-unifi/docs/superpowers/reviews/2026-08-21-plan1-review.md
jwed 33e465898c Review findings for Plan 1, and two corrections from production evidence
Recorded because these outlived the session that produced them and every one is
about code already committed.

Six findings, most severe first. The reconciler as written would revoke every guest
authorization on the whole UniFi site, not just the ones this portal created —
front-desk vouchers, contractors, anything an admin authorized by hand — and
re-authorize anything an admin deliberately revoked. That is why "not scheduled" is
currently load-bearing rather than cautious. The pre-DHCP retry cannot succeed
because all three attempts read one cached client snapshot, and the first failure
warms that cache with a snapshot lacking the device, so "try again" stays refused
for the TTL while holding a worker 1.5s per attempt. Every portal visit blanks the
device fingerprint, because the controller writes three request parameters UniFi
never sends. Session windows slide on every visit, so a bookmarked callback never
ages out. And with no unique index on (user_id, mac_address) and no locking,
concurrent callbacks can produce either two authorizations against a one-device
limit or two rows for one phone that permanently consume two slots.

Four tests pass for the wrong reason, including the headline quota-leak regression
test: it asserts a component name that four different branches render, so it stays
green with no role and no mapping at all.

TWO CORRECTIONS from production evidence the operator supplied.

The earlier claim that the request IP is forgeable was demonstrated by sending a
header directly to nginx on :80. Clients cannot do that — the Wi-Fi VLAN is
firewalled to Traefik only — so that demonstration does not show a live exploit and
must not be cited as one. What survives is narrower: the app believes any
X-Forwarded-For that reaches it, and whether a client can deliver a forged one
depends on one forwardedHeaders setting on a Traefik host that is not reachable from
here.

And spec section E had its release-gate risk backwards. It feared TrustProxies
would be too strict and lock everybody out. Production shows the opposite: the
Logged-in Users list reports real per-client addresses, 10.22.5.x for on-site staff
and a public address for an off-site phone. The IP-to-MAC approach is viable, with
evidence, and that item is not a blocker.

Also added: device type should come from the User-Agent, not from UniFi. The
platform already parses "Safari · iPhone" and "Chrome · macOS" in
ActiveSessionController::device(), and its OS table already separates iPhone, iPad
and Android from the desktop platforms. That is available at authorization time,
which UniFi's traffic-inferred dev_cat is not, and it is known to be populated,
which dev_cat in this environment never was.
2026-08-21 13:50:19 -04:00

17 KiB

Plan 1 review findings — BYOD Wi-Fi access model

Date: 2026-08-21 Reviewed: the six commits implementing docs/superpowers/plans/2026-08-21-byod-wifi-access-model.md (central-unifi a74818b..a8d7b34, dashboard-shell f3de4aa..HEAD) Verdict: CHANGES NEEDED. Nothing needs redesigning; the list below needs fixing. Suite at time of review: 48 passed in tests/Feature/Unifi/; full suite 6293 passed / 10 failed, all in the unrelated pre-existing Logs\ScriptRunsTest.

Recorded here because these findings outlived the session that produced them and every one of them is about code that is already committed.


0. The request IP is trusted, and the app has no opinion of its own

dashboard-shell/bootstrap/app.php:14 — $middleware->trustProxies(at: '*'). Laravel routes '*' to setTrustedProxyIpAddressesToTheCallingIp(), so the immediate peer is unconditionally trusted as a proxy and its X-Forwarded-For becomes $request->ip(). docker/nginx/default.conf sets no real_ip_header or X-Forwarded-For of its own, so a client-supplied header reaches PHP verbatim.

DeviceResolver::resolve() reads $request->ip() and derives the MAC from it, so if a client can deliver a forged header the resolver identifies a device of the attacker's choosing. The AP check does not help: ap is also a request parameter, so an attacker forges both.

What is NOT established

An earlier session demonstrated forgery by sending X-Forwarded-For directly to nginx on :80. That path is not available to a real client: the Wi-Fi VLAN is firewalled off from the server and can reach only Traefik. So that demonstration does not show a live exploit, and it should not be cited as one.

What IS established: the legitimate value arrives correctly

Confirmed by the operator on 2026-08-21 from the production Logged-in Users list, served through Traefik: sessions show real per-client addresses — 10.22.5.254 and 10.22.5.154 for on-site staff, and a public address for an off-site phone. So TrustProxies plus Traefik does yield the true client IP, and UniFi can map an on-site 10.x address to a MAC.

That settles two things:

  • The IP → MAC approach is viable. This was the operator's original question and the answer is yes, with production evidence.
  • Spec §E had the release-gate risk backwards. It called "whether TrustProxies yields the true client IP" the thing that gates release, fearing a fail-closed outage where nobody can get online. That fear is unfounded — it demonstrably works. Remove it as a blocker.

Note the off-site public address: a portal request only ever originates on-site, so a request whose IP is public will correctly find no UniFi client and refuse. That is the right behaviour, not a gap.

The open question, which is narrower than it first appeared

A legitimate value arriving correctly and a forged value being rejected are different properties. The evidence above establishes the first. It does not establish the second, because nothing in that list would look wrong if Traefik were also passing through client-supplied headers.

So one question remains, and it is a one-line check

Whether a client can deliver a forged header depends entirely on Traefik's forwardedHeaders for the entry point, on the Traefik host (162.223.102.234 — a separate LXC, not reachable from the dev box):

  • forwardedHeaders.insecure: true, or trustedIPs covering client ranges → client headers are passed through → the resolver's guarantee is void
  • unset (Traefik's default) → Traefik replaces client-supplied forwarded headers → the design works as intended

Treat this as the release gate. Narrowing trustProxies from * to the Traefik network is worth doing either way as defence in depth: as written, the application delegates the entire question to whoever happens to be talking to it.

The consequence that was missed, if it is forgeable

Not a quota bypass — quotas key on user_id, so an attacker cannot exceed their own limit. It is worse in a different way. updateOrCreate writes a row keyed (attacker_user_id, victim_mac), making the attacker the legitimate owner of a session pointing at somebody else's device. disconnectSession then passes its owner check and runs kickClient + unauthorizeGuest on the victim's MAC.

The spec declared disconnectSession "already correct and not being changed". That was true of the function and false of its inputs — the one component held out of scope as safe is what turns this into an eviction primitive. Worth remembering as a general lesson about scoping something out on the strength of its internals.

Also note the three distinct refusal reasons (no_client / ap_mismatch / success) form an oracle: iterating candidate IPs enumerates the live client table with MAC and AP. They were added for operability and double as reconnaissance.

Nothing in the six commits is defective on this point. DeviceResolver is a correct implementation of "derive the MAC from the source IP" sitting on a platform assumption. Spec §E named this exact dependency as the release gate and got the direction backwards — it worried TrustProxies would be too strict (a visible outage) and never considered it being too permissive (silent fail-open). The blind-spot note in b59b5bf reasons carefully and still only considers the strict direction.

Exposure is wider than this feature. Every consumer of $request->ip() in the shell inherits it — session client IP, the Logged-in Users list, anything keyed on IP. File against dashboard-shell, not central-unifi.

What would not help

Assessed and rejected, all reducing to trusting the same value:

  • Re-checking the client's reported IP — circular; the record was selected by that IP, so the check cannot fail.
  • Requiring the client to be unauthorized — breaks the renewal path and with it the spec's headline promise, while still leaving first-time connectors targetable.
  • Association recency — narrows harvesting to a race the attacker wins by polling, and wrongly refuses a device revisiting after a long window expires.

The durable fix is to stop making the request IP load-bearing: have the controller vouch for the MAC (a signed redirect / HMAC over mac, ap, timestamp). That survives any proxy misconfiguration. Whether this controller supports it is a question for the hardware session — add it as a fourth item to spec §E, ranked above the health line in Plan 2.


1. The reconciler would revoke every guest authorization on the whole site

src/Console/ReconcilePortal.php — array_diff($authorized, $live) then unauthorizeGuest on each, where $authorized comes from getAuthorizedGuestMacs() reading site-wide /stat/guest. Nothing scopes it to authorizations this portal created.

A second guest SSID, UniFi's own voucher portal, front-desk visitors, contractors, a gym rental, or anything an admin authorized by hand: all revoked on the first scheduled run, and again every run. Inversely, a device an admin deliberately un-authorized in the UniFi UI gets re-authorized — the dashboard silently overriding the network admin, forever.

Fix: only revoke a MAC that appears in unifi_portal_sessions at all, or filter by the portal's SSID/VLAN. This is why "not scheduled" is currently load-bearing rather than merely cautious.

Unverifiable without hardware, and worth adding to §E: what window does GET /stat/guest return with no within, and does the controller rewrite end when an authorization is revoked? If not, job 3 silently stops re-asserting.

2. The pre-DHCP retry cannot succeed, and costs a worker 1.5s per failure

DeviceResolver retries three times, but findClientByIp() → getActiveClients() is Cache::remember('unifi:clients', 30s). All three attempts read one snapshot.

Worse than a no-op: the first attempt warms the cache with a snapshot lacking the connecting device, so "try again" stays refused for the rest of the TTL, for that pupil and everyone else. Meanwhile each failure holds a PHP worker for 1.5s of usleep — the exact cost the comment claims to bound, with none of the benefit. A class of 25 arriving together is 25 workers asleep on one stale array.

UnifiApiClient's own comment justifies the cache on the grounds that "the portal calls this once per connection attempt". The resolver calls it three times.

Fix: Cache::forget('unifi:clients') between retries, or an uncached read path.

3a. Device type should come from the User-Agent, not from UniFi

Raised by the operator on 2026-08-21, from the same production screenshot: the Logged-in Users list already labels sessions "Safari · iPhone" and "Chrome · macOS", and does it well.

That is parsed from the User-Agent, by a hand-rolled private method ActiveSessionController::device(string $ua) — no library involved. Its OS table already distinguishes iPhone, iPad, Android from Windows, macOS, Linux, CrOS, which is precisely the phone-versus-laptop distinction the no-phones-for-students policy needs.

This is a better source than UniFi's fingerprint for this purpose, and it removes a dependency the spec could never verify:

  • Available at authorization time. A portal request comes from the device, so its User-Agent is the device's own. UniFi's dev_cat is inferred from observed traffic and does not exist yet at that moment — the reason §C had to push capture into the reconciler at all.
  • Known to be populated. unifi_client_snapshots has zero rows on dev, so whether dev_cat is ever filled in this environment was never established. The User-Agent is always present.
  • It fixes finding 3 with a value that is actually there. Replacing the three always-NULL $request->input(...) writes with the parsed label gives the denial page real device names, which is the single case that list exists for.

Recommendation: extract device() into a shared support class (e.g. App\Support\UserAgent::describe()) and have both the Logged-in Users controller and the portal call it. Small refactor, one owner for the parsing, no duplication.

The spoofing caveat stands and does not weaken this. A mobile browser's "Request Desktop Site" toggle changes the User-Agent, so this is sound for reporting and unsound for enforcement — which is exactly decision 6 (report-only). Keep UniFi's dev_cat capture in the reconciler as a corroborating second opinion: it is not client-controlled, so a disagreement between the two is itself a useful signal if enforcement is ever wanted.

3. Every portal visit blanks the fingerprint the reconciler filled in

PortalController writes device_hostname, device_os and device_type from $request->input('hostname'/'os'/'dev_cat'). UniFi's redirect sends id, ap, t, url, ssid — the controller's own comment says so — so all three are written NULL on every authorization.

The exact mirror of the bug captureFingerprints() goes out of its way to prevent. Consequence: the denial page's device list always reads "Unknown device", so a pupil at their limit chooses between two unlabelled MACs — the single case that list exists for. Plan 2's Wi-Fi Devices page inherits it.

$device->client already carries UniFi's hostname/os_name/dev_cat and is never read. Use it. These are also the last request-supplied values written to a session, so ?hostname=Principal's Laptop is shown to admins as fact.

4. Windows slide, contradicting the spec

expires_at => now()->addMinutes($minutes) on the renewal branch grants a fresh full window on every visit. With session_minutes set to a term, anyone who bookmarks the callback never ages out — which is what §C's fixed-window rule exists to prevent. Either preserve expires_at on renewal, or amend the spec and say why in a comment; today there is no comment where a reader would ask.

5. Two routes to a wrong quota

No unique index on (user_id, mac_address) (confirmed: only PRIMARY, (user_id,is_active), (mac_address)) and no lock around read-then-authorize.

  • Over quota: two devices of a 1-device pupil hit the callback concurrently, both read zero live sessions, both are authorized. The reconciler cannot correct it — both rows are live and both authorized.
  • Phantom slots: two concurrent renewals of the same MAC each miss the other and insert twice. One phone, two live rows, permanently. The reconciler's keyBy(mac) silently collapses the pair so neither direction sees it, and self-disconnect deactivates only the row that was clicked.

Fix: unique index plus lockForUpdate in a transaction. Consider a reconciler step trimming a user's live rows to their quota, newest first — nothing currently enforces the quota against our own rows.

6. Denial copy contradicts itself on the commonest path

Controller says "Still connecting — wait a moment and try again"; PortalDenied.vue then prints "Please contact the school office or IT for help getting online". Two instructions in one view, on the most frequent denial. resolveFailure is passed and never declared as a prop.


Tests that pass for the wrong reason

  1. The headline quota-leak regression test asserts component('Unifi/PortalDenied') and a live count — but four branches render that component. It passes with no role and no mapping at all, via "No WiFi access configured". Fix: assert the reason string.
  2. The proxy-outage test sets X-Forwarded-For and REMOTE_ADDR to the same value, so its condition holds by construction; mutating the production condition to ($forwarded) alone still passes. (Note: under trustProxies(at: '*'), $ip === $raw only holds when no XFF was sent, so proxy_untrusted is unreachable in this environment — a consequence of #0.)
  3. The reconciler re-assert test constrains neither MAC nor minutes. A sign error in now()->diffInMinutes($expires_at, false) would clamp every re-assertion to one minute and the test would not notice — and Carbon 3.13.1 changed that method's return type and default $absolute.
  4. The sort_order precedence test inserts the low-sort_order row first, so replacing orderBy('sort_order') with all() still passes. It also never asserts the user holds parent.

Untested: the unreachable-controller guard — the fail-safe between a network blip and mass revocation. Worth a test now, and more so once #1 is fixed.


Smaller notes

  • PortalDeniedTest's docblock explains why a device "must look CONNECTED, or the controller treats the session as stale, frees the slot and authorises" — documenting behaviour this work deleted. Most confidently-worded comment in the suite and now false. The dead isClientConnected stub goes with it.
  • ResolvedDevice::$client is never read (see #3). $macParameterDisagreed is asserted in a test and consumed nowhere — surface it or drop it and keep the log.
  • The disagreement test asserts the flag, not that Log::warning fired, which is what §B actually asks for.
  • Unused use ...DB; in two test files, copied from the plan.
  • Three resolver tests burn 1.5s of real usleep each; injecting the pause would pay for itself.
  • UnifiApiClient::isClientConnected() is now referenced nowhere in src/. Correct per spec (display-only, Plan 2) — say so in a comment or the next dead-code sweep deletes it.

Release sequencing — not a finding, but it changes the plan

Pages/Unifi/Portal.vue still offers "Default (catch-all)", which now 422s, and offers no role option. Wi-Fi quotas are therefore unconfigurable today: the reachable match types are ou/group/email_domain. Spec §F step 3 ("configure role → student / staff") cannot be carried out until Plan 2, which makes Plan 2 a hard prerequisite for release rather than a follow-up.


What is good and should not be disturbed

  • scopeLive is the right shape in the right place: the model owns the definition of a consumed slot, a NULL expiry is not live, and the why is in the docblock. Leak 2 is fixed at the query level and does not depend on the unscheduled reconciler. The most valuable thing in the six commits, and untouched by #0.
  • ?mac= really is dead. Verified end to end: with a supplied MAC present, authorizeGuest receives the derived one and no other row is written. No fallback anywhere; an absent ?ap= fails closed as ap_mismatch rather than skipping the check. The fail-closed discipline is genuine and thorough — it is the input that betrays it, not the logic.
  • DeviceResolver split out of the controller — four named refusal reasons, no quota knowledge, no Inertia. When the platform is fixed this is the right object in the right place, and where an eventual HMAC check belongs.
  • captureFingerprints()' fill-only rule and its reasoning. #3 is the controller violating this rule, not this code being wrong.
  • The Mockery declaration-order docblock in the reconciler test will save the next person an hour.
  • Removing default while unifi_vlan_mappings was empty, and recording in code why doing it later would be worse.