openvibers/openvibe.live SECURITY_AUDIT.md
SECURITY_AUDIT.md
| 1 | # OpenVibe.Live Security Audit |
| 2 | |
| 3 | Current as of **2026-09-17**. The original March 2026 audit is kept at the end as history. |
| 4 | |
| 5 | Severity uses a realistic scale: **critical** (anyone can take over accounts, streams or money |
| 6 | today), **high** (a signed-in user can act on someone else's objects or run code), **medium** |
| 7 | (disclosure or abuse that needs specific conditions), **low** (hardening). |
| 8 | |
| 9 | ## 1. Threat boundaries |
| 10 | |
| 11 | | Boundary | Who is on each side | What protects it | |
| 12 | |---|---|---| |
| 13 | | Browser → Live HTTP API | anonymous visitors, signed-in users, API tokens (`hbt_`) | `requireAuth`/`optionalAuth` (server/auth/auth.js), per-object ownership checks in routes, token scopes, rate limits (Express + nginx) | |
| 14 | | Browser → Live WebSockets | same | origin check + IP ban in the upgrade handler (server/index.js), per-server join/auth logic | |
| 15 | | Ingest (RTMP, WHIP) → Live | encoders holding a slot key | slot key lookup, WHIP slot-ownership check, key never returned by public APIs | |
| 16 | | Live → OpenVibe.Media | Live's app key, optionally "acting as" a Live user (`X-OV-User-Id`) | Media's tenant auth; **an app-key call without an acting user has full app authority** (see §4) | |
| 17 | | Live → OpenVibe.Network | JWT verification (RS256 public key), wallet client | signature checks, deadlines | |
| 18 | | Live → user-chosen URLs | song requests (yt-dlp/ffmpeg), kiosk link preview, chat relays | server/net/egress.js: connect-time public-address policy, redirect re-checks, loopback egress proxy for child processes | |
| 19 | | Staff → site | admin, global mod, owner (`is_owner`) | permissions.js role ranks; owner-only for money, secrets, admin grants | |
| 20 | |
| 21 | ## 2. Fixed in the 2026-09-16/17 pass |
| 22 | |
| 23 | Each item below was verified by reading the code (the critical one also against production, |
| 24 | read-only). The Test column names the automated test that covers the fix (test/public-serializers and |
| 25 | test/authorization were also run against the previous code and failed there, case by case); |
| 26 | "manual" means the fix was reviewed and exercised by hand but has no automated test. |
| 27 | |
| 28 | | Severity | Finding | Fix | Test | |
| 29 | |---|---|---|---| |
| 30 | | **Critical** | `GET /api/streams/channel/:username` (public) returned every slot's `stream_key` — confirmed on production. Anyone could publish to any streamer's slot. | Explicit public serializers (server/web/serializers.js) on channel, list, recent and detail responses; home ZIP and desk settings removed too. **All slot keys should be rotated after deploying.** | test/public-serializers.test.js | |
| 31 | | High | `POST /api/mod/delete-message` trusted a client `stream_id`: owning any stream allowed deleting any message site-wide. | Checks the message's own stream/channel. | test/authorization.test.js | |
| 32 | | High | Control-profile button edit/delete authorised the profile in the URL, not the button. | SQL scoped by `config_id`; 404 otherwise. | authorization | |
| 33 | | High | Media-request Vibes refund credited the requester even when taking the money back from the streamer failed (repeatable Vibes minting). | Deduction and credit in one transaction; no credit if the deduction fails. | authorization | |
| 34 | | High | A non-owner admin could demote/ban the owner or grant admin. | Owner/admin boundary on role changes and bans. | authorization | |
| 35 | | High | yt-dlp "extra args" (any admin) allowed `--exec`: command execution on the server. | Owner-only, allow-listed flags, disallowed saved values ignored at run time. | authorization | |
| 36 | | High | Pastes proxy sent anonymous requests to Media with app authority: forge pastes/comments as any user, delete any comment, list/read private pastes. | Identity fields stripped from bodies, owner-view switches stripped for anonymous callers, private pastes filtered, comment delete requires sign-in, anonymous write rate limit. | manual (needs Media) | |
| 37 | | High | API token scopes were stored but never enforced (a "read" token could cash out or use admin routes). | Scope enforcement in `requireAuth`/`optionalAuth`; money, staff and credential routes refuse tokens. | authorization | |
| 38 | | High | SSRF: IPv6 spellings of internal IPv4 (`[::ffff:7f00:1]`, NAT64, 6to4) passed the check; DNS rebinding and redirects were not re-checked; yt-dlp/ffmpeg resolved names themselves. | server/net/egress.js: address policy, connect-time lookup, per-hop redirect checks, loopback egress proxy for yt-dlp and ffmpeg (literal-IP bypass found by test and fixed); canonical URLs re-checked. | test/egress.test.js | |
| 39 | | High | Anyone could exhaust the WebRTC SFU: viewer sockets for any stream id created routers/transports. | Viewer sockets only for live streams, 16 per address, viewers never create routers. | manual | |
| 40 | | High | CCBill webhook credited whatever order id the buyer's form URL named (the price digest does not cover `X-order`): pay for a small order, credit a large one. PayPal return read the order before awaiting capture, so a webhook landing in between was credited twice. No webhook checked that the order belonged to that provider. | Provider and paid amount checked on every webhook (CCBill must report a price); orders re-read immediately before crediting; replayed subscription webhooks no longer extend the period. | authorization | |
| 41 | | Medium-High | Chat joined a room by client-supplied `channelUserId`: banned users could post into a channel room and skip its rules; `/ai` controlled other channels' bots. | Room derived from the stream; offline channel chat now applies the channel's latest stream's bans and settings. | security-redaction (ordering) | |
| 42 | | Medium | Relay-user hide/unhide: any user could hide site-wide or delete any channel's hide. | Staff for site-wide; channel moderators for their channel. | authorization | |
| 43 | | Medium | IP-approval queue authorised a channel id as a stream id (IP + geo disclosure). | `canModerateChannel`; review scoped to channel. | authorization | |
| 44 | | Medium | DM: a participant could add a third person to a private 1:1 (who then read the history). | Only group conversations accept new members; blocks checked against every member. | authorization | |
| 45 | | Medium | Hard-coded shared secret on `POST /api/cosmetics/internal-unlock` (in git history). | Configured internal key (timing-safe), or the legacy header from loopback only. | manual | |
| 46 | | Medium | `/banned/continue` trusted a plain display-name cookie (lift any account's ban) and wiped streamer-issued stream bans. | Signed id cookie; site-level bans only. | manual | |
| 47 | | Medium | Control WebSocket forwarded arbitrary command strings and any camera id. | Commands resolved from the stream's enabled buttons; cameras bound to the stream/owner. | manual | |
| 48 | | Medium | Clip upload filed arbitrary video under any stream's channel. | Only for live streams with clipping enabled. | manual | |
| 49 | | Medium | Kiosk link preview followed redirects to internal services and returned their `<title>`. | egress.fetchText. | egress | |
| 50 | | Medium | Chat-relay YouTube host check used `includes` (`youtube.com.evil`), unbounded fetch. | Exact host/subdomain match; guarded, size-capped fetch. | manual | |
| 51 | | Medium | Public VOD/clip lists passed `include_private` to Media under the app key. | Stripped on public lists. | manual | |
| 52 | | Medium | Stream keys in logs (RTMP publish/reject/end, restream command lines incl. SRT passphrases, RS worker ffmpeg line, node-media-server's own connect/publish/play lines). | server/utils/redact.js; the library logs errors only. | manual | |
| 53 | | Medium | OAuth callback skipped the state check when the cookie was missing (login CSRF). | State cookie required. | manual | |
| 54 | | Medium | Media request state (public + broadcast to chat) exposed server file paths and raw yt-dlp errors; `stream-url` returned raw errors to anyone. | Public projection; raw errors only for channel managers. | manual | |
| 55 | | Medium | Real-money Vibes balance and last-seen time on public profiles. | Only for the user themself. | manual | |
| 56 | | Low-Medium | Moderators could change owner policy (grant themselves About editing, disable IP approval). | Owner-only keys ignored for moderators. | manual | |
| 57 | | Low-Medium | Self-donation laundered bought Vibes into cash-out-able Vibes; donation stream id not bound to streamer. | Refused. | manual | |
| 58 | | Low-Medium | Channel-points redemption announced in any stream's chat. | Reward must belong to the stream's channel. | manual | |
| 59 | | Low-Medium | Anyone could set the saved playback position of any media request (unauthenticated). | Channel managers only. | authorization | |
| 60 | | Low-Medium | AI viewer "clone" copied any user's chat history from every channel (and their AI insight) into a bot. | Streamers clone only from what that person said in their own channel; staff unchanged. | authorization | |
| 61 | | Low-Medium | No limit on chat WebSocket connections per address. | 48 per address (admins exempt); clients back off to 30 s on a 4029 close. | test/chat-connection-cap.test.js | |
| 62 | | Medium | Voice: 1:1 call channels were listed publicly and anyone (anonymous included) could walk into them; `call-user` had no rate limit or block check; anyone could answer any invite; a kicked user could rejoin at once; one host could fill the 8-seat Public channel; TURN was emitted without credentials on a box with no secret; static TURN credentials went to every visitor. | Private calls hidden from strangers and invite-only; call-user 6/min + block check; respond requires the invite; 60 s kick cooldown; 3 sockets per address, duplicate accounts replaced; TURN only with credentials; production coturn now runs `use-auth-secret` and every ICE list (voice and SFU, `server/net/turn.js`) carries a credential that expires after an hour — the old static pair no longer authenticates. | test/turn-credentials.test.js + relay probe on the host | |
| 63 | | Medium | Restreams outlived their source: the RobotStreamer passthrough kept its peer open (and restarted forever, without checking the stream was still live) after the broadcaster's socket timed out, OBS stopped, or an admin force-ended the stream — the robot stayed "live" on robotstreamer.com showing black. Force-end only flipped `is_live`. The dashboard's RS Stop button never reached the server. | Every stream-end path (grace timer, RTMP unpublish, WHIP end, stale sweep, admin force-end, own DELETE) stops the RS relay, ffmpeg restreams, chat relays and AI bots; the relay also ends itself on `producer-removed` / the stream row going not-live, backs off and gives up after 12 restarts; `POST /api/robotstreamer/restream/{start,stop}` (owner-only, live stream only); SRT passphrases are stored server-side and never returned (`has_srt_passphrase`), and redacted from ffmpeg command lines in logs. | test/rs-passthrough-lifecycle.test.js, test/restream-manager-lifecycle.test.js | |
| 64 | | Low | Slot `control_config_id` accepted other users' profiles; restream viewer counts writable for any destination. | Ownership checks. | manual | |
| 65 | | Low | ONVIF discovery (LAN scan with default-password probing) available to every user. | Removed with Live's robot controls (2026-10-14, plan T15); robots and cameras are OpenVibe.Bot's. | manual | |
| 66 | | Low | RobotStreamer API calls carrying the user's RS token disabled TLS verification. | Verification on for api.robotstreamer.com (valid certificate). | manual | |
| 67 | | Low | PowerChat test tip broadcast a fake donation to global chat. | Channel room only. | manual | |
| 68 | | Stability | Timer callbacks (RTMP/WHIP heartbeats, renewal sweep) could throw → `uncaughtException` → process exit → every live stream dropped. | Contained. | manual | |
| 69 | | Data integrity | The Vibes ×100 migration re-ran if its settings row was deleted (any admin can delete settings). | Versioned migration ledger (server/db/migrations.js); production shape adopted. | test/migrations.test.js | |
| 70 | |
| 71 | Earlier in September (see git history): WHIP slot ownership, stored/reflected XSS in chat, paste |
| 72 | pages and OAuth pages, stream-key leak on `GET /api/streams` and the media channel endpoint, free |
| 73 | Vibes via unverified purchase, chat logs for non-staff, OAuth slot binding for restreams, ingest URL |
| 74 | validation, private VOD cache leak, HTTP-FLV bound to loopback, admin bans never cascading onto staff. |
| 75 | |
| 76 | ## 3. Content-Security-Policy status |
| 77 | |
| 78 | - **Enforced** (helmet, server/index.js): `default-src 'self'` with explicit hosts; scripts still allow |
| 79 | `'unsafe-inline'` because inline event handlers remain. |
| 80 | - **Report-only** on every HTML document (server/web/assets.js `cspReportOnly`): inline `<script>` |
| 81 | elements allowed only by SHA-256 hash, `object-src 'none'`, `base-uri 'self'`, restricted |
| 82 | `form-action` and `frame-ancestors`. Reports go to `POST /api/csp-report` (rate limited, aggregated, |
| 83 | logged at most once a minute per directive+origin). Verified in Chrome: no report-only violations on |
| 84 | `/`, `/broadcast`, `/@user`, `/documentation`, `/popout/global`. |
| 85 | - Next: move inline `onclick=` handlers to delegated handlers per route, then drop |
| 86 | `script-src-attr 'unsafe-inline'` and enforce. |
| 87 | |
| 88 | ## 4. Remaining risks (not fixed) |
| 89 | |
| 90 | | Severity | Risk | Why it is still open / what closes it | |
| 91 | |---|---|---| |
| 92 | | Medium | **Media trusts app-key calls with no acting user.** Live now scrubs anonymous requests, but any future Live route that forgets is app authority again. | Companion change in OpenVibe.Media: an explicit anonymous mode (e.g. `X-OV-Anonymous: 1`) that never trusts `user_id`. | |
| 93 | | Medium | Session token is readable by page scripts (`token`/`ov_token` cookies are not httpOnly; `localStorage.token`), and WebSocket URLs carry it. Any XSS steals the session. | Needs an OpenVibe.Network contract change (httpOnly session + short-lived socket tickets). Mitigation in the repo: deploy/nginx logs requests without query strings (apply to the live nginx), CSP report-only in place. | |
| 94 | | Medium | Inline event handlers keep `'unsafe-inline'` in the enforced CSP. | Progressive migration (see §3). | |
| 95 | | Medium | Per-address limits (broadcast viewers, rate limits) trust `CF-Connecting-IP`; if the origin is reachable without Cloudflare that header is spoofable. | Firewall the origin to Cloudflare ranges (deploy/cloudflare/checklist.md). | |
| 96 | | Low | Chat connect/close/join still scan every client to update counts. | Profile under load before indexing clients by room. | |
| 97 | | Low | RobotStreamer SFU WebSocket connections still skip certificate verification (hosts are assigned by RS at runtime). | Confirm RS SFU certificates and enable verification. | |
| 98 | | Low | Analytics roll-ups (the hourly `aggregate()` in openvibe-shared/analytics/tracker.js, v1.4.0) run synchronously on the main thread. The raw-event prune is no longer a single DELETE: 30 days, in bounded batches (job `analytics-prune`, `retention.pruneRawEvents`). | Change in OpenVibe.Shared, release, bump the pin. | |
| 99 | |
| 100 | ## 5. Next hardening steps |
| 101 | |
| 102 | 1. Rotate every `managed_streams.stream_key` after the serializer fix is deployed. |
| 103 | 2. OpenVibe.Media anonymous mode (§4). |
| 104 | 3. Delegated handlers for inline `onclick` on the lazy routes first (broadcast, dashboard), then CSP enforcement. |
| 105 | 4. httpOnly session + WebSocket tickets with OpenVibe.Network. |
| 106 | 5. Load-test chat and index clients by room. |
| 107 | |
| 108 | --- |
| 109 | |
| 110 | ## History: March 2026 audit (2026-03-09) |
| 111 | |
| 112 | That pass hardened the entrypoint (trust proxy, explicit CORS and WebSocket origin checks, auth and |
| 113 | upload rate limits), validated registration/profile input, gated private VOD/clip files, capped |
| 114 | WebSocket payloads, restricted HTTP-FLV origins, normalized monetization amounts, and rendered toasts |
| 115 | as text. Its "remaining risks" — heavy `innerHTML`, inline handlers, a broad alpha feature surface, |
| 116 | tokens in `localStorage`, and a stricter CSP — are tracked above in §3 and §4. The files it referred |
| 117 | to under `server/vod/`, `server/game/game-server.js`, `public/js/admin.js` and |
| 118 | `deploy/systemd/openvibelive.service` have since moved or been removed. |
| 119 |