|
|
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
# Captive portal splash designer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**Date:** 2026-08-21
|
|
|
|
|
|
|
|
**Repos:** `central-unifi` (routes, controller, model, migration), `dashboard-shell` (editor page, Blade splash view, tests)
|
|
|
|
|
|
|
|
**Status:** design approved, no implementation plan yet
|
|
|
|
|
|
|
|
**Sequencing:** queues behind the six open findings in
|
|
|
|
|
|
|
|
`docs/superpowers/reviews/2026-08-21-plan1-review.md`. Nothing here depends on
|
|
|
|
|
|
|
|
them, but they touch the same controller.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## What this is
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Let an operator lay out what a pupil sees at the captive portal, with the device's
|
|
|
|
|
|
|
|
own facts available as variables — IP, MAC, browser, machine type — rather than the
|
|
|
|
|
|
|
|
four hardcoded strings the portal has today.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## What already exists and is being reused
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Need | Existing thing |
|
|
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
|
|
| `{token}` substitution + `{if}/{else}` conditionals | `App\Support\TemplateRender::apply()` — already used by signatures, NoE PDFs, payment receipts |
|
|
|
|
|
|
|
|
| Value escaping | the same method's `$escapeValues` parameter; signatures already pass it |
|
|
|
|
|
|
|
|
| Grouped variable sidebar | `TemplateRender`'s helper that turns a token→label map into grouped, alphabetised lists for a template editor |
|
|
|
|
|
|
|
|
| Live preview | the JS mirror `resources/js/utils/template.js` (`applyTemplate`) |
|
|
|
|
|
|
|
|
| Visual, source-free editing | the shared EmailComposer's anatomy (visual-only, no HTML source) |
|
|
|
|
|
|
|
|
| Device type from a User-Agent | `ActiveSessionController::device()` — already yields "Safari · iPhone", and its OS table separates iPhone/iPad/Android from the desktop platforms |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
So this is mostly assembly. The genuinely new parts are a public Blade route, a
|
|
|
|
|
|
|
|
templates table, and the editor screen.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**Prerequisite:** `ActiveSessionController::device()` is a private method on a
|
|
|
|
|
|
|
|
controller. Extract it to `App\Support\UserAgent::describe()` and have the Logged-in
|
|
|
|
|
|
|
|
Users page and the portal both call it. This is also finding 3a of the Plan 1
|
|
|
|
|
|
|
|
review, which wants the same extraction for a different reason.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Decisions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| # | Decision | Rationale |
|
|
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
| 1 | The designer covers **four** templates: splash, success, over-limit, refused | The after-login pages are where a pupil is actually stuck, and they can use quota facts the splash cannot |
|
|
|
|
|
|
|
|
| 2 | **Visual editor inside a fixed frame** | The sign-in button must not be removable; a bad edit would brick the portal for everyone at once |
|
|
|
|
|
|
|
|
| 3 | Device variables everywhere; person and quota variables on after-login pages only | Pre-login there is no signed-in user, so they would render blank |
|
|
|
|
|
|
|
|
| 4 | The splash is a **plain server-rendered Blade page**, not Inertia | The captive-network mini-browser is a stripped-down WebView; SPA bootstrapping is unreliable there |
|
|
|
|
|
|
|
|
| 5 | **Escape every substituted value; no `rawKeys`** | `{browser}` and `{device_type}` derive from a client-controlled header, on a public page |
|
|
|
|
|
|
|
|
| 6 | **No HTML sanitiser** on the operator's body | No library present, and a homemade allowlist is worse than none. Mitigated by a source-free editor and the `unifi.manage` gate. Residual risk stated below |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## A. The four templates
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Template key | Rendered by | Auth | Variables |
|
|
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
| `splash` | new public route, Blade | none | device only |
|
|
|
|
|
|
|
|
| `success` | `captiveCallback`, after authorizing | auth | device + person/quota |
|
|
|
|
|
|
|
|
| `over_limit` | `captiveCallback`, quota branch | auth | device + person/quota + the device list |
|
|
|
|
|
|
|
|
| `refused` | `captiveCallback`, no-mapping or resolver refusal | auth | device only — there may be no mapping to quote |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**The splash needs a new public route.** `captiveCallback` sits behind `['web','auth']`
|
|
|
|
|
|
|
|
today, so an unauthenticated hit does `redirect()->route('login')` and the pupil
|
|
|
|
|
|
|
|
lands on the ordinary shell login. That is why there is no splash at all now.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Flow: UniFi redirects → public splash (device facts, "Sign in with Google") →
|
|
|
|
|
|
|
|
Google → `captiveCallback` → success / over-limit / refused.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The three after-login pages stay Inertia: by then the pupil has been through Google
|
|
|
|
|
|
|
|
in a real browser.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Storage
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A `unifi_portal_templates` table, one row per key: `key`, `body`, `updated_at`.
|
|
|
|
|
|
|
|
Not `Setting`, because these are long HTML bodies and a table gives a per-template
|
|
|
|
|
|
|
|
`updated_at` and a clean per-template reset to default.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Variables
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Device — available on all four:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Token | Source | Trustworthy? |
|
|
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
| `{ip}` | `$request->ip()` via Traefik | Yes in practice — production shows real per-client addresses |
|
|
|
|
|
|
|
|
| `{mac}` | derived from the IP via UniFi's client table | Yes — server-derived |
|
|
|
|
|
|
|
|
| `{is_randomized}` | locally-administered bit on the derived MAC | Yes — server-derived |
|
|
|
|
|
|
|
|
| `{browser}` | `App\Support\UserAgent::describe()` | **No — client-controlled** |
|
|
|
|
|
|
|
|
| `{device_type}` | same | **No — client-controlled** |
|
|
|
|
|
|
|
|
| `{ssid}`, `{ap_name}` | the redirect / the client record | Yes |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Person and quota — after-login pages only: `{name}`, `{role}`, `{devices_used}`,
|
|
|
|
|
|
|
|
`{device_limit}`, `{expires_on}`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
On the splash template the person and quota tokens are **shown disabled with the
|
|
|
|
|
|
|
|
reason**, not hidden and not silently rendering blank. Hiding them invites the
|
|
|
|
|
|
|
|
question "why can't I show their name"; blanking them ships a broken page.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## B. The editor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A new tab on the existing Unifi → Portal settings page, gated on `unifi.manage` —
|
|
|
|
|
|
|
|
the key that already guards portal settings.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- **Template switcher** across the four keys. One screen, not four.
|
|
|
|
|
|
|
|
- **Fixed frame.** Logo slot, editable content region, sign-in button, footer. The
|
|
|
|
|
|
|
|
frame and the button are our markup and are not part of the editable body, so no
|
|
|
|
|
|
|
|
edit can remove the way in.
|
|
|
|
|
|
|
|
- **Variable picker** beside the editor, grouped Device / Person & Quota, built from
|
|
|
|
|
|
|
|
`TemplateRender`'s grouping helper.
|
|
|
|
|
|
|
|
- **Live preview** through the JS mirror, fed sample values. The token set is
|
|
|
|
|
|
|
|
defined once, server-side, and handed to the page — the memory on this engine is
|
|
|
|
|
|
|
|
explicit that the JS mirror and the PHP engine must change together, so a single
|
|
|
|
|
|
|
|
source for the token list is the point.
|
|
|
|
|
|
|
|
- **Images must be local.** The picker offers uploaded images only, and pasting an
|
|
|
|
|
|
|
|
external URL warns. See section C — a remote asset cannot load on the splash.
|
|
|
|
|
|
|
|
- **Reset to default** per template.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## C. Rendering safety
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Escape everything
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`TemplateRender::apply($body, $values, escapeValues: true)` with **no `rawKeys`**.
|
|
|
|
|
|
|
|
`{browser}` and `{device_type}` come from the User-Agent, so a crafted header would
|
|
|
|
|
|
|
|
otherwise be reflected XSS on an unauthenticated page. `{ip}` and `{mac}` are
|
|
|
|
|
|
|
|
server-derived and escaped anyway, because there is no reason not to. Nothing needs
|
|
|
|
|
|
|
|
to bypass escaping: with a fixed frame the sign-in button is markup, not a token.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The engine defaults `escapeValues` to false, which is correct for its email and PDF
|
|
|
|
|
|
|
|
callers. This is therefore an explicit decision at the call site, and the reason
|
|
|
|
|
|
|
|
belongs in a comment there.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### No sanitiser on the operator's body, and what that leaves
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
There is no HTML sanitising library in the project and no allowlist helper. Writing
|
|
|
|
|
|
|
|
one is rejected: a homemade allowlist is leaky in ways nobody audits, which is worse
|
|
|
|
|
|
|
|
than not having one.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Mitigations: the editor is visual-only and offers no source view or script
|
|
|
|
|
|
|
|
insertion, and the body is editable only by `unifi.manage` holders.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**Residual risk, stated plainly:** an admin holding `unifi.manage` can put arbitrary
|
|
|
|
|
|
|
|
HTML on a public page. That is a trusted-insider scenario rather than an escalation —
|
|
|
|
|
|
|
|
the same admin already controls the portal, the VLAN mappings and the device quotas.
|
|
|
|
|
|
|
|
But it is a real difference from signatures, which use the same engine and render
|
|
|
|
|
|
|
|
into email rather than onto the open internet. If the trust boundary ever changes —
|
|
|
|
|
|
|
|
say portal editing is delegated to a non-admin role — this decision must be revisited
|
|
|
|
|
|
|
|
first.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### The constraint most likely to break a rollout
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**The splash is served before the device has internet access.** That is what a
|
|
|
|
|
|
|
|
captive portal is. Any external asset — a CDN, Google Fonts, a remote logo — silently
|
|
|
|
|
|
|
|
fails to load, and the pupil gets an unstyled page at the worst possible moment.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Therefore: all CSS and images local, no external fonts, and UniFi's pre-auth walled
|
|
|
|
|
|
|
|
garden must allow this host. The likeliest way this breaks in practice is a
|
|
|
|
|
|
|
|
well-meaning operator pasting a remote logo URL into the editor, which is why
|
|
|
|
|
|
|
|
section B restricts images rather than merely advising.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
If a template row is missing, empty, or throws while rendering, render a built-in
|
|
|
|
|
|
|
|
default body. A blank or broken template must never leave a pupil with no way in —
|
|
|
|
|
|
|
|
the same reasoning as the fixed frame, applied to content instead of structure.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The splash carries a device's MAC and IP, so: `Cache-Control: no-store` and
|
|
|
|
|
|
|
|
`noindex`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## D. Testing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Covered here:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- Escaping: a hostile User-Agent (`<script>` and an `onerror=` attribute) renders
|
|
|
|
|
|
|
|
escaped in the splash body.
|
|
|
|
|
|
|
|
- The public splash route renders with **no** authentication.
|
|
|
|
|
|
|
|
- Person and quota tokens on the splash render as their disabled placeholder rather
|
|
|
|
|
|
|
|
than leaking a blank or a previous user's value.
|
|
|
|
|
|
|
|
- A missing template row, and an empty body, each fall back to the default.
|
|
|
|
|
|
|
|
- The after-login pages render quota values — `devices_used`, `device_limit`,
|
|
|
|
|
|
|
|
`expires_on`.
|
|
|
|
|
|
|
|
- **Rendered splash HTML references no external hosts.** This is the regression most
|
|
|
|
|
|
|
|
likely to creep back in, and it is invisible on a developer machine that has
|
|
|
|
|
|
|
|
internet.
|
|
|
|
|
|
|
|
- `UserAgent::describe()` against real iOS, Android, macOS and Windows strings, plus
|
|
|
|
|
|
|
|
an empty one.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Browser pass on the splash in a plain, non-Inertia context.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**Not provable here** — same hardware list as the access-model spec: whether UniFi's
|
|
|
|
|
|
|
|
walled garden lets this host through pre-auth, and how the real Captive Network
|
|
|
|
|
|
|
|
Assistant behaves with the page.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Out of scope
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- **Drag-and-drop or free positioning.** A captive portal is a single narrow column
|
|
|
|
|
|
|
|
on a phone; absolute positioning is actively wrong for it.
|
|
|
|
|
|
|
|
- **Conditionals on `{device_type}` as a decision.** The engine supports
|
|
|
|
|
|
|
|
`{if device_type == 'iPhone'}` and the picker will not encourage it: it puts a
|
|
|
|
|
|
|
|
spoofable value into a decision rather than a display, which contradicts the
|
|
|
|
|
|
|
|
report-only stance agreed for device type.
|
|
|
|
|
|
|
|
- **Enforcement of any kind.** This designer changes what a pupil is told, never
|
|
|
|
|
|
|
|
what they are allowed.
|
|
|
|
|
|
|
|
- **Per-role or per-SSID template variants.** One template per key. Add variants
|
|
|
|
|
|
|
|
when somebody asks for them.
|
|
|
|
|
|
|
|
- **A block builder.** Considered and rejected: it invents a pattern this codebase
|
|
|
|
|
|
|
|
has no precedent for, and the layout freedom it buys is limited on a phone.
|