openvibers/openvibe.live SECURITY_AUDIT.md

SECURITY_AUDIT.md

main 15.4 KB · 119 lines · markdown

1# OpenVibe.Live Security Audit
2
3Current as of **2026-09-17**. The original March 2026 audit is kept at the end as history.
4
5Severity uses a realistic scale: **critical** (anyone can take over accounts, streams or money
6today), **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
23Each item below was verified by reading the code (the critical one also against production,
24read-only). The Test column names the automated test that covers the fix (test/public-serializers and
25test/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
71Earlier in September (see git history): WHIP slot ownership, stored/reflected XSS in chat, paste
72pages and OAuth pages, stream-key leak on `GET /api/streams` and the media channel endpoint, free
73Vibes via unverified purchase, chat logs for non-staff, OAuth slot binding for restreams, ingest URL
74validation, 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
1021. Rotate every `managed_streams.stream_key` after the serializer fix is deployed.
1032. OpenVibe.Media anonymous mode (§4).
1043. Delegated handlers for inline `onclick` on the lazy routes first (broadcast, dashboard), then CSP enforcement.
1054. httpOnly session + WebSocket tickets with OpenVibe.Network.
1065. Load-test chat and index clients by room.
107
108---
109
110## History: March 2026 audit (2026-03-09)
111
112That pass hardened the entrypoint (trust proxy, explicit CORS and WebSocket origin checks, auth and
113upload rate limits), validated registration/profile input, gated private VOD/clip files, capped
114WebSocket payloads, restricted HTTP-FLV origins, normalized monetization amounts, and rendered toasts
115as text. Its "remaining risks" — heavy `innerHTML`, inline handlers, a broad alpha feature surface,
116tokens in `localStorage`, and a stricter CSP — are tracked above in §3 and §4. The files it referred
117to 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