Skip to content

Latest commit

 

History

History
253 lines (193 loc) · 12.4 KB

File metadata and controls

253 lines (193 loc) · 12.4 KB

Security Invariants

This document formalizes the security invariants and authentication policies for the xg2g system.

Authentication Policy: Transport Separation

To protect against Cross-Site Request Forgery (CSRF) while maintaining API usability for non-browser clients, xg2g enforces a strict separation of authentication mechanisms based on the request type:

Request Category Target Path Authentication Mechanism
Control Plane (API) /api/v3/* Authorization: Bearer <Token>
Data Plane (Media) /api/v3/sessions/{id}/hls/* Session Cookie (xg2g_session)

Rationale

  • Bearer Tokens: Ideal for headless automation and non-browser clients. They are not susceptible to CSRF as they are not automatically sent by the browser.
  • Session Cookies: Required for HLS playback in standard HTML5 video players which do not support custom headers for fragment requests. Cookies are scoped to the media domain to prevent cross-site leaks.

Browser Authentication Flow

Operator Requirement

  • Local loopback access (http://localhost:8088 on the same host) may stay on plain HTTP for smoke tests and one-box development.
  • Any browser-facing deployment reached from another device or hostname must be served over HTTPS, either directly in xg2g or through a trusted HTTPS proxy.
  • Public/native reachability candidates for Android, TV, and browser-adjacent bootstrap flows must come from explicit connectivity.publishedEndpoints configuration. Clients do not invent origins from forwarded headers or Docker topology.
  • Public deployment contract evaluation is fail-closed:
    • fatal findings fail startup
    • degraded public findings block the affected runtime flow (pairing or web)
    • warnings stay visible through diagnostics without silently changing behavior
  • When xg2g is intentionally deployed behind a trusted HTTPS proxy, the backend hop from proxy to xg2g may remain plain HTTP on an internal network. Startup cleartext-token warnings are therefore suppressed when trustedProxies is configured. Direct cleartext access to the xg2g listener is still an operator responsibility and should remain LAN-scoped or otherwise blocked.
  • local_http published endpoints remain opt-in only via connectivity.allowLocalHTTP / XG2G_CONNECTIVITY_ALLOW_LOCAL_HTTP and are never advertised for browser cookie bootstrap.
  • trustedProxies must never be world-open in public profiles. 0.0.0.0/0 and ::/0 are rejected by the contract because they let untrusted clients spoof HTTPS-offload headers.
  • Trust only addresses that actually terminate proxy connections. Never put a WireGuard, Tailscale, or other VPN client subnet into trustedProxies; VPN membership is not permission to assert X-Forwarded-* identity or scheme.
  • Keep the backend listener unreachable from untrusted hosts. The production Compose default publishes 8088 on host loopback only. A VPN-only deployment must replace that host bind with the server's exact VPN IP and enforce its WireGuard/Tailscale ACL or host firewall independently.
  • The WebUI uses React Router only as a client-side browser router. RSC, server actions, and data-router action APIs are forbidden by backend/scripts/verify-webui-router-security.sh while the upstream RSC CSRF advisory has no published fixed release.
  • If xg2g sees a non-loopback browser request as plain HTTP, POST /api/v3/auth/session fails closed with 400 HTTPS required. The xg2g_session cookie is not minted, native HLS media requests to /api/v3/sessions/{id}/hls/* fail authorization, and Safari/native players can collapse into generic playback errors such as Video Error: 4.

Public Deployment Contract

Use connectivity.profile to declare which invariants must hold:

  • lan: no public origin required
  • reverse_proxy: public HTTPS must exist and trustedProxies is mandatory
  • tunnel: public HTTPS must exist and trustedProxies is mandatory
  • vps: public HTTPS must exist and xg2g must terminate TLS directly

The canonical operator diagnostic for this contract is:

  • GET /api/v3/system/connectivity

It reports:

  • effective profile and severity
  • startup/readiness/pairing/web blocking state
  • trusted proxy configuration
  • selected published endpoints for web/native/pairing
  • request-scoped forwarded-header trust and effective HTTPS truth

Public Exposure Security Contract

Public reachability also activates an exposure policy model. Every v3 operation has an explicit exposure class, auth kind, browser-trust rule, rate-limit class, and audit requirement.

Public profiles additionally require:

  • no wildcard browser origins
  • published public web origins listed in allowedOrigins
  • at least one scoped API token
  • API tokens with at least 32 non-default characters
  • legacy token sources disabled
  • a playback decision secret when public streaming endpoints are published
  • dedicated abuse-control limits and audit events for Pairing and DeviceAuth
  • single-winner replay semantics for pairing exchange, web bootstrap completion, and rotating device grants
  • versioned exposure audit events with trusted-proxy-resolved client IPs

See docs/ops/PUBLIC_EXPOSURE_SECURITY_CONTRACT.md for the endpoint exposure policy model.

Browser-based clients (integrations) must use the following flow to obtain access to the Data Plane:

  1. API Authentication: The client authenticates against the Control Plane using a Bearer Token.
  2. Session Exchange: The client makes a POST request to /api/v3/auth/session with the Bearer Token.
  3. Cookie Issuance: The server validates the token and responds with a Set-Cookie header:
    • Name: xg2g_session
    • HttpOnly: true (Prevent XSS access)
    • SameSite: Lax (Prevent cross-site ambient sends while preserving top-level navigation flows)
    • Path: /api/v3/ (Scoped to API/Media routes)
    • Secure: true on HTTPS or trusted HTTPS proxy requests
    • Transport Rule: /api/v3/auth/session rejects plain HTTP unless the request originates from loopback (127.0.0.1 / ::1)
  4. Media Access: Subsequent requests to /api/v3/sessions/{id}/hls/* will include the cookie automatically.

Household PIN Policy

Household protection is optional and operator-controlled.

  • Without household.pin or household.pinHash, household profiles remain a convenience and scoping feature only.
  • With a configured PIN, protected household actions are enforced server-side: switching to adult profiles, household settings access, and logout from a child profile.
  • Browser/UI prompts are only UX. The backend remains the source of truth.

Configuration Inputs

Use exactly one of these YAML inputs:

  • household.pin: operator input in plaintext. The loader hashes it before the runtime config is stored.
  • household.pinHash: pre-hashed form for deployments that never want the plaintext PIN written to disk.

household.pinHash is the only form that is persisted back out through config management surfaces. Do not place a plaintext PIN in screenshots, examples, support bundles, or logs.

Optional tuning:

  • household.unlockTTL: duration string controlling how long a successful household unlock stays active on the server. Default is 4h and it is never allowed to outlive the authenticated session lifetime.

Unlock Semantics

  • Unlock state is stored server-side and bound to the xg2g_household_unlock cookie.
  • The cookie is session-scoped in the browser and is also invalidated by the server-side unlock TTL.
  • Unlock state ends on logout, explicit relock, browser-session end, or TTL expiry, whichever comes first.
  • This is a practical household gate, not a hardened boundary against an already-unlocked and unattended client.

Legacy Token Migration (X-API-Token)

Legacy X-API-Token header/cookie sources are supported only for migration and can be disabled explicitly:

  • Flag: XG2G_API_DISABLE_LEGACY_TOKEN_SOURCES=true
  • Default: true (legacy sources are disabled unless an operator explicitly re-enables them for migration)
  • Recommended rollout:
    1. Migrate clients to Authorization: Bearer <token> (API) and xg2g_session cookie (media).
    2. Keep XG2G_API_DISABLE_LEGACY_TOKEN_SOURCES=true.
    3. Monitor auth logs for auth.legacy_token_source before and during cutover.

Admission Control: Fail-Closed Policy

The system implements a "Fail-Closed" policy for resource admission:

  • State Unknown: If the system cannot determine the current resource usage (e.g., due to a database failure), it returns 503 Service Unavailable with problem code ADMISSION_STATE_UNKNOWN.
  • Engine Disabled: If the streaming engine is disabled, all media requests are rejected with 503.

Live Stream Decision Token (JWT)

Live stream intent requests (POST /api/v3/intents) require a short-lived HS256 JWT signed by the server. The signing key is a shared secret that must be configured before the service starts.

Required Environment Variable

Variable Required Min Length Description
XG2G_DECISION_SECRET Yes 32 ASCII bytes HMAC-SHA256 signing key for playback decision tokens.
XG2G_RECORDINGS_TARGET_SIGNING_KEY Yes 32 ASCII bytes HMAC-SHA256 signing key for recording target profiles (protects HLS variant mappings).

The service refuses to start if either of these variables is missing, empty, or whitespace-only. The systemd ExecStartPre gate enforces the 32-byte minimum before any container is started.

Byte-count clarification: The length check uses wc -c (raw byte count). For pure ASCII characters (hex, base64) one character equals one byte, so the check is unambiguous. Avoid Unicode or multi-byte characters in the secret value — use hex or base64 output as shown below.

Recommended: generate with openssl (produces unambiguous ASCII output):

# 256-bit key as hex (64 ASCII chars = 64 bytes — clearly above the 32-byte floor)
openssl rand -hex 32

# Alternative: base64url (43 ASCII chars = 43 bytes, 256 bits of entropy)
openssl rand -base64 32 | tr -d '=' | tr '+/' '-_'

Add to /etc/xg2g/xg2g.env (root:root, mode 0600):

XG2G_DECISION_SECRET=<openssl-output>
XG2G_RECORDINGS_TARGET_SIGNING_KEY=<openssl-output>
secret="$(grep XG2G_DECISION_SECRET /etc/xg2g/xg2g.env | cut -d= -f2)"
printf '%s' "$secret" | wc -c   # must be >= 32

rec_secret="$(grep XG2G_RECORDINGS_TARGET_SIGNING_KEY /etc/xg2g/xg2g.env | cut -d= -f2)"
printf '%s' "$rec_secret" | wc -c   # must be >= 32

Secret Rotation

Rotation model: single-key, restart-based.

JWT tokens are short-lived (≤ 120 s). This means the rotation window is at most 2 minutes, which is acceptable for a single-instance deployment. No dual-key scheme is implemented.

Rotation procedure:

  1. Generate new secrets: openssl rand -hex 32
  2. Update XG2G_DECISION_SECRET and XG2G_RECORDINGS_TARGET_SIGNING_KEY in /etc/xg2g/xg2g.env
  3. Restart the service: systemctl restart xg2g
  4. Expected transient behaviour during restart: clients holding a token signed by the old key will receive a 401 TOKEN_INVALID_SIG on their next intent request for up to one token TTL (≤ 120 s). This is by design. Compliant players re-initiate the intent flow automatically and receive a new token signed with the new key. No manual intervention is required; the error window is bounded by the TTL and does not persist beyond it.

Dual-key rotation (multi-instance only): If you ever run more than one xg2g instance behind a load balancer, implement zero-downtime rotation with a second variable XG2G_DECISION_SECRET_PREV:

  • Deploy new instances with both SECRET (new) and SECRET_PREV (old).
  • Verification accepts tokens signed by either key; new tokens are signed by SECRET only.
  • After all old-key tokens have expired (≤ 120 s), remove SECRET_PREV and re-deploy.

This dual-key extension is not implemented in the current codebase — only add it if you scale to multiple instances. Single-instance operators: the restart procedure above is sufficient.

Feature Flags Governance

Feature flags are treated as a strict product surface:

  • Registry Enforcement: All feature flag keys must be registered (Screaming Snake Case).
  • Type Safety: Values must match registered types (e.g. Bool).
  • Primitives Only: Values MUST be JSON primitives (bool, string, number, null). Nested structures are forbidden.
  • Unknown/Invalid Keys: Result in 400 Bad Request with INVALID_INPUT.