Input Trust Boundaries
This page documents the trust boundaries of the Playroll Core (playroll-core.exe) and the validation applied at each one. It is intended for contributors reviewing pull requests, security researchers performing audits, and operators integrating with the local HTTP API.
The Core is a Windows-only background service that:
- Accepts JSON over
http://127.0.0.1:8080from a single trusted local client (the Electron UI). - Reads the game catalog from Supabase as JSON.
- Reads engine config files (
.ini,.xml) under%APPDATA%for detected games. - Writes recordings under
%USERPROFILE%\Videos\PlayrollCaptures(or a user-configured path). - Uploads completed recordings to S3 via short-lived presigned URLs obtained from a Supabase Edge Function.
It does NOT accept network traffic from outside the loopback interface, does not invoke external processes from user-controlled input, and does not embed any scripting engine.
What we treat as trusted
| Surface | Why trusted |
|---|---|
The signed playroll-core.exe binary itself | Built and code-signed by the installer pipeline (DigiCert). |
HKCU\Software\Playnite\PlayRoll\* registry values | Written only by the installer / UI on the same machine. |
%LOCALAPPDATA%\Playroll\* files (auth token, session secret, SQLite DB) | Written only by the Core / UI on the same machine. ACLs default to the user profile. |
The Electron UI when it presents a valid session secret on 127.0.0.1:8080 | Shared secret established at install time; see Data Collection. |
| Hard-coded vcpkg dependencies | Pinned by manifest baseline + vcpkg manifest hash. |
What we treat as untrusted
| Surface | Source | Why untrusted |
|---|---|---|
| HTTP request bodies | Local Electron UI (after session-secret auth) | Defense in depth: a malicious extension or compromised UI must not be able to corrupt the Core. |
| Supabase game catalog | games table / Edge Function | Database content is server-controlled but enters the C++ process as JSON; treated as untrusted to keep the Core robust against bad data, schema drift, or future content moderation gaps. |
Engine config files (.ini, .xml) under %APPDATA%\<Game>\ | Written by the game itself | Game files are not under our control; we read them only to probe resolution settings. |
| HTTP query strings and URL path parameters | Local UI | Same reasoning as bodies. |
Input surfaces and current defenses
HTTP API
All routes are defined in src/service/http_server.cpp and the *_http_callbacks.cpp files. Defenses:
| Defense | Where | Notes |
|---|---|---|
| Bind to loopback only | http_server.cpp | The listener is 127.0.0.1:8080; LAN traffic is rejected at the socket layer. |
| Session-secret authentication | requireSessionSecret() | Every state-changing endpoint requires the secret from %LOCALAPPDATA%\Playroll\core-session-secret.txt. |
| JWT bearer for authenticated calls | requireAuth() | Validates token presence and the local expiry hint. |
| JSON parsing fenced by try/catch | handleJsonRequest() | Malformed JSON returns 400 and is logged via http.invalid_json (throttled). |
| Typed extraction with fallback | jsonGet<T>() | Missing or wrong-type fields fall back to a default, never throw out of the handler. |
| String length caps | jsonGetStringCapped() in include/util/json_validation.hpp | Applied to JWT fields (8 KB), encoder IDs (128 B), lobby identifiers (256 B). Oversize values are rejected as if missing. |
| Numeric range clamping | jsonGetClamped<T>() in include/util/json_validation.hpp | Helper available for any future numeric body field that drives sizing or allocation. |
| Whitelist enums | /api/uploader/action, /api/encoders/select | Action verbs and encoder IDs are validated against fixed allowlists before reaching state-machine code. |
Supabase game catalog
Parsed by src/service/game_catalog_parser.cpp. Defenses:
| Defense | Where |
|---|---|
.value(key, default) and .is_array() checks instead of bare .get<T>() | parseSupabaseGameCatalog() |
display_name is sanitized before being used as a folder name | src/service/recording_paths.cpp via util::sanitizeFilenameComponent |
weakly_normal boundary check on the assembled recording path | src/service/recording_paths.cpp via util::pathIsWithin |
| Sanitizer behavior — see the next section | src/util/path_sanitize.cpp |
File paths derived from external input
A single helper governs every component of a recording path that comes from outside the Core: util::sanitizeFilenameComponent in include/util/path_sanitize.hpp. It is intentionally a single-component sanitizer (no multi-segment paths) and produces results that:
- Contain no path separators (
/,\), drive separators (:), wildcards (*,?), quote ("), angle brackets (<,>), pipe (|), or ASCII control characters. - Are never equal to a Windows reserved device name (
CON,PRN,AUX,NUL,COM1..COM9,LPT1..LPT9), even with an extension. - Have no leading or trailing dots or spaces (which Windows silently trims).
- Are capped at 100 wide characters.
- Are non-empty (returns
unnamedfor an input that sanitizes to empty).
After sanitization, recording_paths.cpp rebuilds the full path and verifies with util::pathIsWithin that the result is a strict descendant of the user's recordings root. If it isn't (a sanitizer bug or a future code change), it falls back to a literal _unsafe_game_folder name and emits the recording.unsafe_game_folder log event.
Engine config files
src/service/resolution_probe.cpp parses XML and JSON config files written by games. The parser:
- Loads files only from paths derived from
%PROGRAMFILES%,%USERPROFILE%, or%LOCALAPPDATA%afterExpandEnvironmentStringsWand alooksResolved()check that rejects strings with unresolved%TOKEN%placeholders. - Uses
pugixmlfor XML and the same.find()/.is_object()/.is_string()pattern for JSON.
These files come from games installed on the user's own machine, so the threat model assumes a benign-but-buggy producer rather than an attacker.
SQL queries
All SQLite access goes through src/service/sqlite_state.cpp. Every query uses sqlite3_prepare_v2() with sqlite3_bind_*() parameters — there is no string concatenation of SQL anywhere in the Core.
Operating-system surfaces we do not expose
| Surface | Status |
|---|---|
CreateProcess / ShellExecute / system from user input | Not used. The capture pipeline initializes NVENC and ffmpeg muxer in-process via library calls only. |
| Loading user-supplied DLLs | Not used. |
| Embedded scripting engine (Lua, JS, ...) | Not present. |
| Inbound non-loopback traffic | Not bound. |
What we deliberately don't ship
libx264is not linked. H.264 encoding goes through the NVENC SDK directly; the muxer only referencesAV_CODEC_ID_H264as a constant.ffmpeg[nonfree]features (e.g.libfdk-aac,libnpp) are not enabled. AAC encoding is done by Windows Media Foundation; no nonfree codec ships in the binary.spdlogandfmtare not linked. The logger is implemented directly overstd::ofstreamandnlohmann::json.
These exclusions both shrink the attack surface and keep the binary redistributable under LGPL terms.
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 build of
playroll-core.exe. - The version label reported by
GET http://127.0.0.1:8080/status.
We aim to acknowledge reports within two business days. Do not file security issues as public GitHub issues.