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.
323 lines
17 KiB
Markdown
323 lines
17 KiB
Markdown
# 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.
|