openvibers/openvibe.actor Commits
Canvas: a cross-origin read from openvibe.chat only (HO9) (#347)
Changes
@@@ -49,6 +49,9 @@ What changed in OpenVibe.Actor, newest first. Each site also publishes its patchv4.145.0's ov-canvas.js. The page redraws it from the thread's live events when `canvas_set` runs; a closed barstill updates its count and says "updated" for a few seconds. The document is in the account export and goes withthe 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)@@@ -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));@@@ -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');
@@ -49,6 +49,9 @@ What changed in OpenVibe.Actor, newest first. Each site also publishes its patch
@@ -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
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
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.
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)
## Unreleased — the skills registry (plan HO2)
@@ -70,6 +70,40 @@ function createApi(ctx) {
@@ -70,6 +70,40 @@ function createApi(ctx) {
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
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(principal.middleware);
r.use('/chat-apps', require('../chat-apps/routes').router(ctx));
r.use('/chat-apps', require('../chat-apps/routes').router(ctx));
@@ -179,6 +179,61 @@ const CHECKLIST = [{ type: 'heading', text: 'Lisbon, 12–14 March' },
@@ -179,6 +179,61 @@ const CHECKLIST = [{ type: 'heading', text: 'Lisbon, 12–14 March' },
assert.match(css, /contain: layout paint/);
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 () => {
await check('account data names the canvas table (export and deletion cover it)', async () => {
const { TABLES } = require('../server/identity/account-data');
const { TABLES } = require('../server/identity/account-data');
const entry = TABLES.find((x) => x.table === 'actor_canvases');
const entry = TABLES.find((x) => x.table === 'actor_canvases');