openvibers/openvibe.live Commits
Offline screen: sanitize custom HTML/CSS (no script/frames/forms), proxy external images, fix Most Watched parity over a custom image
Changes
@@@ -26,6 +26,7 @@"node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-shared": "file:./vendor/openvibe-shared",++ "sanitize-html": "^2.17.7","sdp-transform": "^3.0.0","sharp": "^0.33.0","uuid": "^10.0.0",@@@ -1279,6 +1280,12 @@"node": "*"}},++ "node_modules/dayjs": {++ "version": "1.11.23",++ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",++ "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",++ "license": "MIT"++ },"node_modules/debug": {"version": "2.6.9","resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",@@@ -1312,6 +1319,15 @@"node": ">=4.0.0"}},++ "node_modules/deepmerge": {++ "version": "4.3.1",++ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",++ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/define-data-property": {"version": "1.1.4","resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",@@@ -1386,6 +1402,39 @@"node": ">=6"}},++ "node_modules/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },"node_modules/domexception": {"version": "4.0.0","resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",@@@ -1400,6 +1449,40 @@"node": ">=12"}},++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -1459,6 +1542,18 @@"once": "^1.4.0"}},++ "node_modules/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/es-define-property": {"version": "1.0.1","resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",@@@ -1910,6 +2005,28 @@"node": ">=16.0.0"}},++ "node_modules/htmlparser2": {++ "version": "12.0.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",++ "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "domutils": "^4.0.2",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ }++ },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -2092,6 +2209,15 @@"safe-buffer": "^5.0.1"}},++ "node_modules/launder": {++ "version": "1.7.1",++ "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",++ "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",++ "license": "MIT",++ "dependencies": {++ "dayjs": "^1.11.7"++ }++ },"node_modules/lazy": {"version": "1.0.11","resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz",@@@ -2465,6 +2591,24 @@"multicast-dns": "cli.js"}},++ "node_modules/nanoid": {++ "version": "3.3.19",++ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",++ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/ai"++ }++ ],++ "license": "MIT",++ "bin": {++ "nanoid": "bin/nanoid.cjs"++ },++ "engines": {++ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"++ }++ },"node_modules/napi-build-utils": {"version": "2.0.0","resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",@@@ -2646,6 +2790,12 @@"resolved": "vendor/openvibe-shared","link": true},++ "node_modules/parse-srcset": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",++ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",++ "license": "MIT"++ },"node_modules/parseurl": {"version": "1.3.3","resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",@@@ -2667,6 +2817,40 @@"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==","license": "MIT"},++ "node_modules/picocolors": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",++ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",++ "license": "ISC"++ },++ "node_modules/postcss": {++ "version": "8.5.28",++ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",++ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",++ "funding": [++ {++ "type": "opencollective",++ "url": "https://opencollective.com/postcss/"++ },++ {++ "type": "tidelift",++ "url": "https://tidelift.com/funding/github/npm/postcss"++ },++ {++ "type": "github",++ "url": "https://github.com/sponsors/ai"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "nanoid": "^3.3.18",++ "picocolors": "^1.1.1",++ "source-map-js": "^1.2.1"++ },++ "engines": {++ "node": "^10 || ^12 || >=14"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -2849,6 +3033,33 @@"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==","license": "MIT"},++ "node_modules/sanitize-html": {++ "version": "2.17.7",++ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",++ "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",++ "license": "MIT",++ "dependencies": {++ "deepmerge": "^4.2.2",++ "escape-string-regexp": "^4.0.0",++ "htmlparser2": "^12.0.0",++ "is-plain-object": "^5.0.0",++ "launder": "^1.7.1",++ "parse-srcset": "^1.0.2",++ "postcss": "^8.3.11"++ },++ "engines": {++ "node": ">=22.12.0"++ }++ },++ "node_modules/sanitize-html/node_modules/is-plain-object": {++ "version": "5.1.0",++ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",++ "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/sdp-transform": {"version": "3.0.0","resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",@@@ -3101,6 +3312,15 @@"is-arrayish": "^0.3.1"}},++ "node_modules/source-map-js": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",++ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",++ "license": "BSD-3-Clause",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1,7 +1,7 @@{"name": "live","version": "1.0.0",-- "description": "OpenVibe.Live \u2014 free & open live streaming. Part of the OpenVibe network (media by OpenVibe.Media, identity by OpenVibe.Network).",++ "description": "OpenVibe.Live — free & open live streaming. Part of the OpenVibe network (media by OpenVibe.Media, identity by OpenVibe.Network).","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -44,6 +44,7 @@"node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0","openvibe-shared": "file:./vendor/openvibe-shared",++ "sanitize-html": "^2.17.7","sdp-transform": "^3.0.0","sharp": "^0.33.0","uuid": "^10.0.0",@@@ -2692,11 +2692,16 @@ function _renderOfflineScreen(ch) {} else if (type === 'video' && url) {host.innerHTML = `<video class="ch-offline-media" src="${esc(url)}" autoplay muted loop playsinline></video>`;} else if (type === 'html' && (ch.offline_html || ch.offline_css)) {++ // The server sanitizes offline_html/offline_css to basic markup with no script, frames,++ // forms or event handlers (server/streaming/offline-html-sanitize.js), on both save and++ // every read — so this iframe needs no script/form/popup privileges at all. `allow-popups`++ // is the one grant kept, so a plain `<a target="_blank">` still opens; it carries no++ // sandbox-escape (no allow-popups-to-escape-sandbox), no allow-scripts, no allow-forms,++ // and (as before) no allow-same-origin.const doc = `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;color:#eee;font-family:system-ui,sans-serif;overflow:auto}a{color:#e0a44a}${ch.offline_css || ''}</style></head><body>${ch.offline_html || ''}</body></html>`;const iframe = document.createElement('iframe');iframe.className = 'ch-offline-html';-- // Sandbox WITHOUT allow-same-origin → the custom page is fully isolated.-- iframe.setAttribute('sandbox', 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms');++ iframe.setAttribute('sandbox', 'allow-popups');iframe.setAttribute('referrerpolicy', 'no-referrer');iframe.srcdoc = doc;host.innerHTML = '';@@@ -2775,10 +2780,16 @@ async function _fillOfflineExplore(username) {}// The discover board (live now, hot clips, fresh reports, star of the day, streamers like// this one) fills the rest of the offline screen — with or without this channel's own content.++ // Over a custom offline image, #ch-offline-screen is clipped to the image's own aspect ratio++ // (overflow:hidden), so the board has to anchor after that whole box, not after the explore++ // host nested inside its overlay, or it renders clipped away and invisible.++ const overlay = host.closest('.ch-offline-overlay');const mountDiscover = () => {-- if (host.closest('.ch-offline-overlay')) return; // over a custom image: keep it light++ const anchor = overlay ? document.getElementById('ch-offline-screen') : host;++ if (!anchor) return;let d = document.getElementById('ch-discover');-- if (!d) { d = document.createElement('div'); d.id = 'ch-discover'; host.insertAdjacentElement('afterend', d); }++ if (!d) { d = document.createElement('div'); d.id = 'ch-discover'; }++ anchor.insertAdjacentElement('afterend', d);if (typeof renderOfflineDiscover === 'function') renderOfflineDiscover(username, d);};if (!built.length) {@@@ -690,6 +690,9 @@ app.use('/api/kiosk', require('./kiosk/routes'));// Song-request queue (watch-party) — stays Live-local (OpenVibe.Media does not// carry the downloader/queue subsystem). Spends OpenCoins via the Network wallet.app.use('/api/media', require('./media/routes'));++// Privacy proxy for third-party images (offline-screen HTML, panels): fetched server-side so a++// viewer's IP/UA never reaches a host a streamer chose. SSRF-guarded, image-only, cache-forever.++app.use('/api/img-proxy', require('./media/external-image-proxy'));app.use('/api/vibe-coding', vibeCodingRoutes);const ttsRoutes = require('./chat/tts-routes');app.use('/api/tts', ttsRoutes);nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++/**++ * Privacy/security proxy for images that live outside OpenVibe (offline-screen HTML, panels,++ * anywhere a streamer can paste a third-party URL). Without this, a viewer's browser fetches++ * the image directly — leaking their IP, User-Agent and Referer to a host the streamer chose,++ * a classic tracking-pixel vector. We fetch it here instead, so the third party only ever sees++ * OpenVibe's server, and we cache the result so repeat views don't re-fetch it at all.++ *++ * SSRF-guarded via server/net/egress.js (connect-time address policy on every redirect hop, the++ * same mechanism the kiosk link-preview and media-downloader routes use for user-chosen URLs).++ */++const express = require('express');++const egress = require('../net/egress');++const router = express.Router();++++const FETCH_TIMEOUT_MS = 5000;++const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // 8MB++// SVG is deliberately excluded: a browser that navigates directly to a proxied URL (new tab,++// "open image") executes any <script> inside an SVG document. Every other image type is inert.++const ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', 'image/x-icon', 'image/vnd.microsoft.icon']);++++function normalizeUrl(raw) {++ let u;++ try { u = new URL(String(raw || '')); } catch { return null; }++ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;++ if (u.username || u.password) return null;++ return u;++}++++// ── GET /api/img-proxy?url=<external image URL> ─────────────────────++router.get('/', async (req, res) => {++ const u = normalizeUrl(req.query.url);++ if (!u) return res.status(400).json({ error: 'Invalid image URL' });++++ let r;++ try {++ r = await egress.fetchBuffer(u.href, { timeoutMs: FETCH_TIMEOUT_MS, maxBytes: MAX_IMAGE_BYTES, maxRedirects: 3 });++ } catch (e) {++ const denied = e && e.code === 'EGRESS_DENIED';++ return res.status(denied ? 403 : 502).json({ error: denied ? 'That address cannot be fetched' : 'Fetch failed' });++ }++ if (r.status < 200 || r.status >= 300) return res.status(502).json({ error: `Upstream returned ${r.status}` });++++ const type = String(r.headers['content-type'] || '').split(';')[0].trim().toLowerCase();++ if (!ALLOWED_TYPES.has(type)) return res.status(415).json({ error: 'Not a supported image type' });++ if (!r.body.length) return res.status(502).json({ error: 'Empty response' });++++ // Cacheable and immutable from the client/CDN's point of view — the URL is the cache key, and++ // a streamer who wants a fresh image at the same URL should change the URL (as with any CDN++ // asset). Cloudflare sits in front of this site, so this also gets us edge caching for free:++ // repeat viewers never reach this route, and the third-party host is fetched at most once++ // per Cloudflare PoP per day.++ res.setHeader('Content-Type', type);++ res.setHeader('Cache-Control', 'public, max-age=86400, immutable');++ res.setHeader('Content-Disposition', 'inline');++ // No Referer/identifying headers are forwarded to the client here — nothing about the++ // original fetch (redirect chain, upstream headers) leaks past content-type.++ res.end(r.body);++});++++module.exports = router;@@@ -112,10 +112,12 @@ async function assertPublicUrl(url) {}/**-- * GET a URL with the policy enforced at connect time on every redirect hop.-- * Resolves { status, url, headers, text } with text capped at maxBytes.++ * GET a URL with the policy enforced at connect time on every redirect hop, collecting the body++ * as a Buffer capped at maxBytes. Shared core for fetchText (decodes utf8) and fetchBuffer (raw++ * bytes — images and other binary payloads must never go through a text decode/re-encode).++ * Resolves { status, url, headers, body }.*/--async function fetchText(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRedirects = 4, headers = {} } = {}) {++async function fetchRaw(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRedirects = 4, headers = {} } = {}) {let current = await assertPublicUrl(url);const deadline = Date.now() + timeoutMs;for (let hop = 0; hop <= maxRedirects; hop++) {@@@ -129,7 +131,7 @@ async function fetchText(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRediif (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location) { r.resume(); return resolve({ redirect: r.headers.location, status: r.statusCode }); }const chunks = []; let size = 0;r.on('data', (c) => { size += c.length; if (size > maxBytes) { chunks.push(c.subarray(0, c.length - (size - maxBytes))); r.destroy(); } else chunks.push(c); });-- const done = () => resolve({ status: r.statusCode, headers: r.headers, text: Buffer.concat(chunks).toString('utf8') });++ const done = () => resolve({ status: r.statusCode, headers: r.headers, body: Buffer.concat(chunks) });r.on('end', done); r.on('close', done); r.on('error', reject);});req.on('timeout', () => req.destroy(new Error('timeout')));@@@ -142,6 +144,17 @@ async function fetchText(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRedithrow new EgressDenied('Too many redirects');}++/** GET a URL as decoded utf8 text. Resolves { status, url, headers, text } with text capped at maxBytes. */++async function fetchText(url, opts = {}) {++ const r = await fetchRaw(url, opts);++ return { status: r.status, url: r.url, headers: r.headers, text: r.body.toString('utf8') };++}++++/** GET a URL as a raw Buffer (images, fonts, anything binary). Resolves { status, url, headers, body }. */++async function fetchBuffer(url, opts = {}) {++ return fetchRaw(url, opts);++}++/*** Node's http and net skip `lookup` entirely when the host is already an IP literal, so a literal* has to be checked by hand before connecting. (Found by test: ffmpeg fetched http://127.0.0.1/@@@ -202,4 +215,4 @@ function proxy() {return _proxy;}--module.exports = { isPublicAddress, embeddedV4, safeLookup, assertPublicUrl, fetchText, proxy, EgressDenied };++module.exports = { isPublicAddress, embeddedV4, safeLookup, assertPublicUrl, fetchText, fetchBuffer, proxy, EgressDenied };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * Sanitizer for a streamer's custom offline-screen HTML/CSS (channels.offline_html/offline_css).++ *++ * The requirement is "basic markup, no JS": no <script>, no event-handler attributes, no++ * embeds/frames/forms that could run script or phish a viewer, and no javascript:/data: URLs.++ * This is applied both on write (server/streaming/routes.js, the PATCH that saves it) and on++ * read (the public GET /channel/:username, so any row saved before this sanitizer existed is++ * cleaned before it ever reaches a viewer — no backfill migration needed).++ *++ * Third-party <img src> values are rewritten through /api/img-proxy so a viewer's browser never++ * makes a direct request to a host the streamer chose (IP/UA leak — see external-image-proxy.js).++ * First-party OpenVibe hosts are left alone.++ */++const sanitizeHtml = require('sanitize-html');++++const FIRST_PARTY_HOSTS = /(^|\.)openvibe\.(live|network|media|tools|games|community|sites)$|(^|\.)openre\.stream$/i;++++function proxiedImageSrc(raw) {++ if (!raw) return null;++ let abs;++ try { abs = new URL(raw, 'https://openvibe.live'); } catch { return null; } // unparsable → drop++ // transformTags runs on the raw attribute value before sanitize-html's own scheme allowlist++ // is applied, so a non-http(s) src (data:, blob:, javascript: with a leading space/tab that++ // evades the scheme check, etc.) must be rejected here too — never hand it to the proxy URL.++ if (abs.protocol !== 'http:' && abs.protocol !== 'https:') return null;++ if (FIRST_PARTY_HOSTS.test(abs.hostname)) return abs.href;++ return `/api/img-proxy?url=${encodeURIComponent(abs.href)}`;++}++++const HTML_OPTIONS = {++ allowedTags: [++ 'p', 'br', 'hr', 'div', 'span', 'b', 'i', 'strong', 'em', 'u', 's', 'small', 'sub', 'sup',++ 'blockquote', 'code', 'pre', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',++ 'a', 'img', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'figure', 'figcaption',++ ],++ allowedAttributes: {++ '*': ['class', 'id', 'title', 'style'],++ a: ['href', 'target', 'rel'],++ img: ['src', 'alt', 'width', 'height', 'loading', 'referrerpolicy'],++ td: ['colspan', 'rowspan'],++ th: ['colspan', 'rowspan'],++ },++ allowedSchemes: ['http', 'https', 'mailto'],++ allowedSchemesByTag: { img: ['http', 'https'] },++ // Never let the streamer point a link/image at something that resolves in-process++ // (data:/javascript:/vbscript: URLs, credentials in the URL) — belt-and-suspenders on top++ // of the scheme allowlist above.++ allowProtocolRelative: false,++ disallowedTagsMode: 'discard',++ nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript', 'iframe', 'object', 'embed', 'svg'],++ // Any link opens in a new, non-opener tab — a streamer's page never navigates the viewer's++ // existing tab away from OpenVibe.++ transformTags: {++ a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noopener noreferrer nofollow ugc' } }),++ img: (tagName, attribs) => {++ const src = proxiedImageSrc(attribs.src);++ if (!src) return { tagName: 'span', attribs: {}, text: '' }; // unparsable src → drop the element++ return { tagName, attribs: { ...attribs, src, loading: 'lazy', referrerpolicy: 'no-referrer' } };++ },++ },++ // Inline `style` is plain CSS, not script — no browser executes CSS. The one legacy vector++ // (IE `expression()` / `-moz-binding`) is dead in every current engine, so no CSS filtering++ // is needed here beyond what the tag/attribute allowlist above already does.++};++++function sanitizeOfflineHtml(html) {++ const s = String(html || '');++ if (!s.trim()) return '';++ return sanitizeHtml(s, HTML_OPTIONS);++}++++// offline_css is a standalone <style> block, never inline script-bearing markup — but strip the++// handful of constructs that historically executed code from CSS (dead in modern engines, kept++// as defense in depth) and any @import (would be a first-party-bypassing, untracked fetch).++function sanitizeOfflineCss(css) {++ const s = String(css || '');++ if (!s.trim()) return '';++ return s++ .replace(/@import[^;]*;?/gi, '')++ .replace(/expression\s*\(/gi, 'blocked(')++ .replace(/-moz-binding\s*:/gi, 'blocked:')++ .replace(/behavior\s*:/gi, 'blocked:');++}++++module.exports = { sanitizeOfflineHtml, sanitizeOfflineCss };@@@ -32,6 +32,7 @@ const i18n = require('../i18n/translate');const robotStreamerService = require('../integrations/robotstreamer-service');const chatRelayService = require('../integrations/chat-relay-service');const chatServer = require('../chat/chat-server');++const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');const multer = require('multer');const path = require('path');const fs = require('fs');@@@ -429,6 +430,10 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {// Strip private fields from public channel responseconst publicChannel = { ...channel, follower_count: followerCount, is_following: isFollowing };++ // Re-sanitize on every read: covers rows saved before offline-html-sanitize.js existed,++ // with no backfill migration needed, at the cost of re-running a cheap parse per request.++ if (publicChannel.offline_html) publicChannel.offline_html = sanitizeOfflineHtml(publicChannel.offline_html);++ if (publicChannel.offline_css) publicChannel.offline_css = sanitizeOfflineCss(publicChannel.offline_css);// Whether the weather widget is on (without leaking the zip) — used by the// client to decide if the About tab should show.publicChannel.weather_enabled = !!(channel.weather_zip && channel.weather_detail && channel.weather_detail !== 'off');@@@ -715,8 +720,10 @@ router.put('/channel', requireAuth, (req, res) => {const t = String(req.body.offline_screen_type || 'none').trim();if (['none', 'image', 'video', 'html'].includes(t)) fields.offline_screen_type = t;}-- if (hasOwn(req.body, 'offline_html')) fields.offline_html = String(req.body.offline_html || '').slice(0, 20000);-- if (hasOwn(req.body, 'offline_css')) fields.offline_css = String(req.body.offline_css || '').slice(0, 20000);++ // Sanitized on write (basic markup, no script/frames/forms — see offline-html-sanitize.js)++ // and again on every read below, so rows saved before this sanitizer existed are covered too.++ if (hasOwn(req.body, 'offline_html')) fields.offline_html = sanitizeOfflineHtml(String(req.body.offline_html || '').slice(0, 20000));++ if (hasOwn(req.body, 'offline_css')) fields.offline_css = sanitizeOfflineCss(String(req.body.offline_css || '').slice(0, 20000));if (Object.keys(fields).length > 0) {db.updateChannel(req.user.id, fields);
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-shared": "file:./vendor/openvibe-shared",
"openvibe-shared": "file:./vendor/openvibe-shared",
+ "sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
"sharp": "^0.33.0",
"uuid": "^10.0.0",
"uuid": "^10.0.0",
@@ -1279,6 +1280,12 @@
@@ -1279,6 +1280,12 @@
"node": "*"
"node": "*"
}
}
},
},
+ "node_modules/dayjs": {
+ "version": "1.11.23",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
+ "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1312,6 +1319,15 @@
@@ -1312,6 +1319,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/define-data-property": {
"node_modules/define-data-property": {
"version": "1.1.4",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -1386,6 +1402,39 @@
@@ -1386,6 +1402,39 @@
"node": ">=6"
"node": ">=6"
}
}
},
},
+ "node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/domexception": {
"node_modules/domexception": {
"version": "4.0.0",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
@@ -1400,6 +1449,40 @@
@@ -1400,6 +1449,40 @@
"node": ">=12"
"node": ">=12"
}
}
},
},
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1459,6 +1542,18 @@
@@ -1459,6 +1542,18 @@
"once": "^1.4.0"
"once": "^1.4.0"
}
}
},
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1910,6 +2005,28 @@
@@ -1910,6 +2005,28 @@
"node": ">=16.0.0"
"node": ">=16.0.0"
}
}
},
},
+ "node_modules/htmlparser2": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
+ "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "domutils": "^4.0.2",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2092,6 +2209,15 @@
@@ -2092,6 +2209,15 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/launder": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
+ "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
+ "license": "MIT",
+ "dependencies": {
+ "dayjs": "^1.11.7"
+ }
+ },
"node_modules/lazy": {
"node_modules/lazy": {
"version": "1.0.11",
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz",
"resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz",
@@ -2465,6 +2591,24 @@
@@ -2465,6 +2591,24 @@
"multicast-dns": "cli.js"
"multicast-dns": "cli.js"
}
}
},
},
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/napi-build-utils": {
"node_modules/napi-build-utils": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -2646,6 +2790,12 @@
@@ -2646,6 +2790,12 @@
"resolved": "vendor/openvibe-shared",
"resolved": "vendor/openvibe-shared",
"link": true
"link": true
},
},
+ "node_modules/parse-srcset": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
+ "license": "MIT"
+ },
"node_modules/parseurl": {
"node_modules/parseurl": {
"version": "1.3.3",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2667,6 +2817,40 @@
@@ -2667,6 +2817,40 @@
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -2849,6 +3033,33 @@
@@ -2849,6 +3033,33 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/sanitize-html": {
+ "version": "2.17.7",
+ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
+ "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
+ "license": "MIT",
+ "dependencies": {
+ "deepmerge": "^4.2.2",
+ "escape-string-regexp": "^4.0.0",
+ "htmlparser2": "^12.0.0",
+ "is-plain-object": "^5.0.0",
+ "launder": "^1.7.1",
+ "parse-srcset": "^1.0.2",
+ "postcss": "^8.3.11"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/sanitize-html/node_modules/is-plain-object": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
+ "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/sdp-transform": {
"node_modules/sdp-transform": {
"version": "3.0.0",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
@@ -3101,6 +3312,15 @@
@@ -3101,6 +3312,15 @@
"is-arrayish": "^0.3.1"
"is-arrayish": "^0.3.1"
}
}
},
},
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"name": "live",
"name": "live",
"version": "1.0.0",
"version": "1.0.0",
- "description": "OpenVibe.Live \u2014 free & open live streaming. Part of the OpenVibe network (media by OpenVibe.Media, identity by OpenVibe.Network).",
+ "description": "OpenVibe.Live — free & open live streaming. Part of the OpenVibe network (media by OpenVibe.Media, identity by OpenVibe.Network).",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -44,6 +44,7 @@
@@ -44,6 +44,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-shared": "file:./vendor/openvibe-shared",
"openvibe-shared": "file:./vendor/openvibe-shared",
+ "sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
"sharp": "^0.33.0",
"uuid": "^10.0.0",
"uuid": "^10.0.0",
@@ -2692,11 +2692,16 @@ function _renderOfflineScreen(ch) {
@@ -2692,11 +2692,16 @@ function _renderOfflineScreen(ch) {
} else if (type === 'video' && url) {
} else if (type === 'video' && url) {
host.innerHTML = `<video class="ch-offline-media" src="${esc(url)}" autoplay muted loop playsinline></video>`;
host.innerHTML = `<video class="ch-offline-media" src="${esc(url)}" autoplay muted loop playsinline></video>`;
} else if (type === 'html' && (ch.offline_html || ch.offline_css)) {
} else if (type === 'html' && (ch.offline_html || ch.offline_css)) {
+ // The server sanitizes offline_html/offline_css to basic markup with no script, frames,
+ // forms or event handlers (server/streaming/offline-html-sanitize.js), on both save and
+ // every read — so this iframe needs no script/form/popup privileges at all. `allow-popups`
+ // is the one grant kept, so a plain `<a target="_blank">` still opens; it carries no
+ // sandbox-escape (no allow-popups-to-escape-sandbox), no allow-scripts, no allow-forms,
+ // and (as before) no allow-same-origin.
const doc = `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;color:#eee;font-family:system-ui,sans-serif;overflow:auto}a{color:#e0a44a}${ch.offline_css || ''}</style></head><body>${ch.offline_html || ''}</body></html>`;
const doc = `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;color:#eee;font-family:system-ui,sans-serif;overflow:auto}a{color:#e0a44a}${ch.offline_css || ''}</style></head><body>${ch.offline_html || ''}</body></html>`;
const iframe = document.createElement('iframe');
const iframe = document.createElement('iframe');
iframe.className = 'ch-offline-html';
iframe.className = 'ch-offline-html';
- // Sandbox WITHOUT allow-same-origin → the custom page is fully isolated.
- iframe.setAttribute('sandbox', 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms');
+ iframe.setAttribute('sandbox', 'allow-popups');
iframe.setAttribute('referrerpolicy', 'no-referrer');
iframe.setAttribute('referrerpolicy', 'no-referrer');
iframe.srcdoc = doc;
iframe.srcdoc = doc;
host.innerHTML = '';
host.innerHTML = '';
@@ -2775,10 +2780,16 @@ async function _fillOfflineExplore(username) {
@@ -2775,10 +2780,16 @@ async function _fillOfflineExplore(username) {
}
}
// The discover board (live now, hot clips, fresh reports, star of the day, streamers like
// The discover board (live now, hot clips, fresh reports, star of the day, streamers like
// this one) fills the rest of the offline screen — with or without this channel's own content.
// this one) fills the rest of the offline screen — with or without this channel's own content.
+ // Over a custom offline image, #ch-offline-screen is clipped to the image's own aspect ratio
+ // (overflow:hidden), so the board has to anchor after that whole box, not after the explore
+ // host nested inside its overlay, or it renders clipped away and invisible.
+ const overlay = host.closest('.ch-offline-overlay');
const mountDiscover = () => {
const mountDiscover = () => {
- if (host.closest('.ch-offline-overlay')) return; // over a custom image: keep it light
+ const anchor = overlay ? document.getElementById('ch-offline-screen') : host;
+ if (!anchor) return;
let d = document.getElementById('ch-discover');
let d = document.getElementById('ch-discover');
- if (!d) { d = document.createElement('div'); d.id = 'ch-discover'; host.insertAdjacentElement('afterend', d); }
+ if (!d) { d = document.createElement('div'); d.id = 'ch-discover'; }
+ anchor.insertAdjacentElement('afterend', d);
if (typeof renderOfflineDiscover === 'function') renderOfflineDiscover(username, d);
if (typeof renderOfflineDiscover === 'function') renderOfflineDiscover(username, d);
};
};
if (!built.length) {
if (!built.length) {
@@ -690,6 +690,9 @@ app.use('/api/kiosk', require('./kiosk/routes'));
@@ -690,6 +690,9 @@ app.use('/api/kiosk', require('./kiosk/routes'));
// Song-request queue (watch-party) — stays Live-local (OpenVibe.Media does not
// Song-request queue (watch-party) — stays Live-local (OpenVibe.Media does not
// carry the downloader/queue subsystem). Spends OpenCoins via the Network wallet.
// carry the downloader/queue subsystem). Spends OpenCoins via the Network wallet.
app.use('/api/media', require('./media/routes'));
app.use('/api/media', require('./media/routes'));
+// Privacy proxy for third-party images (offline-screen HTML, panels): fetched server-side so a
+// viewer's IP/UA never reaches a host a streamer chose. SSRF-guarded, image-only, cache-forever.
+app.use('/api/img-proxy', require('./media/external-image-proxy'));
app.use('/api/vibe-coding', vibeCodingRoutes);
app.use('/api/vibe-coding', vibeCodingRoutes);
const ttsRoutes = require('./chat/tts-routes');
const ttsRoutes = require('./chat/tts-routes');
app.use('/api/tts', ttsRoutes);
app.use('/api/tts', ttsRoutes);
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+/**
+ * Privacy/security proxy for images that live outside OpenVibe (offline-screen HTML, panels,
+ * anywhere a streamer can paste a third-party URL). Without this, a viewer's browser fetches
+ * the image directly — leaking their IP, User-Agent and Referer to a host the streamer chose,
+ * a classic tracking-pixel vector. We fetch it here instead, so the third party only ever sees
+ * OpenVibe's server, and we cache the result so repeat views don't re-fetch it at all.
+ *
+ * SSRF-guarded via server/net/egress.js (connect-time address policy on every redirect hop, the
+ * same mechanism the kiosk link-preview and media-downloader routes use for user-chosen URLs).
+ */
+const express = require('express');
+const egress = require('../net/egress');
+const router = express.Router();
+
+const FETCH_TIMEOUT_MS = 5000;
+const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // 8MB
+// SVG is deliberately excluded: a browser that navigates directly to a proxied URL (new tab,
+// "open image") executes any <script> inside an SVG document. Every other image type is inert.
+const ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', 'image/x-icon', 'image/vnd.microsoft.icon']);
+
+function normalizeUrl(raw) {
+ let u;
+ try { u = new URL(String(raw || '')); } catch { return null; }
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
+ if (u.username || u.password) return null;
+ return u;
+}
+
+// ── GET /api/img-proxy?url=<external image URL> ─────────────────────
+router.get('/', async (req, res) => {
+ const u = normalizeUrl(req.query.url);
+ if (!u) return res.status(400).json({ error: 'Invalid image URL' });
+
+ let r;
+ try {
+ r = await egress.fetchBuffer(u.href, { timeoutMs: FETCH_TIMEOUT_MS, maxBytes: MAX_IMAGE_BYTES, maxRedirects: 3 });
+ } catch (e) {
+ const denied = e && e.code === 'EGRESS_DENIED';
+ return res.status(denied ? 403 : 502).json({ error: denied ? 'That address cannot be fetched' : 'Fetch failed' });
+ }
+ if (r.status < 200 || r.status >= 300) return res.status(502).json({ error: `Upstream returned ${r.status}` });
+
+ const type = String(r.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
+ if (!ALLOWED_TYPES.has(type)) return res.status(415).json({ error: 'Not a supported image type' });
+ if (!r.body.length) return res.status(502).json({ error: 'Empty response' });
+
+ // Cacheable and immutable from the client/CDN's point of view — the URL is the cache key, and
+ // a streamer who wants a fresh image at the same URL should change the URL (as with any CDN
+ // asset). Cloudflare sits in front of this site, so this also gets us edge caching for free:
+ // repeat viewers never reach this route, and the third-party host is fetched at most once
+ // per Cloudflare PoP per day.
+ res.setHeader('Content-Type', type);
+ res.setHeader('Cache-Control', 'public, max-age=86400, immutable');
+ res.setHeader('Content-Disposition', 'inline');
+ // No Referer/identifying headers are forwarded to the client here — nothing about the
+ // original fetch (redirect chain, upstream headers) leaks past content-type.
+ res.end(r.body);
+});
+
+module.exports = router;
@@ -112,10 +112,12 @@ async function assertPublicUrl(url) {
@@ -112,10 +112,12 @@ async function assertPublicUrl(url) {
}
}
/**
/**
- * GET a URL with the policy enforced at connect time on every redirect hop.
- * Resolves { status, url, headers, text } with text capped at maxBytes.
+ * GET a URL with the policy enforced at connect time on every redirect hop, collecting the body
+ * as a Buffer capped at maxBytes. Shared core for fetchText (decodes utf8) and fetchBuffer (raw
+ * bytes — images and other binary payloads must never go through a text decode/re-encode).
+ * Resolves { status, url, headers, body }.
*/
*/
-async function fetchText(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRedirects = 4, headers = {} } = {}) {
+async function fetchRaw(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRedirects = 4, headers = {} } = {}) {
let current = await assertPublicUrl(url);
let current = await assertPublicUrl(url);
const deadline = Date.now() + timeoutMs;
const deadline = Date.now() + timeoutMs;
for (let hop = 0; hop <= maxRedirects; hop++) {
for (let hop = 0; hop <= maxRedirects; hop++) {
@@ -129,7 +131,7 @@ async function fetchText(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRedi
@@ -129,7 +131,7 @@ async function fetchText(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRedi
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location) { r.resume(); return resolve({ redirect: r.headers.location, status: r.statusCode }); }
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location) { r.resume(); return resolve({ redirect: r.headers.location, status: r.statusCode }); }
const chunks = []; let size = 0;
const chunks = []; let size = 0;
r.on('data', (c) => { size += c.length; if (size > maxBytes) { chunks.push(c.subarray(0, c.length - (size - maxBytes))); r.destroy(); } else chunks.push(c); });
r.on('data', (c) => { size += c.length; if (size > maxBytes) { chunks.push(c.subarray(0, c.length - (size - maxBytes))); r.destroy(); } else chunks.push(c); });
- const done = () => resolve({ status: r.statusCode, headers: r.headers, text: Buffer.concat(chunks).toString('utf8') });
+ const done = () => resolve({ status: r.statusCode, headers: r.headers, body: Buffer.concat(chunks) });
r.on('end', done); r.on('close', done); r.on('error', reject);
r.on('end', done); r.on('close', done); r.on('error', reject);
});
});
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('timeout', () => req.destroy(new Error('timeout')));
@@ -142,6 +144,17 @@ async function fetchText(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRedi
@@ -142,6 +144,17 @@ async function fetchText(url, { timeoutMs = 8000, maxBytes = 512 * 1024, maxRedi
throw new EgressDenied('Too many redirects');
throw new EgressDenied('Too many redirects');
}
}
+/** GET a URL as decoded utf8 text. Resolves { status, url, headers, text } with text capped at maxBytes. */
+async function fetchText(url, opts = {}) {
+ const r = await fetchRaw(url, opts);
+ return { status: r.status, url: r.url, headers: r.headers, text: r.body.toString('utf8') };
+}
+
+/** GET a URL as a raw Buffer (images, fonts, anything binary). Resolves { status, url, headers, body }. */
+async function fetchBuffer(url, opts = {}) {
+ return fetchRaw(url, opts);
+}
+
/**
/**
* Node's http and net skip `lookup` entirely when the host is already an IP literal, so a literal
* Node's http and net skip `lookup` entirely when the host is already an IP literal, so a literal
* has to be checked by hand before connecting. (Found by test: ffmpeg fetched http://127.0.0.1/
* has to be checked by hand before connecting. (Found by test: ffmpeg fetched http://127.0.0.1/
@@ -202,4 +215,4 @@ function proxy() {
@@ -202,4 +215,4 @@ function proxy() {
return _proxy;
return _proxy;
}
}
-module.exports = { isPublicAddress, embeddedV4, safeLookup, assertPublicUrl, fetchText, proxy, EgressDenied };
+module.exports = { isPublicAddress, embeddedV4, safeLookup, assertPublicUrl, fetchText, fetchBuffer, proxy, EgressDenied };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * Sanitizer for a streamer's custom offline-screen HTML/CSS (channels.offline_html/offline_css).
+ *
+ * The requirement is "basic markup, no JS": no <script>, no event-handler attributes, no
+ * embeds/frames/forms that could run script or phish a viewer, and no javascript:/data: URLs.
+ * This is applied both on write (server/streaming/routes.js, the PATCH that saves it) and on
+ * read (the public GET /channel/:username, so any row saved before this sanitizer existed is
+ * cleaned before it ever reaches a viewer — no backfill migration needed).
+ *
+ * Third-party <img src> values are rewritten through /api/img-proxy so a viewer's browser never
+ * makes a direct request to a host the streamer chose (IP/UA leak — see external-image-proxy.js).
+ * First-party OpenVibe hosts are left alone.
+ */
+const sanitizeHtml = require('sanitize-html');
+
+const FIRST_PARTY_HOSTS = /(^|\.)openvibe\.(live|network|media|tools|games|community|sites)$|(^|\.)openre\.stream$/i;
+
+function proxiedImageSrc(raw) {
+ if (!raw) return null;
+ let abs;
+ try { abs = new URL(raw, 'https://openvibe.live'); } catch { return null; } // unparsable → drop
+ // transformTags runs on the raw attribute value before sanitize-html's own scheme allowlist
+ // is applied, so a non-http(s) src (data:, blob:, javascript: with a leading space/tab that
+ // evades the scheme check, etc.) must be rejected here too — never hand it to the proxy URL.
+ if (abs.protocol !== 'http:' && abs.protocol !== 'https:') return null;
+ if (FIRST_PARTY_HOSTS.test(abs.hostname)) return abs.href;
+ return `/api/img-proxy?url=${encodeURIComponent(abs.href)}`;
+}
+
+const HTML_OPTIONS = {
+ allowedTags: [
+ 'p', 'br', 'hr', 'div', 'span', 'b', 'i', 'strong', 'em', 'u', 's', 'small', 'sub', 'sup',
+ 'blockquote', 'code', 'pre', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'a', 'img', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'figure', 'figcaption',
+ ],
+ allowedAttributes: {
+ '*': ['class', 'id', 'title', 'style'],
+ a: ['href', 'target', 'rel'],
+ img: ['src', 'alt', 'width', 'height', 'loading', 'referrerpolicy'],
+ td: ['colspan', 'rowspan'],
+ th: ['colspan', 'rowspan'],
+ },
+ allowedSchemes: ['http', 'https', 'mailto'],
+ allowedSchemesByTag: { img: ['http', 'https'] },
+ // Never let the streamer point a link/image at something that resolves in-process
+ // (data:/javascript:/vbscript: URLs, credentials in the URL) — belt-and-suspenders on top
+ // of the scheme allowlist above.
+ allowProtocolRelative: false,
+ disallowedTagsMode: 'discard',
+ nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript', 'iframe', 'object', 'embed', 'svg'],
+ // Any link opens in a new, non-opener tab — a streamer's page never navigates the viewer's
+ // existing tab away from OpenVibe.
+ transformTags: {
+ a: (tagName, attribs) => ({ tagName, attribs: { ...attribs, target: '_blank', rel: 'noopener noreferrer nofollow ugc' } }),
+ img: (tagName, attribs) => {
+ const src = proxiedImageSrc(attribs.src);
+ if (!src) return { tagName: 'span', attribs: {}, text: '' }; // unparsable src → drop the element
+ return { tagName, attribs: { ...attribs, src, loading: 'lazy', referrerpolicy: 'no-referrer' } };
+ },
+ },
+ // Inline `style` is plain CSS, not script — no browser executes CSS. The one legacy vector
+ // (IE `expression()` / `-moz-binding`) is dead in every current engine, so no CSS filtering
+ // is needed here beyond what the tag/attribute allowlist above already does.
+};
+
+function sanitizeOfflineHtml(html) {
+ const s = String(html || '');
+ if (!s.trim()) return '';
+ return sanitizeHtml(s, HTML_OPTIONS);
+}
+
+// offline_css is a standalone <style> block, never inline script-bearing markup — but strip the
+// handful of constructs that historically executed code from CSS (dead in modern engines, kept
+// as defense in depth) and any @import (would be a first-party-bypassing, untracked fetch).
+function sanitizeOfflineCss(css) {
+ const s = String(css || '');
+ if (!s.trim()) return '';
+ return s
+ .replace(/@import[^;]*;?/gi, '')
+ .replace(/expression\s*\(/gi, 'blocked(')
+ .replace(/-moz-binding\s*:/gi, 'blocked:')
+ .replace(/behavior\s*:/gi, 'blocked:');
+}
+
+module.exports = { sanitizeOfflineHtml, sanitizeOfflineCss };
@@ -32,6 +32,7 @@ const i18n = require('../i18n/translate');
@@ -32,6 +32,7 @@ const i18n = require('../i18n/translate');
const robotStreamerService = require('../integrations/robotstreamer-service');
const robotStreamerService = require('../integrations/robotstreamer-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatServer = require('../chat/chat-server');
const chatServer = require('../chat/chat-server');
+const { sanitizeOfflineHtml, sanitizeOfflineCss } = require('./offline-html-sanitize');
const multer = require('multer');
const multer = require('multer');
const path = require('path');
const path = require('path');
const fs = require('fs');
const fs = require('fs');
@@ -429,6 +430,10 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -429,6 +430,10 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
// Strip private fields from public channel response
// Strip private fields from public channel response
const publicChannel = { ...channel, follower_count: followerCount, is_following: isFollowing };
const publicChannel = { ...channel, follower_count: followerCount, is_following: isFollowing };
+ // Re-sanitize on every read: covers rows saved before offline-html-sanitize.js existed,
+ // with no backfill migration needed, at the cost of re-running a cheap parse per request.
+ if (publicChannel.offline_html) publicChannel.offline_html = sanitizeOfflineHtml(publicChannel.offline_html);
+ if (publicChannel.offline_css) publicChannel.offline_css = sanitizeOfflineCss(publicChannel.offline_css);
// Whether the weather widget is on (without leaking the zip) — used by the
// Whether the weather widget is on (without leaking the zip) — used by the
// client to decide if the About tab should show.
// client to decide if the About tab should show.
publicChannel.weather_enabled = !!(channel.weather_zip && channel.weather_detail && channel.weather_detail !== 'off');
publicChannel.weather_enabled = !!(channel.weather_zip && channel.weather_detail && channel.weather_detail !== 'off');
@@ -715,8 +720,10 @@ router.put('/channel', requireAuth, (req, res) => {
@@ -715,8 +720,10 @@ router.put('/channel', requireAuth, (req, res) => {
const t = String(req.body.offline_screen_type || 'none').trim();
const t = String(req.body.offline_screen_type || 'none').trim();
if (['none', 'image', 'video', 'html'].includes(t)) fields.offline_screen_type = t;
if (['none', 'image', 'video', 'html'].includes(t)) fields.offline_screen_type = t;
}
}
- if (hasOwn(req.body, 'offline_html')) fields.offline_html = String(req.body.offline_html || '').slice(0, 20000);
- if (hasOwn(req.body, 'offline_css')) fields.offline_css = String(req.body.offline_css || '').slice(0, 20000);
+ // Sanitized on write (basic markup, no script/frames/forms — see offline-html-sanitize.js)
+ // and again on every read below, so rows saved before this sanitizer existed are covered too.
+ if (hasOwn(req.body, 'offline_html')) fields.offline_html = sanitizeOfflineHtml(String(req.body.offline_html || '').slice(0, 20000));
+ if (hasOwn(req.body, 'offline_css')) fields.offline_css = sanitizeOfflineCss(String(req.body.offline_css || '').slice(0, 20000));
if (Object.keys(fields).length > 0) {
if (Object.keys(fields).length > 0) {
db.updateChannel(req.user.id, fields);
db.updateChannel(req.user.id, fields);