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

OP OpenVibers committed 21 Sept 2026 · be51afc36e · 8 files, +414 −12

Security: offline_html/offline_css were stored and served raw — a streamer's custom offline
screen ran arbitrary script (sandboxed from the site's own origin, but still able to pop
unsandboxed windows via allow-popups-to-escape-sandbox, and to phish/track via forms and
direct third-party image loads leaking viewer IP/UA). Now sanitized to a basic-markup allowlist
(server/streaming/offline-html-sanitize.js, sanitize-html) on both write and every read — so
rows saved before this fix are cleaned too, no backfill needed — and the client iframe sandbox
is tightened to just `allow-popups` (no scripts, no forms, no sandbox-escaping popups).

Privacy: third-party <img src> in offline_html is rewritten through the new /api/img-proxy
(server/media/external-image-proxy.js), so a viewer's browser never makes a direct request to
a host the streamer chose. SSRF-guarded via the existing egress module (server/net/egress.js,
now exporting fetchBuffer alongside fetchText) — same connect-time address policy already used
by the kiosk link-preview and media-downloader routes for user-chosen URLs.

UX: a streamer with a custom offline image showed only a compact "keep it light" overlay,
missing the full Most Watched / discover board the default (no image) screen shows. The
discover board was mounting inside #ch-offline-screen, which is clipped (overflow:hidden,
image-derived max-height) when a custom image is set — it was rendering fully off-screen
either way. Now anchored after the whole screen box instead of the nested overlay host, so it
renders in normal flow below the image, unclipped.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 55313c61..fe65d18e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex e22360c8..4bc154fe 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 94380ce5..db8e3f7a 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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) {
ddiff --git a/server/index.js b/server/index.js
iindex 05ef1969..c8389cbd 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
ddiff --git a/server/media/external-image-proxy.js b/server/media/external-image-proxy.js
nnew file mode 100644
iindex 00000000..af7e53e0
---- /dev/null
++++ b/server/media/external-image-proxy.js
@@@ -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;
ddiff --git a/server/net/egress.js b/server/net/egress.js
iindex e7455496..ea4d2142 100644
---- a/server/net/egress.js
++++ b/server/net/egress.js
@@@ -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, maxRedi
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;
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, maxRedi
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
* 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 };
ddiff --git a/server/streaming/offline-html-sanitize.js b/server/streaming/offline-html-sanitize.js
nnew file mode 100644
iindex 00000000..7628fc8d
---- /dev/null
++++ b/server/streaming/offline-html-sanitize.js
@@@ -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 };
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 8b152017..70a08573 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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 response
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
// 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);