Skip to main content

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 BrowserWindow with 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

SurfaceWhy trusted
The signed playroll-ui binary itselfBuilt and code-signed by the installer pipeline (DigiCert).
%LOCALAPPDATA%\Playroll\auth.jsonEncrypted at rest via Electron safeStorage (DPAPI on Windows), tied to the OS user.
%LOCALAPPDATA%\Playroll\core-session-secret.txtWritten only by the UI; shared with the Core over a loopback bind.
The main BrowserWindow after it has loaded the bundled file:// indexSandboxed, context-isolated, served exclusively from the packaged ASAR.
Responses from @supabase/supabase-js after the JWT has been validated by SupabaseThe SDK handles transport and signature validation against the project's auth server.

What we treat as untrusted

SurfaceSourceWhy untrusted
IPC payloads from the rendererRenderer processDefense 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:8080Local Core serviceLocal but a separate process boundary; the UI does not assume schema fidelity from a possibly-mismatched Core version.
playroll://auth/callback?... deep linksAnywhere 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 proxyExternal community serviceOutside our control; the URLs flow directly into renderer <img src>.
resolve-update edge function response (download URL + SHA-256)Supabase project under our controlAuthenticated, 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:

DefenseWhereNotes
Trusted-sender checksrc/main/ipc/trusted-sender.tsRejects 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 validationsrc/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 validationOTEL event ipc.validation_failedLogs the channel name and zod issue paths only — never the raw payload (avoids leaking tokens or PII).
Insecure-bypass env vars ignored in packaged buildsPLAYROLL_INSECURE_DISABLE_IPC_SENDER_GUARDGated on !app.isPackaged.

Specific hardening worth calling out:

  • SHOW_IN_FOLDER requires the resolved path to live under the user's recordings root before forwarding to shell.openPath / shell.showItemInFolder.
  • OPEN_EXTERNAL_URL allows only http://, https://, and steam:// schemes, parsed through the WHATWG URL parser.
  • Path inputs flow through safePathSchema which 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:

DefenseWhere
sandbox: true + contextIsolation: true + nodeIntegration: falsesrc/main/auth/auth-manager.ts:openSteamLogin
setWindowOpenHandler denies every window.open / target=_blankSame
will-navigate and will-redirect allowlisted to the configured auth origin plus Steam community/store/login hostnamesSame
playroll://auth/callback?... accepted only while a login flow is genuinely pendinghandleAuthCallback / 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

DefenseWhere
Per-session shared secret in the X-Api-Key headersrc/main/core-lifecycle.ts:coreFetch
Secret regenerated on every UI install / first launchgetOrCreateCoreSessionSecret in src/main/index.ts
Insecure-bypass env vars ignored in packaged buildsPLAYROLL_INSECURE_DISABLE_CORE_LOCAL_AUTH, gated on !app.isPackaged
/health, /ping, /shutdown responses gated by zod schemassrc/shared/schemas/core-responses.ts
All other Core HTTP responses gated by a minimal-strict object schemaapiCallValidated 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:

EndpointSchemaNotes
Playnite-IGDB metadata proxyIgdbMetadataEnvelopeSchemaCover 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 functionResolveUpdateResponseSchemadownloadUrl 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

SurfaceStatus
nodeIntegration in the rendererDisabled. Renderer cannot require() Node modules.
webSecurity: false / allowRunningInsecureContentNot set anywhere.
Remote script loading in the main windowNot used. The renderer is served from file:// (production) with a strict CSP.
Renderer-controlled child_process spawnNot exposed. Launch / focus IPC accepts only a game id, looked up in the locally-detected library.
webview tagBlocked at the web-contents-created event.
Mic / camera / geolocation / USB / serial permission requestsAll 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_VERSION IPC.

We aim to acknowledge reports within two business days. Do not file security issues as public GitHub issues.