openvibers/openvibe.network Commits
Link: safe machine picker return flow (#756)
Changes
@@@ -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();@@@ -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 };@@@ -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;@@@ -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 = {@@@ -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];@@@ -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; },
@@ -151,6 +151,14 @@ async function mcPair() {
@@ -151,6 +151,14 @@ async function mcPair() {
const fresh = ((list && list.machines) || []).find((m) => !before.has(m.id));
const fresh = ((list && list.machines) || []).find((m) => !before.has(m.id));
if (!fresh) return;
if (!fresh) return;
clearInterval(mcWatch);
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;
box.hidden = true;
mcSay(`${fresh.name || 'Your machine'} is paired. Choose what it does for you below.`);
mcSay(`${fresh.name || 'Your machine'} is paired. Choose what it does for you below.`);
loadMachines();
loadMachines();
@@ -22,6 +22,18 @@ function hostOf(req) {
@@ -22,6 +22,18 @@ function hostOf(req) {
return String((req.headers && req.headers.host) || '').split(':')[0].toLowerCase();
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. */
/** Link's llms.txt: what the page is, how to install it, and where the docs are. */
function llmsTxt() {
function llmsTxt() {
return [
return [
@@ -81,6 +93,20 @@ function createLinkRoutes({ releaseId, releaseApi = release } = {}) {
@@ -81,6 +93,20 @@ function createLinkRoutes({ releaseId, releaseApi = release } = {}) {
return res.set('Cache-Control', 'public, max-age=600').redirect(302, target);
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
// 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.
// session and machine API, and keeps the existing roles and pairing controls.
if (path === '/machines') {
if (path === '/machines') {
@@ -155,4 +181,4 @@ function pathResolveLocker() {
@@ -155,4 +181,4 @@ function pathResolveLocker() {
return pathLib.join(pathLib.dirname(require.resolve('openvibe-sdk')), 'browser', 'openvibe-sdk.mjs');
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 };
@@ -96,6 +96,10 @@ function machineOf(m) {
@@ -96,6 +96,10 @@ function machineOf(m) {
};
};
if (typeof m.name === 'string' && m.name) out.name = m.name;
if (typeof m.name === 'string' && m.name) out.name = m.name;
if (typeof m.os === 'string' && m.os) out.os = m.os;
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.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_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;
if (typeof m.node_id === 'string' && PRINCIPAL.test(m.node_id)) out.principal_id = m.node_id;
@@ -12,7 +12,10 @@ const express = require('express');
@@ -12,7 +12,10 @@ const express = require('express');
const page = require('../server/link/page');
const page = require('../server/link/page');
const release = require('../server/link/release');
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 ──
// ── The release answer: cached for ten minutes, with a fallback when GitHub cannot be reached ──
const FIXTURE = {
const FIXTURE = {
@@ -127,11 +127,15 @@ const runToken = (capability, person = ana.subject_id) => serviceAuth.signServic
@@ -127,11 +127,15 @@ const runToken = (capability, person = ana.subject_id) => serviceAuth.signServic
}, keys.privateKey);
}, keys.privateKey);
try {
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. ──
// ── A person sees their own machines, and the hub calls Bot as them. ──
const mine = await call('GET', '/api/v1/machines', { user: ana });
const mine = await call('GET', '/api/v1/machines', { user: ana });
assert.strictEqual(mine.status, 200, JSON.stringify(mine.body));
assert.strictEqual(mine.status, 200, JSON.stringify(mine.body));
assert.deepStrictEqual(mine.body.machines.map((m) => m.id), [anaMachine]);
assert.deepStrictEqual(mine.body.machines.map((m) => m.id), [anaMachine]);
assert.strictEqual(mine.body.machines[0].kind, 'onboard');
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].roles, []);
assert.deepStrictEqual(mine.body.machines[0].running, []);
assert.deepStrictEqual(mine.body.machines[0].running, []);
const last = calls[calls.length - 1];
const last = calls[calls.length - 1];
@@ -42,7 +42,7 @@ function page({ answers }) {
@@ -42,7 +42,7 @@ function page({ answers }) {
let timers = [];
let timers = [];
const sandbox = {
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')) : []) },
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 () => {} } },
navigator: { clipboard: { writeText: async () => {} } },
setInterval: (fn) => { timers.push(fn); return timers.length; }, clearInterval: () => { timers = []; },
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; },
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; },