openvibers/openvibe.live Commits
The agent embed may be framed by the Companion's exact extension origin (#654)
Changes
@@@ -196,3 +196,7 @@ OV_BILLING_PUBLIC_URL=https://billing.openvibe.network#INVENTORY_URL=http://127.0.0.1:5030# OV_BILLING_TIMEOUT_MS=8000OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230++++# The Companion extension's side panel frames /embed/agent: set this to the Chrome Web Store id once the extension++# is published (exactly chrome-extension:// + 32 letters a-p; comma-separate several; anything else is ignored).++#AGENT_EMBED_EXTENSION_ORIGINS=chrome-extension://bjcjcfcecpkkmfndegkjekoipdmbgakd@@@ -11,6 +11,12 @@const id = body && body.dataset ? body.dataset.agentId : '';if (!id) return;const $ = (name) => document.getElementById(name);++ // A page embedding this one cannot see a `frame-ancestors` refusal (the blocked frame shows an error page, and a++ // parent may not read it). This one message says the embed really loaded; the Dock falls back to the still and a++ // link to the stream when it never arrives. It carries nothing: no ticket, no stream.++ try {++ if (window.parent && window.parent !== window) window.parent.postMessage({ type: 'ov-agent-embed', v: 1 }, '*');++ } catch { /* no parent to tell */ }let ticket = new URLSearchParams(location.search).get('vt') || '';let player = null;let renewTimer = null;@@@ -22,9 +22,30 @@ const SERVICE_SITES = [];/** The CSP frame-ancestors list: first-party origins, plus a deployment's extra ones from the environment. */--function frameAncestors() {++const EXTENSION_ORIGIN_RE = /^chrome-extension:\/\/[a-p]{32}$/;++const EXTENSION_ORIGINS_ENV = 'AGENT_EMBED_EXTENSION_ORIGINS';++++/**++ * The Companion extension's exact origin (`chrome-extension://<32 letters a-p>`), the Chrome Web Store id the owner++ * sets once the extension is published. Only exact origins, comma-separated: never a scheme-wide `chrome-extension:`++ * or `moz-extension:`, because any installed extension could then frame a person's private screen and trick a click.++ * Anything else is ignored, with one warning at boot. Empty by default: today's header, unchanged.++ */++function parseExtensionOrigins(raw, log = console) {++ const kept = [];++ const ignored = [];++ for (const value of String(raw || '').split(',').map((v) => v.trim()).filter(Boolean)) {++ (EXTENSION_ORIGIN_RE.test(value) ? kept : ignored).push(value);++ }++ if (ignored.length) log.warn(`[AgentStream] ${EXTENSION_ORIGINS_ENV}: ignoring ${ignored.join(', ')} (each entry must be exactly chrome-extension://<32 letters a-p>)`);++ return [...new Set(kept)];++}++++const EXTENSION_ORIGINS = parseExtensionOrigins(process.env[EXTENSION_ORIGINS_ENV]);++++function frameAncestors(extensions = EXTENSION_ORIGINS) {const extra = String(process.env.AGENT_EMBED_FRAME_ANCESTORS || '').split(/[\s,]+/).filter(Boolean);-- return ["'self'", ...SERVICE_SITES, ...extra].join(' ');++ return ["'self'", ...SERVICE_SITES, ...extra, ...extensions].join(' ');}function embedCsp() {@@@ -89,4 +110,4 @@ async function embed(req, res) {res.type('html').send(html(id));}--module.exports = { embed, embedCsp, frameAncestors, SERVICE_SITES };++module.exports = { embed, embedCsp, frameAncestors, parseExtensionOrigins, EXTENSION_ORIGINS, EXTENSION_ORIGIN_RE, SERVICE_SITES };@@@ -400,6 +400,30 @@ async function check(name, fn) {assert.strictEqual(bad.status, 404);} finally { s.close(); }});++++ await check('AGENT_EMBED_EXTENSION_ORIGINS adds exactly one chrome-extension id, and only that', async () => {++ const p = require('../server/agent-streams/pages');++ const id = 'a'.repeat(32);++ const origin = `chrome-extension://${id}`;++ // Unset: today's header, no extension origin at all.++ assert.ok(!p.embedCsp().includes('chrome-extension://'), 'unset: the CSP is unchanged');++ assert.ok(!p.frameAncestors().includes('chrome-extension:'), 'unset: no extension may frame it');++ // A valid id is added to the header the page serves.++ delete process.env.AGENT_EMBED_EXTENSION_ORIGINS;++ process.env.AGENT_EMBED_EXTENSION_ORIGINS = `${origin},${'b'.repeat(32)}`;++ delete require.cache[require.resolve('../server/agent-streams/pages')];++ const set = require('../server/agent-streams/pages');++ assert.ok(set.frameAncestors().includes(origin), 'the valid id is added');++ assert.ok(set.embedCsp().includes(origin), 'and it frames the page in the CSP');++ assert.ok(!set.frameAncestors().includes('chrome-extension://bb'), 'a bare 32 letters without the scheme is ignored');++ // Anything that is not one exact origin is dropped, with one warning at boot.++ const warned = [];++ assert.deepStrictEqual(p.parseExtensionOrigins(`${origin}, chrome-extension:, moz-extension://abc, chrome-extension://${'A'.repeat(32)}, https://evil.example`, { warn: (m) => warned.push(m) }), [origin]);++ assert.strictEqual(warned.length, 1, 'one warning at boot');++ assert.ok(!p.frameAncestors(p.parseExtensionOrigins('chrome-extension:, moz-extension://abc', { warn() {} })).includes('moz-extension:'), 'never a scheme-wide source');++ delete process.env.AGENT_EMBED_EXTENSION_ORIGINS;++ delete require.cache[require.resolve('../server/agent-streams/pages')];++ });} finally {server.close();}
@@ -196,3 +196,7 @@ OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
@@ -196,3 +196,7 @@ OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
#INVENTORY_URL=http://127.0.0.1:5030
#INVENTORY_URL=http://127.0.0.1:5030
# OV_BILLING_TIMEOUT_MS=8000
# OV_BILLING_TIMEOUT_MS=8000
OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
+
+# The Companion extension's side panel frames /embed/agent: set this to the Chrome Web Store id once the extension
+# is published (exactly chrome-extension:// + 32 letters a-p; comma-separate several; anything else is ignored).
+#AGENT_EMBED_EXTENSION_ORIGINS=chrome-extension://bjcjcfcecpkkmfndegkjekoipdmbgakd
@@ -11,6 +11,12 @@
@@ -11,6 +11,12 @@
const id = body && body.dataset ? body.dataset.agentId : '';
const id = body && body.dataset ? body.dataset.agentId : '';
if (!id) return;
if (!id) return;
const $ = (name) => document.getElementById(name);
const $ = (name) => document.getElementById(name);
+ // A page embedding this one cannot see a `frame-ancestors` refusal (the blocked frame shows an error page, and a
+ // parent may not read it). This one message says the embed really loaded; the Dock falls back to the still and a
+ // link to the stream when it never arrives. It carries nothing: no ticket, no stream.
+ try {
+ if (window.parent && window.parent !== window) window.parent.postMessage({ type: 'ov-agent-embed', v: 1 }, '*');
+ } catch { /* no parent to tell */ }
let ticket = new URLSearchParams(location.search).get('vt') || '';
let ticket = new URLSearchParams(location.search).get('vt') || '';
let player = null;
let player = null;
let renewTimer = null;
let renewTimer = null;
@@ -22,9 +22,30 @@ const SERVICE_SITES = [
@@ -22,9 +22,30 @@ const SERVICE_SITES = [
];
];
/** The CSP frame-ancestors list: first-party origins, plus a deployment's extra ones from the environment. */
/** The CSP frame-ancestors list: first-party origins, plus a deployment's extra ones from the environment. */
-function frameAncestors() {
+const EXTENSION_ORIGIN_RE = /^chrome-extension:\/\/[a-p]{32}$/;
+const EXTENSION_ORIGINS_ENV = 'AGENT_EMBED_EXTENSION_ORIGINS';
+
+/**
+ * The Companion extension's exact origin (`chrome-extension://<32 letters a-p>`), the Chrome Web Store id the owner
+ * sets once the extension is published. Only exact origins, comma-separated: never a scheme-wide `chrome-extension:`
+ * or `moz-extension:`, because any installed extension could then frame a person's private screen and trick a click.
+ * Anything else is ignored, with one warning at boot. Empty by default: today's header, unchanged.
+ */
+function parseExtensionOrigins(raw, log = console) {
+ const kept = [];
+ const ignored = [];
+ for (const value of String(raw || '').split(',').map((v) => v.trim()).filter(Boolean)) {
+ (EXTENSION_ORIGIN_RE.test(value) ? kept : ignored).push(value);
+ }
+ if (ignored.length) log.warn(`[AgentStream] ${EXTENSION_ORIGINS_ENV}: ignoring ${ignored.join(', ')} (each entry must be exactly chrome-extension://<32 letters a-p>)`);
+ return [...new Set(kept)];
+}
+
+const EXTENSION_ORIGINS = parseExtensionOrigins(process.env[EXTENSION_ORIGINS_ENV]);
+
+function frameAncestors(extensions = EXTENSION_ORIGINS) {
const extra = String(process.env.AGENT_EMBED_FRAME_ANCESTORS || '').split(/[\s,]+/).filter(Boolean);
const extra = String(process.env.AGENT_EMBED_FRAME_ANCESTORS || '').split(/[\s,]+/).filter(Boolean);
- return ["'self'", ...SERVICE_SITES, ...extra].join(' ');
+ return ["'self'", ...SERVICE_SITES, ...extra, ...extensions].join(' ');
}
}
function embedCsp() {
function embedCsp() {
@@ -89,4 +110,4 @@ async function embed(req, res) {
@@ -89,4 +110,4 @@ async function embed(req, res) {
res.type('html').send(html(id));
res.type('html').send(html(id));
}
}
-module.exports = { embed, embedCsp, frameAncestors, SERVICE_SITES };
+module.exports = { embed, embedCsp, frameAncestors, parseExtensionOrigins, EXTENSION_ORIGINS, EXTENSION_ORIGIN_RE, SERVICE_SITES };
@@ -400,6 +400,30 @@ async function check(name, fn) {
@@ -400,6 +400,30 @@ async function check(name, fn) {
assert.strictEqual(bad.status, 404);
assert.strictEqual(bad.status, 404);
} finally { s.close(); }
} finally { s.close(); }
});
});
+
+ await check('AGENT_EMBED_EXTENSION_ORIGINS adds exactly one chrome-extension id, and only that', async () => {
+ const p = require('../server/agent-streams/pages');
+ const id = 'a'.repeat(32);
+ const origin = `chrome-extension://${id}`;
+ // Unset: today's header, no extension origin at all.
+ assert.ok(!p.embedCsp().includes('chrome-extension://'), 'unset: the CSP is unchanged');
+ assert.ok(!p.frameAncestors().includes('chrome-extension:'), 'unset: no extension may frame it');
+ // A valid id is added to the header the page serves.
+ delete process.env.AGENT_EMBED_EXTENSION_ORIGINS;
+ process.env.AGENT_EMBED_EXTENSION_ORIGINS = `${origin},${'b'.repeat(32)}`;
+ delete require.cache[require.resolve('../server/agent-streams/pages')];
+ const set = require('../server/agent-streams/pages');
+ assert.ok(set.frameAncestors().includes(origin), 'the valid id is added');
+ assert.ok(set.embedCsp().includes(origin), 'and it frames the page in the CSP');
+ assert.ok(!set.frameAncestors().includes('chrome-extension://bb'), 'a bare 32 letters without the scheme is ignored');
+ // Anything that is not one exact origin is dropped, with one warning at boot.
+ const warned = [];
+ assert.deepStrictEqual(p.parseExtensionOrigins(`${origin}, chrome-extension:, moz-extension://abc, chrome-extension://${'A'.repeat(32)}, https://evil.example`, { warn: (m) => warned.push(m) }), [origin]);
+ assert.strictEqual(warned.length, 1, 'one warning at boot');
+ assert.ok(!p.frameAncestors(p.parseExtensionOrigins('chrome-extension:, moz-extension://abc', { warn() {} })).includes('moz-extension:'), 'never a scheme-wide source');
+ delete process.env.AGENT_EMBED_EXTENSION_ORIGINS;
+ delete require.cache[require.resolve('../server/agent-streams/pages')];
+ });
} finally {
} finally {
server.close();
server.close();
}
}