UI Trust Boundaries
This page documents the trust boundaries of the Playroll desktop UI (playroll-ui, an Electron application) and the validation applied at each one. It is the renderer / main-process counterpart of Input Trust Boundaries, which covers the C++ core service.
The UI is the only software in the Playroll stack that:
- Runs renderer code from
file://(production) or the Vite dev server (development), with strict Electron sandbox + context isolation. - Handles the Steam OpenID OAuth flow in a separate
BrowserWindowwith its own session partition. - Speaks the localhost HTTP API exposed by
playroll-core.exe, authenticated with a per-session secret. - Talks to Supabase from the main process (the renderer never sees the service-role or access tokens).
- Fetches cover artwork from an external Playnite-IGDB metadata proxy.
- Resolves and downloads its own installer updates from a Supabase Edge Function and an HTTPS releases CDN.
It does NOT expose nodeIntegration to the renderer, does NOT enable webSecurity: false or allowRunningInsecureContent, does NOT accept inbound network traffic, and does NOT load remote scripts in the main window.
What we treat as trusted
| Surface | Why trusted |
|---|---|
The signed playroll-ui binary itself | Built and code-signed by the installer pipeline (DigiCert). |
%LOCALAPPDATA%\Playroll\auth.json | Encrypted at rest via Electron safeStorage (DPAPI on Windows), tied to the OS user. |
%LOCALAPPDATA%\Playroll\core-session-secret.txt | Written only by the UI; shared with the Core over a loopback bind. |
The main BrowserWindow after it has loaded the bundled file:// index | Sandboxed, context-isolated, served exclusively from the packaged ASAR. |
Responses from @supabase/supabase-js after the JWT has been validated by Supabase | The SDK handles transport and signature validation against the project's auth server. |
What we treat as untrusted
| Surface | Source | Why untrusted |
|---|---|---|
| IPC payloads from the renderer | Renderer process | Defense in depth: a compromised renderer (e.g. via XSS-equivalent in a rendered string) must not be able to corrupt the main process. |
Responses from playroll-core on 127.0.0.1:8080 | Local Core service | Local but a separate process boundary; the UI does not assume schema fidelity from a possibly-mismatched Core version. |
playroll://auth/callback?... deep links | Anywhere on the host that can invoke the registered protocol handler (browsers, mail clients, other apps) | A victim clicking an attacker-crafted link in their browser must not be able to overwrite the user's stored session. |
| Cover/artwork URLs from the Playnite-IGDB metadata proxy | External community service | Outside our control; the URLs flow directly into renderer <img src>. |
resolve-update edge function response (download URL + SHA-256) | Supabase project under our control | Authenticated, but security-critical because downloadUrl reaches the installer downloader. |
On-disk preference files (preferences.json) | The user (or any process running as the user) | Path values flow into shell.openPath; an edited file must not be able to traverse out of the recordings root. |
Defenses by surface
IPC main ↔ renderer
All channels go through a single registration helper that combines two guards:
| Defense | Where | Notes |
|---|---|---|
| Trusted-sender check | src/main/ipc/trusted-sender.ts | Rejects IPC from any webContents whose URL is not the configured renderer entry point (production file:// or the Vite dev URL). Subframe IPC is also rejected. |
| Strict schema validation | src/main/ipc/validated-handler.ts + src/shared/schemas/ | Every payload-bearing channel is registered with a zod schema. Unknown keys, wrong types, out-of-range numbers, oversized strings, prototype-pollution keys, and path traversal are rejected at the boundary. |
| Telemetry on failed validation | OTEL event ipc.validation_failed | Logs the channel name and zod issue paths only — never the raw payload (avoids leaking tokens or PII). |
| Insecure-bypass env vars ignored in packaged builds | PLAYROLL_INSECURE_DISABLE_IPC_SENDER_GUARD | Gated on !app.isPackaged. |
Specific hardening worth calling out:
SHOW_IN_FOLDERrequires the resolved path to live under the user's recordings root before forwarding toshell.openPath/shell.showItemInFolder.OPEN_EXTERNAL_URLallows onlyhttp://,https://, andsteam://schemes, parsed through the WHATWG URL parser.- Path inputs flow through
safePathSchemawhich rejects parent-directory traversal, NUL bytes, UNC paths, and embedded URL schemes. - Free-text inputs have explicit length bounds; objects are
.strict()(extra keys rejected).
Steam OAuth flow
The Steam login popup runs in its own BrowserWindow with a dedicated session partition:
| Defense | Where |
|---|---|
sandbox: true + contextIsolation: true + nodeIntegration: false | src/main/auth/auth-manager.ts:openSteamLogin |
setWindowOpenHandler denies every window.open / target=_blank | Same |
will-navigate and will-redirect allowlisted to the configured auth origin plus Steam community/store/login hostnames | Same |
playroll://auth/callback?... accepted only while a login flow is genuinely pending | handleAuthCallback / handleAuthError |
| Pending-login state consumed on the first callback (no replay) | Same |
| Bounded pending window (5 minutes) | Same |
Together these block the "victim clicks an attacker-crafted protocol link in the browser" session-hijack path: without an active openSteamLogin call the callback is rejected with No login in progress.
Localhost playroll-core API
| Defense | Where |
|---|---|
Per-session shared secret in the X-Api-Key header | src/main/core-lifecycle.ts:coreFetch |
| Secret regenerated on every UI install / first launch | getOrCreateCoreSessionSecret in src/main/index.ts |
| Insecure-bypass env vars ignored in packaged builds | PLAYROLL_INSECURE_DISABLE_CORE_LOCAL_AUTH, gated on !app.isPackaged |
/health, /ping, /shutdown responses gated by zod schemas | src/shared/schemas/core-responses.ts |
| All other Core HTTP responses gated by a minimal-strict object schema | apiCallValidated in src/main/ipc/handlers.ts |
The minimal-strict + passthrough approach (see docs/security/input-trust-boundaries.md for the Core counterpart) is deliberate: the schema rejects non-object responses at the boundary, and the domain normalizers continue to do alias mapping and fallback shaping. Each layer does what it is good at.
External network responses
The two endpoints outside our direct control:
| Endpoint | Schema | Notes |
|---|---|---|
| Playnite-IGDB metadata proxy | IgdbMetadataEnvelopeSchema | Cover and artwork URL fields are pinned to the images.igdb.com host; URLs with javascript:, data:, file:, and other dangerous schemes are rejected before reaching renderer <img src>. Strict envelope (no passthrough) — unknown proxy fields cannot leak into UI state. |
resolve-update Supabase edge function | ResolveUpdateResponseSchema | downloadUrl is strictly HTTPS-only. installerSha256 is strictly 64-character hex. Closed enums for reason and assignment.track. Strict envelope. |
The downloaded installer is verified against the SHA-256 from this response before being launched. Authenticode signature verification is a planned follow-up; the SHA gate is the primary integrity check today.
Supabase server-driven notifications
Rows joined from notification_state + notification_event are gated by ServerNotificationRowSchema before being pushed to the renderer. A malformed or malicious row is dropped silently rather than reaching React state.
On-disk preference files
recordingsPath from %APPDATA%/Roaming/playroll-ui/preferences.json is validated through safePathSchema (no parent traversal, NUL, UNC, URL schemes) before being passed to shell.openPath or mkdir. The runtime store layer (gate-store.ts, preferences-store.ts) keeps its own field-by-field defensive sanitizer as defense in depth against on-disk corruption.
Surfaces we deliberately do not expose
| Surface | Status |
|---|---|
nodeIntegration in the renderer | Disabled. Renderer cannot require() Node modules. |
webSecurity: false / allowRunningInsecureContent | Not set anywhere. |
| Remote script loading in the main window | Not used. The renderer is served from file:// (production) with a strict CSP. |
Renderer-controlled child_process spawn | Not exposed. Launch / focus IPC accepts only a game id, looked up in the locally-detected library. |
webview tag | Blocked at the web-contents-created event. |
| Mic / camera / geolocation / USB / serial permission requests | All denied via setPermissionRequestHandler. |
Reporting a vulnerability
Send security reports to security@extrinsiccognition.com. Please include:
- A clear description of the issue and the impact you believe it has.
- Steps to reproduce, ideally against a local debug build of
playroll-ui. - The version reported on the launcher footer or via the
GET_VERSIONIPC.
We aim to acknowledge reports within two business days. Do not file security issues as public GitHub issues.