openvibers/openvibe.live Commits

The agent embed may be framed by the Companion's exact extension origin (#654)

OP OpenVibers committed 6 days ago · ce99258bd3 · 4 files, +58 −3

AGENT_EMBED_EXTENSION_ORIGINS is a comma list of exact
chrome-extension://<32 letters a-p> origins, added to /embed/agent's
frame-ancestors. Each value is validated; anything else is ignored with one
warning at boot. Empty by default, so today's header is unchanged. Never a
scheme-wide chrome-extension: or moz-extension:, which would let any installed
extension frame a person's private screen and trick a click.

The embed also announces itself to its parent with one empty postMessage. A CSP
frame-ancestors refusal shows the parent an error page it cannot read, so this
is how the Dock tells a rendered embed from a refused one and falls back to the
still and a link instead of an empty frame.

Tests in test/agent-streams.test.js: unset keeps today's CSP; a valid id is
added; invalid values (scheme-wide, moz-extension, uppercase, https) are
dropped with one warning.

Changes

ddiff --git a/.env.example b/.env.example
iindex 2d7613a1..910dde15 100644
---- a/.env.example
++++ b/.env.example
@@@ -196,3 +196,7 @@ OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
#INVENTORY_URL=http://127.0.0.1:5030
# OV_BILLING_TIMEOUT_MS=8000
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
ddiff --git a/public/js/agent-embed.js b/public/js/agent-embed.js
iindex 6c8b524a..144796f1 100644
---- a/public/js/agent-embed.js
++++ b/public/js/agent-embed.js
@@@ -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;
ddiff --git a/server/agent-streams/pages.js b/server/agent-streams/pages.js
iindex 2f0ae3d5..9f4cb23c 100644
---- a/server/agent-streams/pages.js
++++ b/server/agent-streams/pages.js
@@@ -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 };
ddiff --git a/test/agent-streams.test.js b/test/agent-streams.test.js
iindex 0e441694..12fc0ce6 100644
---- a/test/agent-streams.test.js
++++ b/test/agent-streams.test.js
@@@ -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();
}