openvibers/openvibe.network Commits

Bridge Live player frames through Network across sites (#835)

OP OpenVibers committed 2 days ago · 18bcadf8bc · 4 files, +98 −0

* Host a narrow Live player bridge for cross-site frames

* Permit the first-party Link homepage as a player host

* Recognize remaining first-party player destinations

Changes

ddiff --git a/public/js/live-player-bridge.js b/public/js/live-player-bridge.js
nnew file mode 100644
iindex 00000000..d31a0fa5
---- /dev/null
++++ b/public/js/live-player-bridge.js
@@@ -0,0 +1,24 @@
++'use strict';
++(() => {
++ const from = new URLSearchParams(location.search).get('from');
++ const frame = document.getElementById('player');
++ const liveOrigin = 'https://openvibe.live';
++ if (!from || !frame || parent === window) return;
++ let loaded = false;
++ const pending = [];
++ frame.addEventListener('load', () => {
++ loaded = true;
++ for (const cmd of pending.splice(0)) frame.contentWindow.postMessage({ type: 'ov-embed', cmd }, liveOrigin);
++ });
++ addEventListener('message', (event) => {
++ const data = event.data;
++ if (!data || data.type !== 'ov-embed') return;
++ if (event.source === parent && event.origin === from) {
++ if (!['mute', 'unmute', 'play', 'pause'].includes(data.cmd)) return;
++ if (loaded) frame.contentWindow.postMessage({ type: 'ov-embed', cmd: data.cmd }, liveOrigin);
++ else pending.push(data.cmd);
++ } else if (event.source === frame.contentWindow && event.origin === liveOrigin && data.event === 'state') {
++ parent.postMessage(data, from);
++ }
++ });
++})();
ddiff --git a/server/frame/live-player.js b/server/frame/live-player.js
nnew file mode 100644
iindex 00000000..4eeb95ed
---- /dev/null
++++ b/server/frame/live-player.js
@@@ -0,0 +1,32 @@
++'use strict';
++const express = require('express');
++const { SITES, PRODUCTS } = require('./sites');
++
++const allowedHosts = new Set([...SITES, ...PRODUCTS].map((site) => site.host));
++for (const host of ['openvibe.link', 'openvibe.my', 'openvibe.mail', 'openvibe.search',
++ 'openvibe.shoes', 'openvibe.connect', 'openvibe.party', 'openvibe.inventory']) allowedHosts.add(host);
++const router = express.Router();
++
++function parentOrigin(value) {
++ try {
++ const url = new URL(String(value || ''));
++ if (url.protocol !== 'https:' || url.origin !== value || !allowedHosts.has(url.hostname)) return null;
++ return url.origin;
++ } catch { return null; }
++}
++
++// Every destination already permits Network frames. This narrow bridge lets one of
++// those frames host Live's existing /embed player without widening every site's CSP.
++router.get('/:username', (req, res) => {
++ const from = parentOrigin(req.query.from);
++ const username = String(req.params.username || '');
++ if (!from || !/^[A-Za-z0-9_-]{1,40}$/.test(username)) return res.sendStatus(404);
++ res.removeHeader('X-Frame-Options');
++ res.set({
++ 'Content-Security-Policy': `default-src 'none'; frame-src https://openvibe.live; frame-ancestors ${from}; script-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; object-src 'none'`,
++ 'Cache-Control': 'public, max-age=300',
++ });
++ res.type('html').send(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live stream</title><style>html,body{margin:0;width:100%;height:100%;background:#000}iframe{display:block;width:100%;height:100%;border:0}</style></head><body><iframe id="player" src="https://openvibe.live/embed/${encodeURIComponent(username)}" title="Live stream" allow="autoplay; fullscreen; picture-in-picture" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" referrerpolicy="strict-origin-when-cross-origin"></iframe><script src="/js/live-player-bridge.js" defer></script></body></html>`);
++});
++
++module.exports = { router, parentOrigin };
ddiff --git a/server/index.js b/server/index.js
iindex a6ac6a38..4a6dd94b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -159,6 +159,7 @@ app.use(helmet({
crossOriginEmbedderPolicy: false,
}));
app.use(cookieParser());
++app.use('/frame/live', require('./frame/live-player').router);
// Provider webhooks (Resend bounces/complaints) — mounted BEFORE the JSON body parser so
// the route sees the raw bytes it must verify the Svix signature over.
app.use('/api/webhooks', require('./notifications/resend-webhook')());
ddiff --git a/test/live-player-bridge.test.js b/test/live-player-bridge.test.js
nnew file mode 100644
iindex 00000000..76b49241
---- /dev/null
++++ b/test/live-player-bridge.test.js
@@@ -0,0 +1,41 @@
++'use strict';
++const assert = require('node:assert/strict');
++const express = require('express');
++const helmet = require('helmet');
++const path = require('node:path');
++const { router, parentOrigin } = require('../server/frame/live-player');
++
++assert.equal(parentOrigin('https://openvibe.video'), 'https://openvibe.video');
++assert.equal(parentOrigin('https://openvibe.link'), 'https://openvibe.link');
++assert.equal(parentOrigin('https://openvibe.my'), 'https://openvibe.my');
++assert.equal(parentOrigin('https://evil.example'), null);
++assert.equal(parentOrigin('http://openvibe.video'), null);
++assert.equal(parentOrigin('https://openvibe.video/path'), null);
++
++(async () => {
++ const app = express();
++ app.use(helmet());
++ app.use('/frame/live', router);
++ app.use(express.static(path.join(__dirname, '..', 'public')));
++ const server = app.listen(0, '127.0.0.1');
++ try {
++ await new Promise((resolve) => server.once('listening', resolve));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const res = await fetch(`${base}/frame/live/maticus?from=https%3A%2F%2Fopenvibe.video`);
++ assert.equal(res.status, 200);
++ const csp = res.headers.get('content-security-policy');
++ assert.match(csp, /frame-src https:\/\/openvibe\.live/);
++ assert.match(csp, /frame-ancestors https:\/\/openvibe\.video/);
++ assert.equal(res.headers.get('x-frame-options'), null);
++ const html = await res.text();
++ assert.match(html, /https:\/\/openvibe\.live\/embed\/maticus/);
++ assert.match(html, /\/js\/live-player-bridge\.js/);
++ const script = await fetch(`${base}/js/live-player-bridge.js`);
++ assert.equal(script.status, 200);
++ for (const from of ['https://evil.example', 'http://openvibe.video', 'https://openvibe.video/path']) {
++ const blocked = await fetch(`${base}/frame/live/maticus?from=${encodeURIComponent(from)}`);
++ assert.equal(blocked.status, 404);
++ }
++ console.log('live-player-bridge: exact first-party frame policy and relay asset');
++ } finally { server.close(); }
++})().catch((error) => { console.error(error); process.exitCode = 1; });