Design: captive portal splash designer
Let an operator lay out what a pupil sees at the portal, with the device's own facts
as variables, instead of the four hardcoded strings there are today.
Mostly assembly rather than invention. TemplateRender already does {token}
substitution with {if}/{else} and value escaping; it already has a helper that
groups a token map into an editor sidebar; there is already a JS mirror for live
preview; and ActiveSessionController::device() already turns a User-Agent into
"Safari · iPhone" and already separates iPhone/iPad/Android from the desktop
platforms. The genuinely new parts are a public Blade route, a templates table and
the editor screen.
Four decisions worth recording because they were not obvious.
The splash cannot be an Inertia page. Every other page in this app is, but the
captive-network mini-browser is a stripped-down WebView where SPA bootstrapping is
unreliable — so the splash is plain server-rendered Blade and only the after-login
pages stay Inertia.
The frame is fixed and the sign-in button is not part of the editable body. A bad
edit would otherwise brick the portal for everybody at once.
Every substituted value is escaped, with no rawKeys. {browser} and {device_type}
derive from the User-Agent, which the pupil controls, on a page served without
authentication. The engine defaults escaping OFF, correctly for its email and PDF
callers, so this is an explicit call at the call site.
And the constraint most likely to break a rollout: the splash is served BEFORE the
device has internet, so any external asset silently fails and the pupil gets an
unstyled page at the worst moment. All assets local, walled garden must allow this
host, and the editor restricts images rather than merely advising — because a
well-meaning operator pasting a remote logo URL is the likeliest way this breaks.
No HTML sanitiser, deliberately: there is no library here and a homemade allowlist
is leaky in ways nobody audits. The residual risk is stated rather than glossed — an
admin with unifi.manage can put arbitrary HTML on a public page, which is
trusted-insider rather than escalation, but it differs from signatures because those
render into email and this renders onto the open internet. Revisit if portal editing
is ever delegated.
Queues behind the six open findings from the Plan 1 review.
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user