# Studio Console — Security Model

The Console is a local Flask web app that reads project state and runs render
subprocesses. It is *not* a public service. Even so, the threat model is real
because:

- A browser tab on any other website can send requests to `localhost:5000`
- DNS rebinding can make a remote attacker appear to the browser as `localhost`
- Subprocess execution from web input is the most common RCE vector in tooling
- Path traversal is the most common file-disclosure vector in static servers

This document lists the threats and the countermeasure for each. **Every
control listed here must be in place before any subprocess endpoint is
wired.** Violations should fail closed.

---

## Threat 1 — Network exposure

**Threat.** Flask's default behavior is to bind to `0.0.0.0`, exposing the
service to every device on the network. On a coffee-shop wifi, anyone could
hit `http://your-laptop-ip:5000/render?...`.

**Control.**
- Bind to `127.0.0.1` only. Loopback interface, never reachable from the
  network. Hardcoded in `server.py::run()`.
- Document: there is no flag to expose to the network. If the user wants
  remote access, they tunnel via SSH.

---

## Threat 2 — DNS rebinding

**Threat.** A website on `evil.com` can return DNS records that resolve
`evil.com` to `127.0.0.1`. The browser then treats `evil.com` as a same-origin
endpoint with the local server. JavaScript on `evil.com` can send requests
that look like legitimate browser-origin localhost traffic.

**Control.**
- Validate the `Host` header on every request. Allowlist:
  `localhost`, `localhost:5000`, `127.0.0.1`, `127.0.0.1:5000`.
- Any other Host value → return 400 with no body.

---

## Threat 3 — Cross-Site Request Forgery (CSRF)

**Threat.** Any website you visit can use a `<form>` or `fetch()` to send a
POST to `localhost:5000/render?project=...`. The browser dutifully attaches
your localhost cookies. Without a CSRF token, the request executes.

**Control — token-based, two layers.**

1. **Origin/Referer check.** Every state-changing request (POST/DELETE) must
   have `Origin` or `Referer` matching the same allowlist as Threat 2. Reject
   otherwise.

2. **Per-session CSRF token.** Server generates a random 32-byte token at
   startup, stored in `~/.spore-studio-console.token` with mode 0600. The
   first GET to `/` reads the token from disk and embeds it in a hidden form
   field + sets a cookie. Every state-changing request must include the token
   in the `X-CSRF-Token` header AND the cookie must match. Mismatch = 403.

3. **Token displayed at startup** so the user can confirm it on first visit
   and bookmark.

---

## Threat 4 — Subprocess command injection

**Threat.** If any subprocess invocation uses `shell=True` or builds a command
string by concatenation, an input like `; rm -rf /` becomes RCE.

**Control.**
- ALL subprocess calls use `subprocess.run(args=[...], shell=False, ...)`.
  Hardcoded as a constant in `render_runner.py`.
- The `args` list is built from validated tokens, never from concatenated
  strings.
- A linter check at startup grep's the codebase for `shell=True` and
  refuses to start if any are found.

---

## Threat 5 — Path traversal in subprocess args

**Threat.** A request like `?project=../../../../etc/passwd.yaml` could pass
a path outside the projects directory to `render_project.py`, which would
load and possibly leak the file.

**Control.**
- Project names are validated against `^[a-zA-Z0-9_]+$`. No dots, no slashes,
  no parent references. Any other value → 400.
- After lookup, the resolved path is checked: `path.resolve()` must start
  with `tv/studio/projects/`. Any escape → 400.
- Same controls for character names (alphabet, characters/), effect names
  (allowlist), and output filenames.

---

## Threat 6 — Path traversal in static file serving

**Threat.** `?file=../../../../Windows/System32/notepad.exe` to a static
serving endpoint could leak any file readable by the Python process.

**Control.**
- Use `flask.send_from_directory(safe_base, filename)` exclusively. Never
  `send_file(user_supplied_path)`.
- Whitelist of safe bases: `tv/output/`, `tv/assets/`, `tv/scenes/`. No
  others.
- Filename component validated against `^[a-zA-Z0-9._/-]+$` and resolved-path
  check, same as Threat 5.

---

## Threat 7 — XSS via subprocess output

**Threat.** Render subprocess output may contain HTML-like content (file
paths with `<>` etc., or maliciously-named files). If rendered into the page
without escaping, it becomes XSS.

**Control.**
- Flask's Jinja2 has autoescape ON by default. Confirm in `server.py`.
- Subprocess output rendered as `<pre>{{ output }}</pre>`. Jinja escapes
  `&<>"'` automatically.
- `|safe` filter is forbidden in templates. Lint at startup.

---

## Threat 8 — Effect name allowlist

**Threat.** Submitting `?effect=__import__('os').system('rm -rf')` — or any
effect name that maps to an arbitrary Python module load — would execute
attacker code.

**Control.**
- Hardcoded `ALLOWED_EFFECTS = {"brainrot_v5", "watercolor_pass",
  "vector_flat_pass", "van_gogh_pass"}` in `security.py`.
- Reject any other effect name.

---

## Threat 9 — File write from web input

**Threat.** A POST that writes any file (project YAML, character YAML, etc.)
opens path-traversal-on-write. Worse than read because it persists.

**Control.**
- v0.1 has NO write endpoints. Read + spawn-subprocess only.
- v0.2 may add YAML editing; will require its own threat-model addition
  before shipping.

---

## Threat 10 — Subprocess resource exhaustion

**Threat.** Repeated render requests could spawn many subprocesses, exhaust
RAM/CPU, lock up the machine.

**Control.**
- Single-render queue. At most one render subprocess running at a time.
  Subsequent requests return 429 Too Many Requests with a polite message.
- Per-render timeout: 600 seconds. Subprocess killed after that.
- Subprocess output bounded: 16MB cap on captured output, truncate beyond.

---

## Threat 11 — Environment variable leakage

**Threat.** A subprocess inheriting parent environment could leak API keys
(ANTHROPIC_API_KEY, etc.) into render logs that get displayed in the UI.

**Control.**
- Subprocess `env=` argument sanitized: only PATH, PYTHONPATH, HOME, USER
  forwarded. All other env vars scrubbed.
- Render output displayed only to the same browser session that initiated
  the render (token-gated).

---

## Threat 12 — Logging accidentally writes secrets

**Threat.** Logging every request with all headers could write the CSRF
token to the log file, where any process reading the log gets the token.

**Control.**
- Logger filters `Authorization`, `Cookie`, and `X-CSRF-Token` headers.
  Replaced with `[REDACTED]`.
- Logs written to `~/.spore-studio-console.log` with mode 0600.
- Log rotation: 10MB cap, keep 3 generations.

---

## Threat 13 — Browser history leaks token

**Threat.** Token in URL query string ends up in browser history,
referrer headers, server logs of upstream proxies, etc.

**Control.**
- Token NEVER in URL. Always in `X-CSRF-Token` header (set by HTMX) or
  cookie (set by initial page load).

---

## Threat 14 — Markdown viewer (`/docs/<filename>`) path traversal

**Threat.** v0.2 dashboard added a markdown viewer route to surface
working-paper content (multiplane_substrate.md, README, PRICING,
SECURITY itself, etc.). A naive implementation could be tricked into
reading any file via `..` segments, URL-encoded slashes, or absolute
paths.

**Control — three layers.**
1. **Hardcoded allowlist.** `_DOC_ALLOWLIST` in `server.py` is a
   `dict[filename → (base_dir, on_disk_name)]` with exactly six entries.
   Any filename not in this dict 404s before any filesystem access.
2. **`resolve_safe()` belt-and-suspenders.** Even though both sides of
   the mapping are hardcoded, the on-disk filename is re-validated with
   `resolve_safe(Path(on_disk), base)` to confirm the resolved path lies
   under the claimed base. Defends against future code that might let
   filenames flow into the mapping value.
3. **`send_from_directory()` only.** The standard Flask helper that
   confines reads to the supplied base directory.

**MIME.** Files are served as `text/markdown; charset=utf-8`. With the
`X-Content-Type-Options: nosniff` response header (Threat 17), browsers
will not sniff the body as HTML, so embedded `<script>` tags in any
markdown content cannot execute.

**Verified probes (live server):**
- `/docs/../README.md` → 404 (Flask URL routing strips `..`)
- `/docs/..%2FREADME.md` → 404
- `/docs/..%252FREADME.md` → 404
- `/docs/%2e%2e/README.md` → 404
- `/docs/etc/passwd` → 404 (not in allowlist)
- `/docs/secret.md` → 404 (not in allowlist)
- `/docs/security.md` → 404 (case-sensitive allowlist; `SECURITY.md`
  works, `security.md` does not)
- `HEAD /docs/secret.md` → 404 (allowlist enforced regardless of method)

---

## Threat 15 — UI theme cookie tampering

**Threat.** v0.2 dashboard added a `?theme=` query parameter that sets
a `console_theme` cookie controlling which CSS bundle is loaded
(default vs. SPOREFACE). A naive implementation could let an attacker
inject a script-laden cookie value, or set a value that flows into HTML
unescaped.

**Control.**
- **Allowlist on write.** `?theme=X` sets the cookie only when X is
  literally `"default"` or `"myspace"` (`_VALID_THEMES` tuple, strict
  `in` check). Anything else is silently ignored.
- **Allowlist on read.** The cookie is re-validated on every request:
  `if cookie not in _VALID_THEMES: theme = "default"`. Manually edited
  cookies fall back to default.
- **Conditional, not interpolated.** The theme value flows only into
  Jinja `{% if theme == 'myspace' %}` conditionals. It is never
  interpolated into HTML attributes or text content.
- **Cookie attributes.** `HttpOnly`, `SameSite=Strict`, `Path=/`,
  `Max-Age=2592000` (30 days), `Secure` omitted (loopback only).
  Verified via raw-socket probe.

The route is GET-safe (idempotent display preference, no real state
mutation). A malicious site can navigate a user to
`http://localhost:5000/?theme=myspace`; the user's UI flips. Annoyance,
not a security boundary. SameSite=Strict prevents the cookie from being
attached to subsequent cross-site navigations.

---

## Threat 16 — DNS rebinding via iframe / clickjacking

**Threat.** A malicious page on `evil.com` can iframe
`http://localhost:5000/` and overlay clickbait UI to trick the user
into clicking dashboard buttons (clickjacking). With DNS rebinding
the iframe could even appear same-origin.

**Control.**
- `X-Frame-Options: DENY` response header on every response. Browsers
  refuse to render the dashboard inside any frame.
- `Referrer-Policy: same-origin` so dashboard URLs are not leaked to
  external sites the user navigates away to.
- Combined with the existing Host header validation (Threat 2) and
  Origin/Referer check on POST endpoints (Threat 3).

---

## Threat 18 — Third-party CDN script in privileged origin

**Threat.** The dashboard initially loaded HTMX from `unpkg.com` via
`<script src="https://unpkg.com/htmx.org@1.9.10">`. That script
executes inside the dashboard's `localhost:5000` origin, where it can
read the CSRF cookie/header, issue same-origin POSTs to `/api/render`,
and inspect the page's structured data. A CDN compromise — or even an
unexpected publisher-side update to that exact pinned URL — would
silently bypass the CSRF and Origin defenses described above.

**Control.**
- HTMX (`htmx.min.js`) and the json-enc extension (`htmx-json-enc.js`)
  are now **vendored** into `studio/console/static/`. All three
  dashboard templates (`index.html`, `compose.html`, `compose_canvas.html`)
  reference `/static/htmx.min.js` and `/static/htmx-json-enc.js`
  exclusively.
- No `https://` script tags load into the dashboard. The page is
  fully self-hosted.
- Vendored versions: HTMX 1.9.10 (47755 bytes, sha256
  `b3bdcf5c741897a53648b1207fff0469a0d61901429ba1f6e88f98ebd84e669e`),
  json-enc 1.9.10 (360 bytes, sha256
  `a09e9ae07419ae88873a595fa67e8f0bcc72783435f7cfd7326150290b1ae3ae`).

When updating HTMX, re-vendor and update these hashes here.

---

## Threat 17 — MIME sniffing on static / docs responses

**Threat.** Older browsers (and some modern configurations) "sniff"
response bodies to guess content type, ignoring the `Content-Type`
header. A markdown file containing inline `<script>` could be sniffed
as HTML and execute, even though we send `text/markdown`.

**Control.**
- `X-Content-Type-Options: nosniff` response header on every response.
  Forces the browser to respect the declared MIME type.

---

## Verification

A startup self-test runs each control:

1. Bind address check — assert socket bound to `127.0.0.1`
2. Subprocess linter — grep codebase for `shell=True`, fail if found
3. Template linter — grep templates for `|safe`, fail if found
4. Allowlist sanity — assert effects/projects/characters lists are non-empty
   and contain only validated names
5. Token file permissions — assert mode is 0600 on Unix; on Windows confirm
   user-only ACL via `os.access` check

If any check fails, the server refuses to start and prints the failure.

---

## Out of scope (intentional)

- **Encryption at rest.** Project YAMLs and renders are already on disk;
  console is not a key-management system.
- **HTTPS.** Loopback traffic is not on the wire. HTTPS would add
  self-signed-cert UX hassle for no real defense.
- **Multi-user auth.** Console is a single-user developer tool. If the user
  needs multi-user, they should not use this.
- **Remote access.** Forbidden by design; see Threat 1.

---

## Reference

The threat model derives from the OWASP Top Ten and the standard local-dev-
tool exposures (CSRF on localhost, DNS rebinding for IoT-style devices, etc.).
The controls match what production-grade local-only dashboards (e.g. Jupyter
since the post-2018 token-auth requirement) implement.
