Skip to main content

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:8080 from 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

SurfaceWhy trusted
The signed playroll-core.exe binary itselfBuilt and code-signed by the installer pipeline (DigiCert).
HKCU\Software\Playnite\PlayRoll\* registry valuesWritten 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:8080Shared secret established at install time; see Data Collection.
Hard-coded vcpkg dependenciesPinned by manifest baseline + vcpkg manifest hash.

What we treat as untrusted

SurfaceSourceWhy untrusted
HTTP request bodiesLocal 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 cataloggames table / Edge FunctionDatabase 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 itselfGame files are not under our control; we read them only to probe resolution settings.
HTTP query strings and URL path parametersLocal UISame 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:

DefenseWhereNotes
Bind to loopback onlyhttp_server.cppThe listener is 127.0.0.1:8080; LAN traffic is rejected at the socket layer.
Session-secret authenticationrequireSessionSecret()Every state-changing endpoint requires the secret from %LOCALAPPDATA%\Playroll\core-session-secret.txt.
JWT bearer for authenticated callsrequireAuth()Validates token presence and the local expiry hint.
JSON parsing fenced by try/catchhandleJsonRequest()Malformed JSON returns 400 and is logged via http.invalid_json (throttled).
Typed extraction with fallbackjsonGet<T>()Missing or wrong-type fields fall back to a default, never throw out of the handler.
String length capsjsonGetStringCapped() in include/util/json_validation.hppApplied to JWT fields (8 KB), encoder IDs (128 B), lobby identifiers (256 B). Oversize values are rejected as if missing.
Numeric range clampingjsonGetClamped<T>() in include/util/json_validation.hppHelper available for any future numeric body field that drives sizing or allocation.
Whitelist enums/api/uploader/action, /api/encoders/selectAction 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:

DefenseWhere
.value(key, default) and .is_array() checks instead of bare .get<T>()parseSupabaseGameCatalog()
display_name is sanitized before being used as a folder namesrc/service/recording_paths.cpp via util::sanitizeFilenameComponent
weakly_normal boundary check on the assembled recording pathsrc/service/recording_paths.cpp via util::pathIsWithin
Sanitizer behavior — see the next sectionsrc/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 unnamed for 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% after ExpandEnvironmentStringsW and a looksResolved() check that rejects strings with unresolved %TOKEN% placeholders.
  • Uses pugixml for 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

SurfaceStatus
CreateProcess / ShellExecute / system from user inputNot used. The capture pipeline initializes NVENC and ffmpeg muxer in-process via library calls only.
Loading user-supplied DLLsNot used.
Embedded scripting engine (Lua, JS, ...)Not present.
Inbound non-loopback trafficNot bound.

What we deliberately don't ship

  • libx264 is not linked. H.264 encoding goes through the NVENC SDK directly; the muxer only references AV_CODEC_ID_H264 as 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.
  • spdlog and fmt are not linked. The logger is implemented directly over std::ofstream and nlohmann::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.