# Security review — 2026-07-16 (updated after the Supabase→Azure migration)

A security pass over the CreatorStudio suite: secret/credential leakage, injection, XSS, SSRF, path
traversal, DoS, and authorization. This records what was found, what was **fixed in this change**, and
what remains an **open architectural risk requiring a product decision** (not silently changed, because
the fix would break the currently-working app or needs real authentication). The Open Risks section
has been re-baselined against the new architecture (Azure PostgreSQL + the portal's Creator API; no
Supabase, no browser-held keys).

## Headline

- **No secret leakage.** No `.env` is tracked or has ever been committed, and — since the Azure
  migration — **no key of any kind ships in the repo or in a browser bundle**: the Creator talks
  same-origin to the portal API, and every credential (`DATABASE_URL`, `CACHE_BLOB_SAS_URL`, TTS
  keys, `DASHBOARD_TOKEN`) is read from the environment server-side only and never reaches the
  browser (verified `withCreatorEnv`, `GET /config`, and the Angular bundles). `.gitignore` now also
  blocks any future `.env.*` variant.
- The real risks are **cross-tenant authorization gaps in the portal API** (tenant is still a
  client-supplied parameter until real sign-in lands) and an **unauthenticated LAN
  server** — see Open Risks.

## Fixed in this change

| # | Issue | Severity | Fix |
|---|---|---|---|
| 1 | **DOM-XSS** — untrusted MQTT/GTFS/config fields (`triggerLabel`/announcement `ref`, geofence name, proof `type`, GTFS stop name) interpolated **unescaped** into Leaflet `bindTooltip`/`bindPopup` (Leaflet renders strings as HTML). A crafted `ref`/name like `<img src=x onerror=…>` executed in the dashboard origin. | High | HTML-escape every such field before it enters a Leaflet HTML string (`map`, `proof`, `geofences`, `simulator` components). |
| 2 | **Unauthenticated config overwrite** (`POST /config`) — any device on the LAN could replace the engine's live config (e.g. empty arrays → silence all announcements fleet-wide). | Critical | Optional bearer-token gate (`DASHBOARD_TOKEN`); when set, config-import / GTFS-import / TTS-preview require `Authorization: Bearer <token>` (constant-time compare). Unset = unchanged behaviour **+ a startup warning**. |
| 3 | **Prototype-pollution vector** via `POST /config` (JSON persisted for the engine's loader). | Low/Med | Reject payloads containing `__proto__` / `constructor` / `prototype` keys at the boundary. |
| 4 | **Forged proof-of-play** (`POST /player/ack`) — arbitrary JSON was published verbatim to the audit topic. | High | Whitelist + bound the fields, constrain `status`, and **server-stamp** `playedAt`; reject records with no `traceId`. |
| 5 | **Paid-TTS abuse** (`POST /tts-preview`) — unauthenticated calls burn the operator's TTS quota. | High | Gated behind `DASHBOARD_TOKEN` (see #2). (Add per-IP rate limiting if exposed publicly.) |
| 6 | **GTFS import DoS** — 200 MB uploads + zip-bomb decompression → disk/heap exhaustion. | Med/High | Upload capped (`GTFS_MAX_UPLOAD_BYTES`, default 80 MB) and a per-entry uncompressed-size guard in `GtfsFeed` (`GTFS_MAX_ENTRY_BYTES`, default 300 MB) rejects zip bombs. |
| 7 | **SSE bridge resource exhaustion** — unbounded player-stream connections and distinct broker subscriptions. | Med | `PlayerBridge` caps concurrent connections and distinct subscriptions (`PLAYER_BRIDGE_MAX_CONNECTIONS`/`…_SUBSCRIPTIONS`); over cap → 503. Also fixed a double-decrement in cleanup. |
| 8 | **Info disclosure** — `GET /config` returned the absolute install path (`config not found at C:\Users\…`). | Low | Generic message. |
| 9 | **`</script>` breakout** in the env `<script>` injected into the Creator HTML. | Low | Escape `<` → `<` in the injected JSON. |
| — | **`.gitignore`** only ignored `.env`/`.env.local`; a `.env.production`/`.env.vehicle` could be committed by `git add -A`. | Low | Ignore all `.env.*` except `.env.example`. |

All fixes are covered by tests (bridge caps + validation in `test/playerBridge.test.ts`; the front-end
suites still pass) and verified at runtime (401/400 responses confirmed).

### Confirmed safe (checked, not vulnerable)
SPA static serving (`serveSpa` containment check) and `/docs/media` (`basename`, no URL-decode) block
path traversal; `/player/qr.svg` does not fetch the URL (no SSRF) and never echoes it into markup;
`/tts-preview` validates `provider` against an allowlist and takes endpoints from env (no SSRF); MQTT
topic ids are validated (no wildcard/separator injection) and acks publish to a server-fixed topic;
`player.html` assigns every untrusted field via `textContent` only; no `eval`/`new Function`/
`bypassSecurityTrust*`; every portal-API/engine SQL statement goes through `pg` with **bound
parameters** (no SQL injection — identifiers like `CONFIG_DB_TABLE` are allowlist-validated); the
`readBody` cap is enforced.

## Open risks — require a product decision (NOT changed here)

These stem from one root cause: **the CreatorStudio has no end-user authentication** — the browser
holds no credential at all (good), but the "tenant" is a client-supplied value the portal API
trusts. Fixing that means introducing real sign-in, which would change how the app works, so it is
documented rather than silently enabled. The Azure migration itself **closed** the risks that were
inherent to the old Supabase surface:

1. ~~**CRITICAL — `publish-engine-config` trusts `body.tenant`.**~~ **CLOSED (surface deleted) /
   restated:** the edge function is gone; publishing is now the portal's `POST /api/publish`, which
   is bearer-gated by `DASHBOARD_TOKEN` and parameterized — but it still takes `tenant` from the
   request body. With the token set, only token-holders can publish at all; **per-tenant**
   authorization within the API still requires real sign-in (below).
2. ~~**HIGH — `manage-project` trusts `body.tenant` for anon callers.**~~ **CLOSED (surface deleted) /
   restated:** project writes are now `PUT`/`DELETE /api/projects` behind the same `DASHBOARD_TOKEN`
   gate. Same residual as (1): tenant is an explicit parameter, not a session-derived claim.
3. ~~**HIGH — RLS read policies collapse to world-readable for the anon key.**~~ **CLOSED.** There is
   no anon key and no browser-reachable database gateway any more: the Postgres server is private
   (VM allow-list / VNet, `sslmode=require`) and every read goes through the portal API, which asks
   for one tenant's rows explicitly. The old open `:54321` gateway with the demo JWT is
   decommissioned.
4. **HIGH — the dashboard-server has no authentication by default.** Unchanged, and now it also
   fronts the database: the `DASHBOARD_TOKEN` gate protects the mutating endpoints (including every
   Creator API write) when set — **set it (and bind the server to a trusted network) before any real
   deployment.** Reads and the passenger player remain open by design.
5. ~~**MED — CORS `*`** on the mutating edge functions.~~ **CLOSED.** The edge functions are deleted
   and the Creator is served from the **same origin** as the API it calls — no cross-origin surface
   remains.

**Recommended priority:** set `DASHBOARD_TOKEN` in every non-dev deployment (4) → real sign-in for
per-tenant authorization (1)/(2).

### Deployed posture (post-migration)
- **Portal writes** gated by the `DASHBOARD_TOKEN` bearer (constant-time compare).
- **Database private**: Azure Database for PostgreSQL reachable only from the VM
  (allow-list / VNet), `sslmode=require`; browsers and vehicles never connect to it — vehicles use
  the portal's `GET /api/engine-config` (`CONFIG_API_URL` + optional `CONFIG_API_TOKEN`).
- **Secrets** live in the VM's `$HOME/.env` (gitignored pattern; never in the repo, never in a
  bundle).

### To fully close (1)–(2): add real authentication to the Creator
The remaining exposure all stems from the Creator having no user login. The path is **Entra ID
sign-in**, and the seams are already in place:
1. Sign the operator in with Entra ID and attach the session token to the Creator's API calls — the
   `SessionService` bearer plumbing already forwards a token when a host shell supplies one.
2. Derive `tenant` **server-side from the verified token** in the portal API (drop the body/query
   tenant for authenticated callers) — the tenant columns are already on every table.
3. Record a server-verified `published_by` on publish — `config_versions.published_by_verified`
   already exists and honestly defaults to `false` today.
