Found while triaging every authenticated route that carries no authorisation
middleware. Of 91 such routes this was the ONLY one that proved nothing: 35 scope to
$request->user(), 51 check ownership in the controller, 4 carry a credential in the URL.
TWO DEFECTS.
1. Arbitrary device admission. Everything identifying the device is request-supplied —
$clientMac = $request->input('mac', $request->input('id', '')) — and this endpoint
WRITES network state: authorizeGuest() puts a MAC on the mapped VLAN and a
PortalSession row is created. So any signed-in account, a student or a parent
included, could call
GET /portal/wifi/callback?mac=<a MAC they do not own>&url=…
and admit that device, up to the mapping's max_devices. No signature, no nonce, no
check that the MAC was even present on the network.
Fixed HALFWAY, on purpose. A well-formed MAC is now required, which stops the
parameter smuggling junk into authorizeGuest and into the session row — but it
cannot prove the caller owns the device, and the comment says so rather than
implying the hole is closed.
The likely proper fix is already in this codebase: UnifiApiClient::isClientConnected()
exists and is used a few lines below for stale-session cleanup, so requiring the MAC
to be currently associated would bind it. I did NOT add it, because if a pre-auth
guest reports as not-connected that check breaks WiFi onboarding for the entire
school, and there is no hardware here to test against. It needs one deliberate test
on real APs. The alternative is having UniFi sign the redirect.
2. Open redirect. `return redirect($request->input('url', '/'))`, unvalidated, on an
authenticated route — a phishing primitive serving from this school's own domain.
A captive portal exists to forward people to an ARBITRARY external site they were
already trying to reach, so an allowlist is not available. safeRedirectTarget()
instead accepts only absolute http/https URLs and site-relative paths, rejecting
javascript:, data: and protocol-relative "//evil.example" values that redirect()
would otherwise follow off-site. A rejected value falls back to '/' rather than
erroring: the guest is already online by then, and a bad `url` should not look like
a failed login.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
New home card 'unifi.guest_wifi': shows the guest SSID, the current
rotated password (unifi.password_rotation.last_password), and a WPA
join QR built from the WIFI: string. Polls app/unifi/guest-card/data
(auth) every 60s so it updates the moment the rotation engine changes
the password — the QR regenerates client-side to match. Self-hides
until enabled with an SSID and a rotated password; grant-gated via
Admin -> Settings -> Dashboard Cards. New settings (WiFi rotation
section): enable + network name + card title.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
unifi_page_grants gains role + default grantee types and can_view
(deny-by-default "Everyone else" row); enforcement moves from the
RouteMatched listener — where request->user() is always null and the
check silently failed open — into route-appended middleware with the
permission-holder pass. Pages-access endpoints gain role search +
default-row handling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Instead of returning HTTP 500 when the UniFi controller can't be reached, these JSON
data feeds now return 200 with an 'available: false' / status 'unavailable' payload so the
dashboard widgets show a friendly unavailable state and the WAN poll stops silently failing.
User-defined SSID groups (configured on the WiFi Networks page and
stored in unifi.ssid_groups) now drive PPSK sibling propagation. The
previous same-SSID-name detection missed cases where two grouped
WLANs have *different* names — e.g. "VCS Guest" on 2.4 and "VCS
Guest 5G" on 5GHz manually grouped by the operator. Falls back to
same-name siblings when no group is configured.
Match-by-name fix: embedded PPSKs on this controller don't carry a
name field — the human "GUEST" label is the *network's* name, with
the entry referenced via networkconf_id. updateEmbeddedPpsk and
verifyEmbeddedPpsk now resolve name → networkconf_id first and match
on that, with entry-name and current-passphrase as fallbacks for
other controller variants.
After every rotation we re-fetch each affected WLAN and verify the
new passphrase is actually present on the named network. Failures
("mismatch" or "fetch_failed" on the primary, anything other than
"not_found" on a sibling) surface in the cron run details as failed
PPSKs so the operator sees what didn't propagate.
v1.10.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every rotation changes an embedded PPSK's synthetic id (it's derived
from sha256(wlan_id : passphrase)). The ingest sync matched only by
unifi_id, so after rotation the row's id was "new" — the sync created
a fresh active row and marked the previous one held. Over multiple
rotations this accumulated: each rotation left a held tombstone, and
the rotate_password / schedule flags were stuck on the original
tombstone instead of transferring to the new active row.
Dev's GUEST PPSK had 3 rows after a few rotations: two held (with
rotate_password=true on the first), one active with rotate=false.
Future rotations would silently skip that PPSK because the active row
no longer had the rotate flag set.
Fix in three layers, all in WifiController::ppskIndex:
1. Match priority extended: unifi_id → name within wlan → held by
passphrase. The name match means a passphrase change just updates
the existing row in place. No more new-row creation per rotation.
2. Salvage step before pruning: for each active row, scan held
tombstones with the same name and copy over rotate_password and
schedule. Operator's rotation opt-in survives history.
3. Prune step: held rows with the same name as an active row in the
same wlan are now hard-deleted (their settings were just salvaged,
their data is stale). Keeps the WiFi modal clean instead of
accumulating phantoms.
v1.10.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sibling-rotation path's "Embedded PPSK not found" error was being
surfaced to the operator as a failure, but it's not — it just means
the PPSK isn't mirrored on that band (GUEST was configured on one
band only, which is a perfectly valid setup). Logging this as a
sibling failure also poisoned the cron run status to "partial".
Now: "not found"-style errors from updateEmbeddedPpsk on a sibling
become info-level log entries and the loop continues. Other errors
(API failures, permissions, etc.) still surface as warnings/failures.
v1.10.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sibling-update path on prod failed with "Embedded PPSK not found
by current passphrase" because the DB-stored x_passphrase on the
unedited band was stale — earlier manual edits (pre-1.8.1) only
touched one band, leaving the other band's row out of sync. When
rotation then tried to use that stale passphrase to find the entry,
no match.
updateEmbeddedPpsk now takes an optional $name parameter and tries it
first. PPSK names within a WLAN are unique, so name-matching survives
any passphrase drift caused by historical out-of-band edits.
Passphrase matching stays as a fallback for callers that don't have
a name (none currently — both rotation and the manual modal pass it).
v1.9.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs reported from prod after a PPSK rotation:
1. unifi.password_rotation.last_password was only saved after a
whole-SSID rotation. PPSK-only setups (the typical guest-WiFi case)
ran a successful rotation but the setting stayed empty, so the
Settings → Tasks UI never showed the current password and the
/api/unifi/wifi/current-password endpoint returned 404
"no rotated password recorded yet". The PPSK loop now writes
last_password on every successful PPSK rotation.
2. When an SSID is "banded" (band-steering disabled), UniFi splits it
into one wlanconf per band — 2.4GHz and 5GHz each get their own _id
and their own embedded PPSK array. Rotating the PPSK on one band
left the other band with the old password. New
UnifiApiClient::getWlanSiblings($wlanId) finds all wlanconfs that
share an SSID name; both rotation and the manual modal edit now
call updateEmbeddedPpsk on each sibling and update the matching
UnifiPpsk DB rows.
3. The manual WiFi modal edit had the same band-blindness as #2 —
editing the GUEST PPSK on the 2.4GHz half left the 5GHz half stale.
WifiController::ppskUpdate now walks siblings the same way.
v1.8.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* UnifiPageGrant::userCanAccess no longer falls back to "open" when a
page has no grants saved. Pages now require an explicit grant for
every non-super-admin user — either a direct user grant or via a
group they belong to. Matches the new dashboard-wide access model.
* Route enforcement returns 404 (was 403) so ungranted users can't even
confirm the page exists.
* New /settings/pages-access/groups/search endpoint mirrors the
user typeahead. Groups are no longer all listed by default — only
super-admin groups (locked-on) and groups with at least one existing
grant show up in the matrix. Operators add more via search.
v1.7.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the API was implicitly active whenever a token existed.
Now there's an explicit unifi.api.enabled setting that gates it:
* WifiApiController returns 503 ("API disabled") when the setting is
off, even if a valid token is presented. Stops the endpoint from
silently working if a token is lying around.
* Settings page exposes the toggle under the Rotate-WiFi-Passwords
block. With it off, the token / URL / curl example are hidden.
* The form submit handles the new api_enabled boolean.
v1.6.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* RotatePasswords now stores the active wordlist entry as
unifi.password_rotation.last_password whenever a whole-SSID rotation
succeeds. Per-PPSK rotation continues to store passwords on each
PPSK row as before.
* Settings → Tasks tab surfaces the current password in bold beneath
the wordlist textarea so operators can quickly check what's live.
* New JSON endpoint GET /api/unifi/wifi/current-password returns
{"password": "...", "rotated_at": "..."}. Protected by a token stored
in unifi.api_token — pass as Authorization: Bearer <token> or
?token=<token>. 401 on bad/missing token, 503 if no token is
configured, 404 if no rotation has happened yet.
* Settings page lets super-admins Generate / Regenerate / Clear the
token. Generated tokens are 48-char hex from bin2hex(random_bytes(24)).
* The endpoint lives outside the web/auth middleware so external
signage / kiosks can hit it without a session cookie.
v1.6.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* The Test URL button was POSTing a generic {event, timestamp, data}
envelope to every endpoint. Google Chat / Slack / Discord / Teams
reject anything that isn't their specific shape — so a successful
Laravel request still got a 400 back from the platform, making the
test look broken. The real webhook events already handle this via
WebhookCheckService::formatPayloadForPlatform; that helper is now
exposed as a public static (buildPlatformPayload) and the test
endpoint uses the same code path, so the test exercises the same
format real events will.
* unifi_device_states was missing a consecutive_count column the
WebhookCheckService inserts on every snapshot capture. The scheduler
was throwing "Unknown column 'consecutive_count'" once a minute.
Added an idempotent migration.
v1.6.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Embedded PPSKs live inside the parent WLAN's private_preshared_keys
array — they have no controller-side _id and the synthetic emb_<hash>
we generate locally isn't a real REST id. Hitting /rest/ppsk/emb_xxx
returns HTTP 400/503, which is what the GUEST PPSK rotation was
failing on at the scheduled 3pm run.
* New UnifiApiClient::updateEmbeddedPpsk($wlanId, $oldPass, $newPass):
GETs /rest/wlanconf/{wlanId}, finds the matching entry in
private_preshared_keys by current passphrase, swaps the value while
preserving whichever field name the controller uses (x_passphrase /
password / passphrase), and PUTs the whole WLAN object back.
* RotatePasswords detects emb_-prefixed unifi_ids and routes through
the embedded path. The synthetic id is rederived from the new
passphrase so the DB row stays addressable.
* WifiController::ppskUpdate (manual modal save) does the same — this
is why manual edits sometimes appeared to succeed but the controller
side actually rejected them.
Verified live against the GUEST PPSK on 10.81.0.1.
v1.5.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* The model+validation referenced tracked_clients and templates columns
but they were never in the unifi_webhook_configs migration. Any save
attempt that included those keys 500'd with "Unknown column".
Added an additive migration (idempotent) that adds both as nullable
json columns.
* New POST /settings/webhooks/test-url endpoint takes a url+secret in
the body and fires the standard test payload. Lets operators validate
their endpoint before saving the row — useful when first wiring up
Google Chat, Slack, etc.
v1.5.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listing every user in the system on the access page didn't scale —
schools have thousands of user rows. Now:
- index() only returns users that already have a UnifiPageGrant
somewhere. Groups stay fully listed (few of them).
- new searchUsers(q) endpoint returns up to 20 typeahead matches
against name or email (min 2 chars).
v1.5.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds unifi_cron_runs table (one row per scheduled-task execution) and
UnifiCronRun::record() wrapper that captures start/finish/status and
exceptions. The three scheduled commands now write through it:
- reboot-all-aps → rebooted/failed AP names per run
- rotate-passwords → rotated SSIDs + PPSKs, failures (when actually
rotating; the "is it due" early-return is silent
so we don't flood the log with no-op rows every
minute)
- sync-ppsk-schedules → enabled/disabled PPSKs (silent when there's
no work)
UnifiCronLogsController returns the most-recent 200 runs as JSON,
filterable by command + status. Behind permission:unifi.settings; no
super-admin required — read-only history is fine for any operator
who can see settings.
v1.5.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A snap-in-owned access mechanism. Adds:
- unifi_page_grants table (nav_item_id, grantee_type, grantee_id)
with cascadeOnDelete from nav_items so uninstalling the snap-in
wipes its grant rows automatically
- UnifiPageGrant model + ::userCanAccess(user, navItem) helper
- UnifiPagesAccessController (index + update), super-admin only
- RouteMatched listener in UnifiServiceProvider that 403s any
unifi.* route if the matched nav_item has grants and the user
isn't a super-admin / granted user / member of a granted group
Semantics: a page with NO grants stays open per the existing
permission middleware (no behaviour change). The moment grants are
added, ONLY super-admins and listed users/groups can see/open the
page. Super-admins always pass; their access can't be removed.
v1.4.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UniFi's /rest/apgroup endpoints (and per-SSID ap_group_ids writes via
/rest/wlanconf) require session-cookie auth — they don't accept the
X-API-Key header. The Integration API doesn't expose AP groups at all.
So with the current deployment running on API-key auth, every AP-group
operation returned 400 api.err.InvalidObject. Removing the dead code
rather than carrying a feature that can't function.
* Deleted ApGroupController, ApGroups.vue, the /ap-groups/* routes,
and getApGroups/createApGroup/updateApGroup/deleteApGroup from
UnifiApiClient.
* Removed the per-SSID AP-group assignment from Wifi.vue + the
updateApGroups action + /wifi/{wlanId}/ap-groups route + the
ap_group_ids field from the mapWlan output.
* Removed the AP Groups nav entry from composer.json.
If a future deploy adds local-admin username+password auth, AP groups
can be reintroduced — the UnifiApiClient::buildRequest() session-cookie
path is intact.
v1.3.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The AP Groups page (ApGroups.vue + ApGroupController + UnifiApiClient
CRUD methods) has been built but never declared in composer.json's
pages list, so it was hidden from the menu. Added it at sort_order=6,
between WiFi Networks and Portal.
The WiFi Networks page already has per-SSID AP-group assignment via
the existing updateApGroups route — that wires into UniFi's standard
ap_group_ids field on wlanconf. (UniFi doesn't expose per-AP-only
assignment separately; the convention is "make a one-AP group for
this AP and assign the SSID to it.")
For the "always pull from UniFi on load" guarantee:
- getWlans() and getApGroups() are already uncached — fresh on every
page load
- getDevices() (feeds the AP picker for group membership) is cached
for unifi.cache_ttl seconds; both ApGroupController::index and
WifiController::index now Cache::forget('unifi:devices') before
reading so the device list is always fresh
v1.3.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops unifi.timezone from the settings form (now lives in
Admin → Settings on the shell). Schedulers (PPSK sync, password
rotation) now read \App\Support\Timezone::current() — same fallback
chain as the rest of the platform.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add password rotation: RotatePasswords console command + migration + service updates
- Add PPSK management: UnifiPpsk model, migration, SyncPpskSchedules console
- Add VLAN groups and AP groups: VlanGroupController, ApGroupController, model, migration
- Add RebootAllAps console command
- Add in_alert column to device states
- Wire new features through service provider, routes, and existing controllers/services
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>