openvibers/openvibe.actor Commits

Canvas: a cross-origin read from openvibe.chat only (HO9) (#347)

OP OpenVibers committed 5 days ago · 516db6ec0d · 3 files, +92 −0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 02b721f..f4fce60 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -49,6 +49,9 @@ What changed in OpenVibe.Actor, newest first. Each site also publishes its patch
v4.145.0's ov-canvas.js. The page redraws it from the thread's live events when `canvas_set` runs; a closed bar
still updates its count and says "updated" for a few seconds. The document is in the account export and goes with
the account. Test: `test/canvas.test.js`; openvibe-contracts v0.341.0 names the routes.
++- **Chat can draw it beside a Vibe chat** (HO9 step 2): the two reads answer a cross-origin request from
++ `https://openvibe.chat` only, with the person's own Network Bearer and no credentials mode (a cookie from another
++ site is refused), and carry `Vary: Origin`; every canvas write stays same-origin. Test: `test/canvas.test.js`.
## Unreleased — the skills registry (plan HO2)
ddiff --git a/server/http/api.js b/server/http/api.js
iindex b2b57db..37f8d8c 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -70,6 +70,40 @@ function createApi(ctx) {
r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ // ── A canvas belongs to the person, and Chat may draw it beside a Vibe chat (plan HO9) ─────
++ // Only the two reads are cross-origin, only for openvibe.chat, and only with the person's own Bearer. A cookie
++ // (this site's session) is not a credential another site may spend, and every canvas write stays on this site:
++ // a cross-origin request that is not that read is refused before a route sees it. Vary: Origin keeps a shared
++ // cache from handing one origin's answer to another. No Access-Control-Allow-Credentials is ever sent.
++ const CANVAS_ORIGIN = 'https://openvibe.chat';
++ const CANVAS_READ = /^\/me\/canvases(?:\/cnv_[0-9A-HJKMNP-TV-Z]{26})?$/;
++ const CANVAS_PATH = /^\/me\/canvases(?:\/[^/]+(?:\/events)?)?$/;
++ r.use((req, res, next) => {
++ const path = String(req.originalUrl || req.url || '').split('?')[0].replace(/^\/api\/v1/, '');
++ if (!CANVAS_PATH.test(path)) return next();
++ const origin = String(req.get('origin') || '');
++ if (!origin) return next(); // a same-site fetch, or a server-to-server call
++ const same = origin === config.baseUrl || origin === `${req.protocol}://${req.get('host')}`;
++ if (same) return next();
++ res.vary('Origin');
++ if (origin !== CANVAS_ORIGIN) return problem(req, res, 403, 'request.cross_site', 'A canvas can be read from openvibe.chat only.');
++ const method = String(req.method || '').toUpperCase();
++ const read = CANVAS_READ.test(path);
++ if (method === 'OPTIONS') {
++ if (!read || String(req.get('access-control-request-method') || 'GET').toUpperCase() !== 'GET') return res.status(403).end();
++ res.set('Access-Control-Allow-Origin', CANVAS_ORIGIN)
++ .set('Access-Control-Allow-Methods', 'GET')
++ .set('Access-Control-Allow-Headers', 'Authorization, Accept')
++ .set('Access-Control-Max-Age', '600');
++ return res.status(204).end();
++ }
++ if (!read || method !== 'GET') return problem(req, res, 403, 'request.cross_site', 'Only a canvas read may come from openvibe.chat; every canvas change is made on openvibe.actor itself.');
++ res.set('Access-Control-Allow-Origin', CANVAS_ORIGIN);
++ if (!/^Bearer\s+\S+/i.test(String(req.get('authorization') || ''))) {
++ return problem(req, res, 401, 'token.required', 'Send the person\'s own Network token as a Bearer; cookies are not read from another site.');
++ }
++ return next();
++ });
r.use(principal.middleware);
r.use('/chat-apps', require('../chat-apps/routes').router(ctx));
ddiff --git a/test/canvas.test.js b/test/canvas.test.js
iindex 840f1a0..56f5274 100644
---- a/test/canvas.test.js
++++ b/test/canvas.test.js
@@@ -179,6 +179,61 @@ const CHECKLIST = [{ type: 'heading', text: 'Lisbon, 12–14 March' },
assert.match(css, /contain: layout paint/);
});
++ await check('only openvibe.chat reads a canvas cross-origin, only with the person\'s Bearer', async () => {
++ const CHAT = 'https://openvibe.chat';
++ const read = { origin: CHAT, accept: 'application/json' };
++ const bearer = t.network.userToken(alice);
++ const one = await t.get(`/api/v1/me/canvases?thread=${root.id}`, { bearer, headers: read });
++ assert.strictEqual(one.status, 200, one.text);
++ assert.strictEqual(one.headers.get('access-control-allow-origin'), CHAT);
++ assert.match(String(one.headers.get('vary') || ''), /Origin/i);
++ assert.strictEqual(one.headers.get('access-control-allow-credentials'), null, 'no credentials mode');
++ assert.strictEqual(one.json().canvases[0].id, canvasId);
++
++ const byId = await t.get(`/api/v1/me/canvases/${canvasId}`, { bearer, headers: read });
++ assert.strictEqual(byId.status, 200, byId.text);
++ assert.strictEqual(byId.headers.get('access-control-allow-origin'), CHAT);
++ assert.strictEqual(byId.json().id, canvasId);
++ });
++
++ await check('another origin gets no CORS headers, a cookie without Bearer is refused, and a canvas POST is refused', async () => {
++ const CHAT = 'https://openvibe.chat';
++ const bearer = t.network.userToken(alice);
++ const other = await t.get(`/api/v1/me/canvases?thread=${root.id}`, { bearer, headers: { origin: 'https://example.net' } });
++ assert.strictEqual(other.status, 403, other.text);
++ assert.strictEqual(other.headers.get('access-control-allow-origin'), null);
++
++ const cookieOnly = await t.get(`/api/v1/me/canvases?thread=${root.id}`, { as: alice, headers: { origin: CHAT } });
++ assert.strictEqual(cookieOnly.status, 401, cookieOnly.text);
++ assert.strictEqual(cookieOnly.headers.get('access-control-allow-credentials'), null);
++
++ const before = await s.db.maybe('SELECT version FROM actor_canvases WHERE id = $1', [canvasId]);
++ const post = await t.get(`/api/v1/me/canvases/${canvasId}/events`, {
++ bearer, json: { kind: 'check', block_id: 'packing', item_id: 'bags', done: true }, headers: { origin: CHAT },
++ });
++ assert.strictEqual(post.status, 403, post.text);
++ assert.strictEqual(post.headers.get('access-control-allow-origin'), null, 'a write is never a CORS route');
++ assert.strictEqual(Number((await s.db.maybe('SELECT version FROM actor_canvases WHERE id = $1', [canvasId])).version), Number(before.version), 'the refused POST changed nothing');
++ });
++
++ await check('the canvas preflight allows a GET with Authorization from openvibe.chat only', async () => {
++ const CHAT = 'https://openvibe.chat';
++ const pre = await t.get(`/api/v1/me/canvases?thread=${root.id}`, {
++ method: 'OPTIONS',
++ headers: { origin: CHAT, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' },
++ });
++ assert.strictEqual(pre.status, 204, pre.text);
++ assert.strictEqual(pre.headers.get('access-control-allow-origin'), CHAT);
++ assert.match(String(pre.headers.get('access-control-allow-methods') || ''), /GET/);
++ assert.match(String(pre.headers.get('access-control-allow-headers') || ''), /authorization/i);
++ assert.strictEqual(pre.headers.get('access-control-allow-credentials'), null);
++ const evil = await t.get(`/api/v1/me/canvases?thread=${root.id}`, {
++ method: 'OPTIONS', headers: { origin: 'https://example.net', 'access-control-request-method': 'GET' },
++ });
++ assert.strictEqual(evil.status, 403);
++ assert.strictEqual(evil.headers.get('access-control-allow-origin'), null);
++ });
++
await check('account data names the canvas table (export and deletion cover it)', async () => {
const { TABLES } = require('../server/identity/account-data');
const entry = TABLES.find((x) => x.table === 'actor_canvases');