openvibers/openvibe.run Commits

Persist and enforce browser session origin lists (#254)

OP OpenVibers committed 2 days ago · 4150651758 · 7 files, +60 −6

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 168a058..4aa54da 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,10 @@ Notable changes to OpenVibe.Run. The service is unreleased; everything below is
### Added
++- With `RUN_SESSION_ORIGINS_ENABLED=1`, a browser session may name exact HTTP(S) origins at creation. Run stores the
++ list with the session, places it on a user-owned desk that can enforce the boundary, and sends the same list when
++ leasing that desk. Commands cannot replace the list. The switch is off by default.
++
- One Computer (plan 2026-11-07, contracts 0.327.0): `run.session@1` carries `task_id` (the OpenVibe.Actor task the
session is the Computer of, so the session points back at the task) and `curtain` (the privacy screen).
`GET /api/v1/sessions/{id}/still` answers the desk's newest frame as a small JPEG (at most 640 px wide, cached for a
ddiff --git a/migrations/0019_session_task_origins.sql b/migrations/0019_session_task_origins.sql
nnew file mode 100644
iindex 0000000..31c36a3
---- /dev/null
++++ b/migrations/0019_session_task_origins.sql
@@@ -0,0 +1,3 @@
++-- phase: expand
++-- Null keeps older sessions unrestricted; an empty array permits no browser origin.
++ALTER TABLE run_sessions ADD COLUMN IF NOT EXISTS task_origins JSONB;
ddiff --git a/server/config.js b/server/config.js
iindex e3b6b72..1a8a3a4 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -118,6 +118,7 @@ function loadConfig(env = process.env) {
// Run sessions (contracts 0.223.0, server/sessions/): one pass over the open ones every tickMs; a person holds at
// most maxOpen at once; a running one's lease is checked on Bot every leaseCheckMs.
sessions: {
++ originsEnabled: env.RUN_SESSION_ORIGINS_ENABLED === '1',
tickMs: Math.max(200, int(env.RUN_SESSION_TICK_MS, 2000)),
maxOpen: Math.max(1, int(env.RUN_SESSIONS_PER_PERSON, 3)),
leaseCheckMs: Math.max(1000, int(env.RUN_SESSION_LEASE_CHECK_MS, 30000)),
ddiff --git a/server/sessions/origins.js b/server/sessions/origins.js
nnew file mode 100644
iindex 0000000..3826fe3
---- /dev/null
++++ b/server/sessions/origins.js
@@@ -0,0 +1,16 @@
++'use strict';
++
++function parseOrigins(raw) {
++ if (raw === undefined) return null;
++ if (!Array.isArray(raw) || raw.length > 32) throw new Error('origins is a list of at most 32 http(s) origins');
++ return raw.map((item) => {
++ if (typeof item !== 'string' || item.length > 2048 || /[\s\u0000-\u001f]/.test(item)) throw new Error('invalid origin');
++ let u;
++ try { u = new URL(item); } catch { throw new Error('invalid origin'); }
++ if (!['http:', 'https:'].includes(u.protocol) || !u.hostname || u.username || u.password ||
++ u.pathname !== '/' || u.search || u.hash || /[^a-z0-9.:[\]-]/i.test(u.hostname)) throw new Error('invalid origin');
++ return u.origin;
++ });
++}
++
++module.exports = { parseOrigins };
ddiff --git a/server/sessions/service.js b/server/sessions/service.js
iindex 64196b3..4545943 100644
---- a/server/sessions/service.js
++++ b/server/sessions/service.js
@@@ -24,6 +24,7 @@
*/
const contracts = require('openvibe-contracts');
const store = require('./store');
++const { parseOrigins } = require('./origins');
const curtain = require('./curtain');
const usage = require('../jobs/usage');
const { TABLE: EVENTS_TABLE, ACTOR } = require('../events/outbox');
@@@ -103,7 +104,12 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
if (strategy && !require('openvibe-sdk/compute').STRATEGIES.includes(strategy)) throw new SessionError(422, 'run.compute.strategy', 'unknown compute strategy');
if (strategy && !pool) throw new SessionError(422, 'run.compute.pool', 'a strategy requires a compute pool');
if (pool) { if (!broker) throw new SessionError(503, 'run.compute.off'); await broker.poolConfig(pool); }
-- const v = contracts.validate('run.session-create-request@1', body);
++ if (Object.hasOwn(body, 'origins') && !cfg.originsEnabled) throw new SessionError(403, 'run.session.origins_disabled', 'Task origin boundaries are not enabled here yet.');
++ let taskOrigins;
++ try { taskOrigins = parseOrigins(body.origins); }
++ catch (e) { throw new SessionError(422, 'run.session.invalid', e.message); }
++ const { origins: _boundary, ...contractBody } = body;
++ const v = contracts.validate('run.session-create-request@1', contractBody);
if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid session request: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));
const digest = store.digestOf({ ...body, compute_pool: pool, compute_strategy: strategy, template, actor_id: actorId });
if (body.idempotency_key) {
@@@ -120,7 +126,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
id, owner, via: via || null, kind: 'browser', state: 'placing',
width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,
compute_pool: pool, compute_strategy: strategy, compute_decision: null, template, actor_id: actorId, machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,
-- request_digest: digest, reasons: [], task_id: body.task_id || null, curtain: false,
++ request_digest: digest, reasons: [], task_id: body.task_id || null, curtain: false, task_origins: taskOrigins,
control_lease: { thing: `run.session:${id}`, holder: { kind: 'person', subject: bare(owner) }, since_ms: at, expires_ms: at + 60_000, watchers: [], seq: 1 },
created_at: at, updated_at: at,
};
@@@ -238,6 +244,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
const v = contracts.validate('run.session-control-request@1', cmd);
if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid command: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));
if (cmd.kind === 'browser' && ['active_origin', 'blind_fill'].includes(cmd.value && cmd.value.action)) throw new SessionError(403, 'run.fill.internal_action', 'this browser action is available only through the blind sign-in flow');
++ if (cmd.kind === 'browser' && Object.hasOwn(cmd.value, 'origins')) throw new SessionError(422, 'run.session.invalid', 'The task origins were fixed when the session was created.');
const id = cmd.id || `c-${now().toString(36)}`;
if (row.state !== 'running') throw new SessionError(409, 'run.session.not_running', row.state === 'ended' ? 'the session has ended' : 'the session\'s desk is not running yet');
const lease = await controlLease(row);
@@@ -449,6 +456,11 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
}
function candidates(row, hosts) {
const live = hosts.filter((h) => h && h.online && Number(h.free) > 0 && typeof h.machine_id === 'string');
++ if (row.task_origins !== null) {
++ if (row.requirements && Array.isArray(row.requirements.trust) && !row.requirements.trust.includes('user-owned')) return [];
++ return live.filter((h) => h.trust === 'user-owned' && !h.shared && (!row.machine_asked || h.machine_id === row.machine_asked))
++ .sort((a, b) => Number(b.free) - Number(a.free));
++ }
if (row.machine_asked) return live.filter((h) => h.machine_id === row.machine_asked && h.trust === 'user-owned');
const allowed = row.requirements && Array.isArray(row.requirements.trust) ? row.requirements.trust : DEFAULT_TRUST;
return live.filter((h) => allowed.includes(h.trust))
@@@ -481,7 +493,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
await store.update(db, row.id, { state: 'starting', machine: h.machine_id, desk, trust: h.trust, offer_id: h.shared ? `desk:${h.machine_id}` : null, reasons: [...reasonsOf(row, h), ...(decision ? [...decision.reasons, `estimated ${decision.hourly} ${decision.currency}/hour`] : [])], compute_decision: decision, updated_at: now() });
let lease;
try {
-- lease = await desks.lease(h.machine_id, bare(row.owner), { session: row.id, desk, kind: 'browser', width: row.width, height: row.height });
++ lease = await desks.lease(h.machine_id, bare(row.owner), { session: row.id, desk, kind: 'browser', width: row.width, height: row.height,
++ ...(row.task_origins !== null ? { origins: row.task_origins } : {}) });
} catch (e) {
await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });
hostCache.delete(bare(row.owner));
ddiff --git a/server/sessions/store.js b/server/sessions/store.js
iindex 566e949..715d961 100644
---- a/server/sessions/store.js
++++ b/server/sessions/store.js
@@@ -12,10 +12,10 @@
const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
--const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key',
++const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key', 'task_origins',
'compute_pool', 'compute_strategy', 'compute_decision', 'request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',
'end_detail', 'metered_minutes', 'lease_checked_at', 'task_id', 'curtain', 'control_lease', 'template', 'actor_id', 'egress_label', 'updated_at'];
--const JSON_COLUMNS = new Set(['requirements', 'reasons', 'compute_decision', 'control_lease']);
++const JSON_COLUMNS = new Set(['requirements', 'reasons', 'compute_decision', 'control_lease', 'task_origins']);
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
const json = (v, dflt) => { if (v == null) return dflt; if (typeof v === 'object') return v; try { return JSON.parse(v); } catch { return dflt; } };
ddiff --git a/test/sessions.test.js b/test/sessions.test.js
iindex 7b32bd6..bb26d3a 100644
---- a/test/sessions.test.js
++++ b/test/sessions.test.js
@@@ -11,6 +11,7 @@ const http = require('http');
const contracts = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
++const { loadConfig } = require('../server/config');
const OWN = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A';
const SHARED = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2B';
@@@ -51,8 +52,9 @@ function fakeDesks() {
}
(async () => {
++ assert.equal(loadConfig({}).sessions.originsEnabled, false, 'task origins are owner switched and off by default');
const desks = fakeDesks();
-- const t = await boot({ appOpts: { desks }, env: { RUN_SESSIONS_PER_PERSON: '3', RUN_SESSION_LEASE_CHECK_MS: '1000' } });
++ const t = await boot({ appOpts: { desks }, env: { RUN_SESSIONS_PER_PERSON: '3', RUN_SESSION_LEASE_CHECK_MS: '1000', RUN_SESSION_ORIGINS_ENABLED: '1' } });
const { sessions } = t.app.locals;
const ada = ids.newId('user');
const bea = ids.newId('user');
@@@ -81,6 +83,21 @@ function fakeDesks() {
assert.ok(started.some((p) => p.id === ses.id && p.state === 'running'), 'run.session.started');
});
++ await check('task origins are fixed at creation and use a user-owned desk', async () => {
++ const created = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser', origins: ['https://example.org'] } });
++ assert.equal(created.status, 201, created.text);
++ await sessions.tick();
++ const bounded = (await t.call('GET', `/api/v1/sessions/${created.json.id}`, as(bea))).json;
++ assert.equal(bounded.placement.machine, OWN, 'a shared desk cannot enforce origins');
++ const lease = desks.calls.filter((c) => c[0] === 'lease' && c[3].session === bounded.id).at(-1);
++ assert.deepStrictEqual(lease[3].origins, ['https://example.org']);
++ const changed = await t.call('POST', `/api/v1/sessions/${bounded.id}/commands`, { ...as(bea), body: { kind: 'browser', value: { action: 'read', origins: [] } } });
++ assert.equal(changed.status, 422, 'a later command cannot replace the boundary');
++ await sessions.finish(await sessions.get(bounded.id), 'ended', 'test complete');
++ const bad = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser', origins: ['https://example.org/path'] } });
++ assert.equal(bad.status, 422);
++ });
++
await check('browser and throwaway presets are accepted; kept profiles and snapshots say plainly when unavailable', async () => {
const unavailable = await t.call('POST', '/api/v1/sessions?template=kept-browser', { ...as(bea), body: { kind: 'browser' } });
assert.strictEqual(unavailable.status, 503);