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.
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, ortrustedIPscovering 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_catis 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_snapshotshas zero rows on dev, so whetherdev_catis 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
- 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 thereasonstring. - The proxy-outage test sets
X-Forwarded-ForandREMOTE_ADDRto the same value, so its condition holds by construction; mutating the production condition to($forwarded)alone still passes. (Note: undertrustProxies(at: '*'),$ip === $rawonly holds when no XFF was sent, soproxy_untrustedis unreachable in this environment — a consequence of #0.) - 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. - The
sort_orderprecedence test inserts the low-sort_orderrow first, so replacingorderBy('sort_order')withall()still passes. It also never asserts the user holdsparent.
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 deadisClientConnectedstub goes with it.ResolvedDevice::$clientis never read (see #3).$macParameterDisagreedis 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::warningfired, 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
usleepeach; injecting the pause would pay for itself. UnifiApiClient::isClientConnected()is now referenced nowhere insrc/. 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
scopeLiveis 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,authorizeGuestreceives the derived one and no other row is written. No fallback anywhere; an absent?ap=fails closed asap_mismatchrather than skipping the check. The fail-closed discipline is genuine and thorough — it is the input that betrays it, not the logic.DeviceResolversplit 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
defaultwhileunifi_vlan_mappingswas empty, and recording in code why doing it later would be worse.