From 868f5edeed3345c8a6c78037a13e9402d9edb780 Mon Sep 17 00:00:00 2001 From: jwed Date: Fri, 21 Aug 2026 15:32:10 -0400 Subject: [PATCH] Design: captive portal splash designer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...026-08-21-portal-splash-designer-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-21-portal-splash-designer-design.md diff --git a/docs/superpowers/specs/2026-08-21-portal-splash-designer-design.md b/docs/superpowers/specs/2026-08-21-portal-splash-designer-design.md new file mode 100644 index 0000000..85cf9c7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-portal-splash-designer-design.md @@ -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 (`