Files
central-unifi/docs/superpowers/specs/2026-08-21-portal-splash-designer-design.md
jwed 868f5edeed 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.
2026-08-21 15:32:10 -04:00

10 KiB

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.