openvibers/openvibe.network Commits

Link: safe machine picker return flow (#756)

OP OpenVibers committed 3 days ago · c1de8ad871 · 6 files, +48 −3

* Link: add a safe machine picker return flow

* Network: complete machine return test browser globals

Changes

ddiff --git a/public/js/my/machines.js b/public/js/my/machines.js
iindex ae968f33..4fac5dad 100644
---- a/public/js/my/machines.js
++++ b/public/js/my/machines.js
@@@ -151,6 +151,14 @@ async function mcPair() {
const fresh = ((list && list.machines) || []).find((m) => !before.has(m.id));
if (!fresh) return;
clearInterval(mcWatch);
++ const returnTo = new URLSearchParams(location.search).get('return_to');
++ if (returnTo) {
++ const done = new URL('https://openvibe.link/add-computer/complete');
++ done.searchParams.set('return_to', returnTo);
++ done.searchParams.set('machine', fresh.id);
++ location.assign(done.toString());
++ return;
++ }
box.hidden = true;
mcSay(`${fresh.name || 'Your machine'} is paired. Choose what it does for you below.`);
loadMachines();
ddiff --git a/server/link/routes.js b/server/link/routes.js
iindex f2419b46..03d22c3c 100644
---- a/server/link/routes.js
++++ b/server/link/routes.js
@@@ -22,6 +22,18 @@ function hostOf(req) {
return String((req.headers && req.headers.host) || '').split(':')[0].toLowerCase();
}
++// Return destinations are exact HTTPS origins from service manifests. Reject credentials,
++// protocol-relative URLs, localhost and arbitrary hosts before redirecting.
++function safeReturnTo(value) {
++ if (!value || String(value).length > 2048) return null;
++ try {
++ const url = new URL(String(value));
++ if (url.protocol !== 'https:' || url.username || url.password) return null;
++ if (!require('../first-party-origins').manifestOrigins().has(url.origin)) return null;
++ return url.toString();
++ } catch { return null; }
++}
++
/** Link's llms.txt: what the page is, how to install it, and where the docs are. */
function llmsTxt() {
return [
@@@ -81,6 +93,20 @@ function createLinkRoutes({ releaseId, releaseApi = release } = {}) {
return res.set('Cache-Control', 'public, max-age=600').redirect(302, target);
}
++ if (path === '/add-computer') {
++ const returnTo = safeReturnTo(url.searchParams.get('return_to'));
++ if (!returnTo) return res.status(400).type('text/plain').send('Choose a return address on an OpenVibe site.');
++ return res.set('Cache-Control', 'no-store').redirect(302, `${SITE}/machines?return_to=${encodeURIComponent(returnTo)}`);
++ }
++ if (path === '/add-computer/complete') {
++ const returnTo = safeReturnTo(url.searchParams.get('return_to'));
++ const id = url.searchParams.get('machine') || '';
++ if (!returnTo || !/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) return res.status(400).type('text/plain').send('That computer could not be selected.');
++ const target = new URL(returnTo);
++ target.searchParams.set('machine', id);
++ return res.set('Cache-Control', 'no-store').redirect(303, target.toString());
++ }
++
// The machine hub lives on Link's own origin. It uses Network's same-origin
// session and machine API, and keeps the existing roles and pairing controls.
if (path === '/machines') {
@@@ -155,4 +181,4 @@ function pathResolveLocker() {
return pathLib.join(pathLib.dirname(require.resolve('openvibe-sdk')), 'browser', 'openvibe-sdk.mjs');
}
--module.exports = { createLinkRoutes, llmsTxt, hostOf, HOST, WWW_HOST, SITE };
++module.exports = { createLinkRoutes, llmsTxt, hostOf, safeReturnTo, HOST, WWW_HOST, SITE };
ddiff --git a/server/registry/machines.js b/server/registry/machines.js
iindex 8744d430..367d7acd 100644
---- a/server/registry/machines.js
++++ b/server/registry/machines.js
@@@ -96,6 +96,10 @@ function machineOf(m) {
};
if (typeof m.name === 'string' && m.name) out.name = m.name;
if (typeof m.os === 'string' && m.os) out.os = m.os;
++ // The cross-site picker needs only a displayable system label and the abilities
++ // already granted to this machine; never expose node credentials or relay data.
++ out.system = out.os || out.kind;
++ out.can_run = out.roles.map((role) => ({ 'codes.worker': 'worker', 'desks.host': 'browser', 'ai.host': 'models', 'bot.robot': 'robot' }[role] || role));
if (typeof m.last_seen_at === 'string' && m.last_seen_at) out.last_seen_at = m.last_seen_at;
if (typeof m.node_version === 'string' && m.node_version) out.node_version = m.node_version;
if (typeof m.node_id === 'string' && PRINCIPAL.test(m.node_id)) out.principal_id = m.node_id;
ddiff --git a/test/link-home.test.js b/test/link-home.test.js
iindex 5e105e8c..c13e8ff7 100644
---- a/test/link-home.test.js
++++ b/test/link-home.test.js
@@@ -12,7 +12,10 @@ const express = require('express');
const page = require('../server/link/page');
const release = require('../server/link/release');
--const { createLinkRoutes, llmsTxt } = require('../server/link/routes');
++const { createLinkRoutes, llmsTxt, safeReturnTo } = require('../server/link/routes');
++assert.equal(safeReturnTo('https://evil.example/path'), null, 'return address rejects unknown sites');
++assert.equal(safeReturnTo('javascript:alert(1)'), null, 'return address rejects non-https URLs');
++assert.ok(safeReturnTo('https://openvibe.codes/run'), 'return address accepts an exact manifest site');
// ── The release answer: cached for ten minutes, with a fallback when GitHub cannot be reached ──
const FIXTURE = {
ddiff --git a/test/machines-hub.test.js b/test/machines-hub.test.js
iindex 8b54807f..2debf637 100644
---- a/test/machines-hub.test.js
++++ b/test/machines-hub.test.js
@@@ -127,11 +127,15 @@ const runToken = (capability, person = ana.subject_id) => serviceAuth.signServic
}, keys.privateKey);
try {
++ const signedOut = await call('GET', '/api/v1/machines');
++ assert.strictEqual(signedOut.status, 401, "the cross-site read requires the person's Network token");
// ── A person sees their own machines, and the hub calls Bot as them. ──
const mine = await call('GET', '/api/v1/machines', { user: ana });
assert.strictEqual(mine.status, 200, JSON.stringify(mine.body));
assert.deepStrictEqual(mine.body.machines.map((m) => m.id), [anaMachine]);
assert.strictEqual(mine.body.machines[0].kind, 'onboard');
++ assert.strictEqual(mine.body.machines[0].system, 'onboard');
++ assert.deepStrictEqual(mine.body.machines[0].can_run, []);
assert.deepStrictEqual(mine.body.machines[0].roles, []);
assert.deepStrictEqual(mine.body.machines[0].running, []);
const last = calls[calls.length - 1];
ddiff --git a/test/my-machines.test.js b/test/my-machines.test.js
iindex 11fd0175..dda28d1f 100644
---- a/test/my-machines.test.js
++++ b/test/my-machines.test.js
@@@ -42,7 +42,7 @@ function page({ answers }) {
let timers = [];
const sandbox = {
document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelectorAll: (sel) => (sel === '[data-machine]' ? ids['mc-list'].all((n) => n.getAttribute('data-machine')) : []) },
-- console, Date, Number, String, Set, Math, JSON, Promise,
++ console, Date, Number, String, Set, Math, JSON, Promise, URLSearchParams, location: { search: '' },
navigator: { clipboard: { writeText: async () => {} } },
setInterval: (fn) => { timers.push(fn); return timers.length; }, clearInterval: () => { timers = []; },
apiFetch: async (p, o = {}) => { calls.push([o.method || 'GET', p, o.body ? JSON.parse(o.body) : null]); const a = answers(o.method || 'GET', p, o.body ? JSON.parse(o.body) : null); if (a instanceof Error) throw a; return a; },