openvibers/openvibe.bot Commits
Bind browser tasks to origins and cap page content (#397)
Changes
nnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++-- The Run session's allowed browser origins are fixed when its desk is leased.++-- Null is an older unrestricted lease; an empty array permits no network origin.++ALTER TABLE desk_leases ADD COLUMN IF NOT EXISTS task_origins JSONB;@@@ -1,4 +1,5 @@'use strict';++const { parseOrigins } = require('../browser-origins');/*** Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, capability bot.desk.session; the Desks plan's D6). Run,* with a service token naming the session's person in X-OV-Subject, leases one named browser desk on a machine:@@@ -118,20 +119,25 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>r.post('/machines/:id/desk-leases', limit, express.json({ limit: '8kb' }), wrap(async (req, res) => {const subject = runFor(req);const body = req.body && typeof req.body === 'object' ? req.body : {};-- const check = validate('bot.desk-lease-create-request@1', body);++ let origins;++ try { origins = parseOrigins(body.origins); } catch (e) { fail(422, 'bot.invalid_input', e.message); }++ const { origins: _boundary, ...leaseBody } = body;++ const check = validate('bot.desk-lease-create-request@1', leaseBody);if (!check.valid) fail(422, 'bot.invalid_input', `invalid lease: ${(check.errors && check.errors[0] && check.errors[0].message) || 'bad value'}`.slice(0, 300));const u = await machineFor(req, subject);const owner = `user:${subject}`;const mine = await domain.deskLeases.forSession(body.session);if (mine) {-- if (mine.machine_id === u.device.id && mine.desk === body.desk && mine.owner === owner) return res.status(200).json(mine);++ const oldOrigins = await domain.deskLeases.origins(mine.machine_id, mine.desk);++ if (mine.machine_id === u.device.id && mine.desk === body.desk && mine.owner === owner && JSON.stringify(oldOrigins) === JSON.stringify(origins)) return res.status(200).json(mine);fail(409, 'bot.lease_conflict', 'that session holds another desk');}++ if (u.shared && origins !== null) fail(409, 'bot.origin_boundary_unavailable', 'this shared desk cannot enforce a task origin list');if (!(hub.isOnline && hub.isOnline(u.device.id))) fail(409, 'bot.device_offline', 'the machine is offline');if ((await room(u.device, u.offered)).free < 1) fail(409, 'bot.machine_busy', 'the machine has no room for another desk right now');const width = Number.isInteger(body.width) ? body.width : 1280;const height = Number.isInteger(body.height) ? body.height : 800;-- const lease = await domain.deskLeases.create({ deviceId: u.device.id, desk: body.desk, session: body.session, owner, width, height, shared: u.shared });++ const lease = await domain.deskLeases.create({ deviceId: u.device.id, desk: body.desk, session: body.session, owner, width, height, shared: u.shared, origins });if (!lease) fail(409, 'bot.desk_busy', 'that desk is in use');// The desk starts now; a machine that refuses it gives the lease back at once.let result;@@@ -162,8 +168,10 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>const value = req.body && req.body.value && typeof req.body.value === 'object' && !Array.isArray(req.body.value) ? req.body.value : null;if (!ACTS.has(kind) || !value) fail(422, 'bot.invalid_input', 'kind is screen, input or browser, with a value');if ('desk' in value) fail(422, 'bot.invalid_input', 'a session acts on its own desk only: leave out desk');++ if ('origins' in value) fail(422, 'bot.invalid_input', 'a session uses the origin list fixed when its desk was leased');++ const origins = kind === 'browser' ? await domain.deskLeases.origins(device.id, lease.desk) : null;const result = await hub.machineCommand(device.id, principalFor(req, subject), subject, {-- id: req.body.id, kind, value: { ...value, desk: lease.desk }, ms: req.body.ms,++ id: req.body.id, kind, value: { ...value, desk: lease.desk, ...(origins !== null ? { origins } : {}) }, ms: req.body.ms,});res.json({ machine_id: device.id, desk: lease.desk, ...result });}));nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++++const { randomBytes } = require('crypto');++const MAX_PAGE = 48 * 1024;++const MAX_TEXT = 20 * 1024;++const FENCED = /^<<<ov-text nonce:([0-9a-f]{32}) origin:[^\n]*>>>\n([\s\S]*)\n<<<ov-text-end nonce:\1>>>$/;++++function cap(value, bytes) {++ const text = typeof value === 'string' ? value : '';++ const raw = Buffer.from(text);++ if (raw.length <= bytes) return [text, false];++ const cut = raw.subarray(0, bytes).toString('utf8').replace(/\uFFFD$/, '');++ return [`${cut}\n…[truncated ${raw.length - bytes} bytes]`, true];++}++++function plain(value) {++ const m = typeof value === 'string' ? FENCED.exec(value) : null;++ return m ? m[2] : value;++}++++function originOf(url) {++ try {++ const u = new URL(url);++ return ['http:', 'https:'].includes(u.protocol) && /^[a-z0-9.:[\]-]+$/i.test(u.hostname) ? u.origin : 'unknown';++ } catch { return 'unknown'; }++}++++function boundPage(data) {++ if (!data || typeof data !== 'object' || Array.isArray(data)) return data;++ const nonce = randomBytes(16).toString('hex');++ const origin = originOf(data.url);++ const fence = (value) => `<<<ov-text nonce:${nonce} origin:${origin}>>>\n${value}\n<<<ov-text-end nonce:${nonce}>>>`;++ if (typeof data.text !== 'string') {++ if (!data.receipt || typeof data.receipt !== 'object') return data;++ const appeared = Array.isArray(data.receipt.appeared) ? data.receipt.appeared : [];++ return { done: data.done === true, truncated: appeared.length > 8,++ receipt: { url_changed: data.receipt.url_changed === true,++ appeared: appeared.slice(0, 8).map((line) => fence(cap(plain(line), 160)[0])),++ gone: Number.isInteger(data.receipt.gone) ? Math.max(0, data.receipt.gone) : 0,++ ...(typeof data.receipt.page === 'string' ? { page: cap(data.receipt.page, 32)[0] } : {}) } };++ }++ const [body, bodyCut] = cap(plain(data.text), MAX_TEXT);++ const [title, titleCut] = cap(data.title, 512);++ const [url, urlCut] = cap(data.url, 2048);++ const out = { url, title, text: fence(body), fence: nonce, origin, links: [], elements: [],++ truncated: Boolean(data.truncated || bodyCut || titleCut || urlCut) };++ for (const item of Array.isArray(data.links) ? data.links : []) {++ if (!item || typeof item !== 'object') { out.truncated = true; continue; }++ const [label, labelCut] = cap(item.text, 160);++ const [href, hrefCut] = cap(item.href, 2048);++ out.links.push({ text: label, href });++ out.truncated ||= labelCut || hrefCut;++ if (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE) { out.links.pop(); out.truncated = true; break; }++ }++ for (const item of Array.isArray(data.elements) ? data.elements : []) {++ const [line, lineCut] = cap(item, 256);++ out.elements.push(line);++ out.truncated ||= lineCut;++ if (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE) { out.elements.pop(); out.truncated = true; break; }++ }++ if (data.receipt && typeof data.receipt === 'object' && !Array.isArray(data.receipt)) {++ const appeared = Array.isArray(data.receipt.appeared) ? data.receipt.appeared : [];++ out.receipt = { url_changed: data.receipt.url_changed === true,++ appeared: appeared.slice(0, 8).map((line) => fence(cap(plain(line), 160)[0])),++ gone: Number.isInteger(data.receipt.gone) ? Math.max(0, data.receipt.gone) : 0,++ ...(typeof data.receipt.page === 'string' ? { page: cap(data.receipt.page, 32)[0] } : {}) };++ out.truncated ||= appeared.length > 8;++ }++ // Body text is the last adjustable field; preserve both markers if a verbose old Link exceeded the cap.++ while (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE && out.elements.length) { out.elements.pop(); out.truncated = true; }++ while (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE && out.links.length) { out.links.pop(); out.truncated = true; }++ if (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE) {++ const excess = Buffer.byteLength(JSON.stringify(out)) - MAX_PAGE;++ out.text = fence(cap(body, Math.max(0, Buffer.byteLength(body) - excess - 256))[0]);++ out.truncated = true;++ }++ return out;++}++++module.exports = { boundPage, MAX_PAGE };nnew file mode 100644@@@ -0,0 +1,18 @@++'use strict';++++// Null means the caller did not name a boundary (old clients). An explicit++// list is exact scheme + host + port; no paths, credentials or wildcards.++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 };@@@ -923,14 +923,18 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =const deskLeases = {async get(deviceId, desk) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE device_id = $1 AND desk = $2', [deviceId, desk])); },async forSession(sessionId) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE session_id = $1', [sessionId])); },++ async origins(deviceId, desk) {++ const row = await db.maybe('SELECT task_origins FROM desk_leases WHERE device_id = $1 AND desk = $2', [deviceId, desk]);++ return row && row.task_origins != null ? (typeof row.task_origins === 'string' ? JSON.parse(row.task_origins) : row.task_origins) : null;++ },async count(deviceId) { return Number((await db.one('SELECT count(*)::int AS n FROM desk_leases WHERE device_id = $1', [deviceId])).n); },async list(deviceId) { return (await db.many('SELECT * FROM desk_leases WHERE device_id = $1 ORDER BY created_at', [deviceId])).map(leaseOut); },/** The lease, or null when the desk is someone else's already (the caller answers 409). */-- async create({ deviceId, desk, session, owner, width, height, shared }) {++ async create({ deviceId, desk, session, owner, width, height, shared, origins = null }) {const r = await db.maybe(-- `INSERT INTO desk_leases (device_id, desk, session_id, owner_subject, kind, width, height, shared, created_at)-- VALUES ($1, $2, $3, $4, 'browser', $5, $6, $7, $8) ON CONFLICT DO NOTHING RETURNING *`,-- [deviceId, desk, session, owner, width, height, !!shared, iso(now())]);++ `INSERT INTO desk_leases (device_id, desk, session_id, owner_subject, kind, width, height, shared, task_origins, created_at)++ VALUES ($1, $2, $3, $4, 'browser', $5, $6, $7, $8, $9) ON CONFLICT DO NOTHING RETURNING *`,++ [deviceId, desk, session, owner, width, height, !!shared, origins === null ? null : JSON.stringify(origins), iso(now())]);return leaseOut(r);},async remove(deviceId, desk) { return leaseOut(await db.maybe('DELETE FROM desk_leases WHERE device_id = $1 AND desk = $2 RETURNING *', [deviceId, desk])); },@@@ -48,6 +48,8 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;// 0.214.0, Link v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live// screen) act there with `desk: private`, never on the owner's own screen.const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk', ai: 'ai' };++const { parseOrigins } = require('./browser-origins');++const { boundPage } = require('./browser-content');const NO_DESK = 'this machine has no private desk: it needs OpenVibe Link v0.15.0 or newer on Linux, with Xvfb and allow_desk on';const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Link v0.19.0 or newer';const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Link v0.26.0 or newer (an older one would run it on the person\'s own screen)';@@@ -518,7 +520,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat// no data back), so 4096 cached results never hold 4096 screens.cacheResult(p.key, { result, reason: msg.fault_code || null });await domain.audit.record({ robotId: p.robotId, deviceId: p.deviceId, subject: p.subject, operatorKind: p.operatorKind, role: p.role, kind: p.kind, value: p.value, result, reason: msg.fault_code || null, latencyMs: latency });-- const data = p.computer && result === 'ack' && msg.data && typeof msg.data === 'object' && !Array.isArray(msg.data) ? msg.data : null;++ const rawData = p.computer && result === 'ack' && msg.data && typeof msg.data === 'object' && !Array.isArray(msg.data) ? msg.data : null;++ const data = rawData && p.kind === 'browser' ? boundPage(rawData) : rawData;const message = p.computer && result === 'nack' && typeof msg.message === 'string' ? msg.message.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 300) : null;sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: message || msg.fault_code || null, latency_ms: latency, ...(data ? { data } : {}) });}@@@ -806,15 +809,23 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat};const screen = msg.screen === true && kind === SCREEN_KIND;if (!screen && !COMPUTER_CAP[kind]) return refuse('bot.invalid_input', `kind is ${COMPUTER_KINDS.join(', ')}`);++ if (kind === 'browser' && Object.hasOwn(value, 'origins')) {++ try { parseOrigins(value.origins); } catch (e) { return refuse('bot.invalid_input', e.message); }++ }const profileAction = kind === 'browser' && ['profile_export', 'profile_import', 'profile_sign_out'].includes(value.action);if (profileAction) {const lease = /^desk-[a-z0-9][a-z0-9-]{0,23}$/.test(value.desk || '') ? await domain.deskLeases.get(deviceId, value.desk) : null;if (conn.principal.kind !== 'service' || conn.principal.sub !== 'svc:run' || !lease || lease.owner !== `user:${subject}` || !capabilities.grants(conn.principal.cap, 'bot.desk.session')) return refuse('bot.forbidden', 'Saved sign-ins belong to Run and its leased desk.');-- const keys = value.action === 'profile_import' ? ['action', 'desk', 'profile'] : value.action === 'profile_sign_out' ? ['action', 'desk', 'site'] : ['action', 'desk'];++ const keys = value.action === 'profile_import' ? ['action', 'desk', 'profile', 'origins'] : value.action === 'profile_sign_out' ? ['action', 'desk', 'site', 'origins'] : ['action', 'desk', 'origins'];if (Object.keys(value).some(k => !keys.includes(k)) || JSON.stringify(value).length > 48000 || (value.action === 'profile_import' && (!value.profile || !Array.isArray(value.profile.cookies) || !Array.isArray(value.profile.origins))) || (value.action === 'profile_sign_out' && !/^[a-z0-9.-]{1,253}$/.test(value.site || ''))) return refuse('bot.invalid_input', 'Invalid saved sign-ins.');}if (!screen && !profileAction) {-- const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value });++ let checkedValue = value;++ if (kind === 'browser' && Object.hasOwn(value, 'origins')) {++ const { origins: _boundary, ...rest } = value;++ checkedValue = rest; // the released contract predates the additive task boundary++ }++ const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value: checkedValue });if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));}const summary = screen@@@ -87,6 +87,8 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(bad.status, 422, 'never main or private');const notShared = await t.call('POST', `/api/v1/machines/${plain.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(4) } });assert.strictEqual(notShared.status, 404, 'a host that does not sandbox its desks is never leased to someone else');++ const bounded = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(9), desk: 'desk-s09', origins: ['https://example.org'] } });++ assert.deepStrictEqual([bounded.status, bounded.json.code], [409, 'bot.origin_boundary_unavailable'], 'a shared sandbox cannot silently skip the origin boundary');});await check('commands reach the leased desk alone, for its person alone', async () => {@@@ -138,8 +140,18 @@ const { boot, check, done } = require('./helpers/app');await t.domain.machines.setSharedDesks(host.deviceId, false);const r = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(7), desk: 'desk-s07', kind: 'browser' } });assert.strictEqual(r.status, 404);-- const own2 = await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(8), desk: 'desk-s08', kind: 'browser' } });++ const body = { session: SES(8), desk: 'desk-s08', kind: 'browser', origins: ['https://example.org'] };++ const own2 = await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body });assert.deepStrictEqual([own2.status, own2.json.shared], [201, false], 'a person\'s own machine, where agents may work');++ assert.equal((await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, origins: [] } })).status, 409, 'a retry cannot widen or change the boundary');++ const url = `/api/v1/machines/${own.deviceId}/desk-leases/desk-s08/commands`;++ const action = await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });++ assert.equal(action.status, 200, action.text);++ assert.deepStrictEqual(seen.at(-1).value.origins, ['https://example.org'], 'Bot attaches the stored list to every browser command');++ const profile = await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'profile_export' } } });++ assert.equal(profile.status, 200, profile.text);++ assert.deepStrictEqual(seen.at(-1).value.origins, ['https://example.org']);++ assert.equal((await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'read', origins: [] } } })).status, 422, 'the caller cannot change the list per command');});for (const m of [host, plain, own]) m.c.close();@@@ -7,6 +7,7 @@const assert = require('assert');const { boot, check, done } = require('./helpers/app');const { withDelivery } = require('../server/command-delivery');++const { boundPage, MAX_PAGE } = require('../server/browser-content');(async () => {await check('a timeout or in-flight repeat never claims a safe retry', async () => {@@@ -95,6 +96,8 @@ const { withDelivery } = require('../server/command-delivery');await check('the browser kind reaches the machine; a file: address is refused before it leaves', async () => {const open = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'open', url: 'https://example.org/account?token=abc' } }, { user: alex });assert.deepStrictEqual([open.status, open.json.result, open.json.data.title], [200, 'ack', 'Example']);++ assert.match(open.json.data.text, /^<<<ov-text nonce:[0-9a-f]{32} origin:https:\/\/example.org>>>\nExample page\n<<<ov-text-end nonce:[0-9a-f]{32}>>>$/);++ assert.equal(open.json.data.origin, 'https://example.org');const fill = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'fill', selector: '#password', text: 'hunter3 secret' } }, { user: alex });assert.strictEqual(fill.json.result, 'ack');assert.deepStrictEqual(seen.filter((m) => m.kind === 'browser').map((m) => m.value.action), ['open', 'fill']);@@@ -104,6 +107,28 @@ const { withDelivery } = require('../server/command-delivery');assert.deepStrictEqual(rows.map((r) => r.value), [{ action: 'open', host: 'example.org' }, { action: 'fill', selector: '#password', chars: 14 }],'the audit keeps the host (never the path or query) and how much was typed (never the text)');assert.ok(!JSON.stringify(rows).includes('hunter3') && !JSON.stringify(rows).includes('token=abc'));++ const bounded = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'read', origins: ['https://example.org'] } }, { user: alex });++ assert.equal(bounded.status, 200, bounded.text);++ assert.deepStrictEqual(seen.at(-1).value.origins, ['https://example.org']);++ const invalidOrigins = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'read', origins: ['https://example.org/path'] } }, { user: alex });++ assert.deepStrictEqual([invalidOrigins.status, invalidOrigins.json.code], [422, 'bot.invalid_input']);++ });++++ await check('a verbose page is bounded before it reaches a caller', async () => {++ const out = boundPage({ url: 'https://example.org/', title: 'A', text: 'é'.repeat(100000),++ links: Array.from({ length: 60 }, () => ({ text: 'x', href: 'https://example.org/' + 'y'.repeat(5000) })),++ elements: Array(120).fill('e1 button ' + 'z'.repeat(500)),++ untrusted_extra: 'x'.repeat(200000), receipt: { appeared: ['a'.repeat(200000)], extra: 'y'.repeat(200000) } });++ assert.equal(out.truncated, true);++ assert.ok(Buffer.byteLength(JSON.stringify(out)) <= MAX_PAGE);++ assert.equal(out.untrusted_extra, undefined);++ assert.equal(out.receipt.extra, undefined);++ assert.match(out.text, /<<<ov-text-end nonce:[0-9a-f]{32}>>>$/);++ const receiptOnly = boundPage({ done: true, receipt: { appeared: ['a'.repeat(200000)], gone: 1, extra: 'x'.repeat(200000) } });++ assert.ok(Buffer.byteLength(JSON.stringify(receiptOnly)) <= MAX_PAGE);++ assert.equal(receiptOnly.receipt.gone, 1);++ assert.equal(receiptOnly.receipt.extra, undefined);++ assert.match(receiptOnly.receipt.appeared[0], /<<<ov-text-end nonce:[0-9a-f]{32}>>>$/);});await check('the private desk (contracts 0.214.0): start, stop and status reach a machine that has one, and desk: private acts there; one without says so', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+-- The Run session's allowed browser origins are fixed when its desk is leased.
+-- Null is an older unrestricted lease; an empty array permits no network origin.
+ALTER TABLE desk_leases ADD COLUMN IF NOT EXISTS task_origins JSONB;
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const { parseOrigins } = require('../browser-origins');
/**
/**
* Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, capability bot.desk.session; the Desks plan's D6). Run,
* Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, capability bot.desk.session; the Desks plan's D6). Run,
* with a service token naming the session's person in X-OV-Subject, leases one named browser desk on a machine:
* with a service token naming the session's person in X-OV-Subject, leases one named browser desk on a machine:
@@ -118,20 +119,25 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>
@@ -118,20 +119,25 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>
r.post('/machines/:id/desk-leases', limit, express.json({ limit: '8kb' }), wrap(async (req, res) => {
r.post('/machines/:id/desk-leases', limit, express.json({ limit: '8kb' }), wrap(async (req, res) => {
const subject = runFor(req);
const subject = runFor(req);
const body = req.body && typeof req.body === 'object' ? req.body : {};
const body = req.body && typeof req.body === 'object' ? req.body : {};
- const check = validate('bot.desk-lease-create-request@1', body);
+ let origins;
+ try { origins = parseOrigins(body.origins); } catch (e) { fail(422, 'bot.invalid_input', e.message); }
+ const { origins: _boundary, ...leaseBody } = body;
+ const check = validate('bot.desk-lease-create-request@1', leaseBody);
if (!check.valid) fail(422, 'bot.invalid_input', `invalid lease: ${(check.errors && check.errors[0] && check.errors[0].message) || 'bad value'}`.slice(0, 300));
if (!check.valid) fail(422, 'bot.invalid_input', `invalid lease: ${(check.errors && check.errors[0] && check.errors[0].message) || 'bad value'}`.slice(0, 300));
const u = await machineFor(req, subject);
const u = await machineFor(req, subject);
const owner = `user:${subject}`;
const owner = `user:${subject}`;
const mine = await domain.deskLeases.forSession(body.session);
const mine = await domain.deskLeases.forSession(body.session);
if (mine) {
if (mine) {
- if (mine.machine_id === u.device.id && mine.desk === body.desk && mine.owner === owner) return res.status(200).json(mine);
+ const oldOrigins = await domain.deskLeases.origins(mine.machine_id, mine.desk);
+ if (mine.machine_id === u.device.id && mine.desk === body.desk && mine.owner === owner && JSON.stringify(oldOrigins) === JSON.stringify(origins)) return res.status(200).json(mine);
fail(409, 'bot.lease_conflict', 'that session holds another desk');
fail(409, 'bot.lease_conflict', 'that session holds another desk');
}
}
+ if (u.shared && origins !== null) fail(409, 'bot.origin_boundary_unavailable', 'this shared desk cannot enforce a task origin list');
if (!(hub.isOnline && hub.isOnline(u.device.id))) fail(409, 'bot.device_offline', 'the machine is offline');
if (!(hub.isOnline && hub.isOnline(u.device.id))) fail(409, 'bot.device_offline', 'the machine is offline');
if ((await room(u.device, u.offered)).free < 1) fail(409, 'bot.machine_busy', 'the machine has no room for another desk right now');
if ((await room(u.device, u.offered)).free < 1) fail(409, 'bot.machine_busy', 'the machine has no room for another desk right now');
const width = Number.isInteger(body.width) ? body.width : 1280;
const width = Number.isInteger(body.width) ? body.width : 1280;
const height = Number.isInteger(body.height) ? body.height : 800;
const height = Number.isInteger(body.height) ? body.height : 800;
- const lease = await domain.deskLeases.create({ deviceId: u.device.id, desk: body.desk, session: body.session, owner, width, height, shared: u.shared });
+ const lease = await domain.deskLeases.create({ deviceId: u.device.id, desk: body.desk, session: body.session, owner, width, height, shared: u.shared, origins });
if (!lease) fail(409, 'bot.desk_busy', 'that desk is in use');
if (!lease) fail(409, 'bot.desk_busy', 'that desk is in use');
// The desk starts now; a machine that refuses it gives the lease back at once.
// The desk starts now; a machine that refuses it gives the lease back at once.
let result;
let result;
@@ -162,8 +168,10 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>
@@ -162,8 +168,10 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>
const value = req.body && req.body.value && typeof req.body.value === 'object' && !Array.isArray(req.body.value) ? req.body.value : null;
const value = req.body && req.body.value && typeof req.body.value === 'object' && !Array.isArray(req.body.value) ? req.body.value : null;
if (!ACTS.has(kind) || !value) fail(422, 'bot.invalid_input', 'kind is screen, input or browser, with a value');
if (!ACTS.has(kind) || !value) fail(422, 'bot.invalid_input', 'kind is screen, input or browser, with a value');
if ('desk' in value) fail(422, 'bot.invalid_input', 'a session acts on its own desk only: leave out desk');
if ('desk' in value) fail(422, 'bot.invalid_input', 'a session acts on its own desk only: leave out desk');
+ if ('origins' in value) fail(422, 'bot.invalid_input', 'a session uses the origin list fixed when its desk was leased');
+ const origins = kind === 'browser' ? await domain.deskLeases.origins(device.id, lease.desk) : null;
const result = await hub.machineCommand(device.id, principalFor(req, subject), subject, {
const result = await hub.machineCommand(device.id, principalFor(req, subject), subject, {
- id: req.body.id, kind, value: { ...value, desk: lease.desk }, ms: req.body.ms,
+ id: req.body.id, kind, value: { ...value, desk: lease.desk, ...(origins !== null ? { origins } : {}) }, ms: req.body.ms,
});
});
res.json({ machine_id: device.id, desk: lease.desk, ...result });
res.json({ machine_id: device.id, desk: lease.desk, ...result });
}));
}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+
+const { randomBytes } = require('crypto');
+const MAX_PAGE = 48 * 1024;
+const MAX_TEXT = 20 * 1024;
+const FENCED = /^<<<ov-text nonce:([0-9a-f]{32}) origin:[^\n]*>>>\n([\s\S]*)\n<<<ov-text-end nonce:\1>>>$/;
+
+function cap(value, bytes) {
+ const text = typeof value === 'string' ? value : '';
+ const raw = Buffer.from(text);
+ if (raw.length <= bytes) return [text, false];
+ const cut = raw.subarray(0, bytes).toString('utf8').replace(/\uFFFD$/, '');
+ return [`${cut}\n…[truncated ${raw.length - bytes} bytes]`, true];
+}
+
+function plain(value) {
+ const m = typeof value === 'string' ? FENCED.exec(value) : null;
+ return m ? m[2] : value;
+}
+
+function originOf(url) {
+ try {
+ const u = new URL(url);
+ return ['http:', 'https:'].includes(u.protocol) && /^[a-z0-9.:[\]-]+$/i.test(u.hostname) ? u.origin : 'unknown';
+ } catch { return 'unknown'; }
+}
+
+function boundPage(data) {
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
+ const nonce = randomBytes(16).toString('hex');
+ const origin = originOf(data.url);
+ const fence = (value) => `<<<ov-text nonce:${nonce} origin:${origin}>>>\n${value}\n<<<ov-text-end nonce:${nonce}>>>`;
+ if (typeof data.text !== 'string') {
+ if (!data.receipt || typeof data.receipt !== 'object') return data;
+ const appeared = Array.isArray(data.receipt.appeared) ? data.receipt.appeared : [];
+ return { done: data.done === true, truncated: appeared.length > 8,
+ receipt: { url_changed: data.receipt.url_changed === true,
+ appeared: appeared.slice(0, 8).map((line) => fence(cap(plain(line), 160)[0])),
+ gone: Number.isInteger(data.receipt.gone) ? Math.max(0, data.receipt.gone) : 0,
+ ...(typeof data.receipt.page === 'string' ? { page: cap(data.receipt.page, 32)[0] } : {}) } };
+ }
+ const [body, bodyCut] = cap(plain(data.text), MAX_TEXT);
+ const [title, titleCut] = cap(data.title, 512);
+ const [url, urlCut] = cap(data.url, 2048);
+ const out = { url, title, text: fence(body), fence: nonce, origin, links: [], elements: [],
+ truncated: Boolean(data.truncated || bodyCut || titleCut || urlCut) };
+ for (const item of Array.isArray(data.links) ? data.links : []) {
+ if (!item || typeof item !== 'object') { out.truncated = true; continue; }
+ const [label, labelCut] = cap(item.text, 160);
+ const [href, hrefCut] = cap(item.href, 2048);
+ out.links.push({ text: label, href });
+ out.truncated ||= labelCut || hrefCut;
+ if (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE) { out.links.pop(); out.truncated = true; break; }
+ }
+ for (const item of Array.isArray(data.elements) ? data.elements : []) {
+ const [line, lineCut] = cap(item, 256);
+ out.elements.push(line);
+ out.truncated ||= lineCut;
+ if (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE) { out.elements.pop(); out.truncated = true; break; }
+ }
+ if (data.receipt && typeof data.receipt === 'object' && !Array.isArray(data.receipt)) {
+ const appeared = Array.isArray(data.receipt.appeared) ? data.receipt.appeared : [];
+ out.receipt = { url_changed: data.receipt.url_changed === true,
+ appeared: appeared.slice(0, 8).map((line) => fence(cap(plain(line), 160)[0])),
+ gone: Number.isInteger(data.receipt.gone) ? Math.max(0, data.receipt.gone) : 0,
+ ...(typeof data.receipt.page === 'string' ? { page: cap(data.receipt.page, 32)[0] } : {}) };
+ out.truncated ||= appeared.length > 8;
+ }
+ // Body text is the last adjustable field; preserve both markers if a verbose old Link exceeded the cap.
+ while (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE && out.elements.length) { out.elements.pop(); out.truncated = true; }
+ while (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE && out.links.length) { out.links.pop(); out.truncated = true; }
+ if (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE) {
+ const excess = Buffer.byteLength(JSON.stringify(out)) - MAX_PAGE;
+ out.text = fence(cap(body, Math.max(0, Buffer.byteLength(body) - excess - 256))[0]);
+ out.truncated = true;
+ }
+ return out;
+}
+
+module.exports = { boundPage, MAX_PAGE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+'use strict';
+
+// Null means the caller did not name a boundary (old clients). An explicit
+// list is exact scheme + host + port; no paths, credentials or wildcards.
+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 };
@@ -923,14 +923,18 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -923,14 +923,18 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const deskLeases = {
const deskLeases = {
async get(deviceId, desk) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE device_id = $1 AND desk = $2', [deviceId, desk])); },
async get(deviceId, desk) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE device_id = $1 AND desk = $2', [deviceId, desk])); },
async forSession(sessionId) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE session_id = $1', [sessionId])); },
async forSession(sessionId) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE session_id = $1', [sessionId])); },
+ async origins(deviceId, desk) {
+ const row = await db.maybe('SELECT task_origins FROM desk_leases WHERE device_id = $1 AND desk = $2', [deviceId, desk]);
+ return row && row.task_origins != null ? (typeof row.task_origins === 'string' ? JSON.parse(row.task_origins) : row.task_origins) : null;
+ },
async count(deviceId) { return Number((await db.one('SELECT count(*)::int AS n FROM desk_leases WHERE device_id = $1', [deviceId])).n); },
async count(deviceId) { return Number((await db.one('SELECT count(*)::int AS n FROM desk_leases WHERE device_id = $1', [deviceId])).n); },
async list(deviceId) { return (await db.many('SELECT * FROM desk_leases WHERE device_id = $1 ORDER BY created_at', [deviceId])).map(leaseOut); },
async list(deviceId) { return (await db.many('SELECT * FROM desk_leases WHERE device_id = $1 ORDER BY created_at', [deviceId])).map(leaseOut); },
/** The lease, or null when the desk is someone else's already (the caller answers 409). */
/** The lease, or null when the desk is someone else's already (the caller answers 409). */
- async create({ deviceId, desk, session, owner, width, height, shared }) {
+ async create({ deviceId, desk, session, owner, width, height, shared, origins = null }) {
const r = await db.maybe(
const r = await db.maybe(
- `INSERT INTO desk_leases (device_id, desk, session_id, owner_subject, kind, width, height, shared, created_at)
- VALUES ($1, $2, $3, $4, 'browser', $5, $6, $7, $8) ON CONFLICT DO NOTHING RETURNING *`,
- [deviceId, desk, session, owner, width, height, !!shared, iso(now())]);
+ `INSERT INTO desk_leases (device_id, desk, session_id, owner_subject, kind, width, height, shared, task_origins, created_at)
+ VALUES ($1, $2, $3, $4, 'browser', $5, $6, $7, $8, $9) ON CONFLICT DO NOTHING RETURNING *`,
+ [deviceId, desk, session, owner, width, height, !!shared, origins === null ? null : JSON.stringify(origins), iso(now())]);
return leaseOut(r);
return leaseOut(r);
},
},
async remove(deviceId, desk) { return leaseOut(await db.maybe('DELETE FROM desk_leases WHERE device_id = $1 AND desk = $2 RETURNING *', [deviceId, desk])); },
async remove(deviceId, desk) { return leaseOut(await db.maybe('DELETE FROM desk_leases WHERE device_id = $1 AND desk = $2 RETURNING *', [deviceId, desk])); },
@@ -48,6 +48,8 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
@@ -48,6 +48,8 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
// 0.214.0, Link v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live
// 0.214.0, Link v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live
// screen) act there with `desk: private`, never on the owner's own screen.
// screen) act there with `desk: private`, never on the owner's own screen.
const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk', ai: 'ai' };
const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk', ai: 'ai' };
+const { parseOrigins } = require('./browser-origins');
+const { boundPage } = require('./browser-content');
const NO_DESK = 'this machine has no private desk: it needs OpenVibe Link v0.15.0 or newer on Linux, with Xvfb and allow_desk on';
const NO_DESK = 'this machine has no private desk: it needs OpenVibe Link v0.15.0 or newer on Linux, with Xvfb and allow_desk on';
const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Link v0.19.0 or newer';
const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Link v0.19.0 or newer';
const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Link v0.26.0 or newer (an older one would run it on the person\'s own screen)';
const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Link v0.26.0 or newer (an older one would run it on the person\'s own screen)';
@@ -518,7 +520,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -518,7 +520,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
// no data back), so 4096 cached results never hold 4096 screens.
// no data back), so 4096 cached results never hold 4096 screens.
cacheResult(p.key, { result, reason: msg.fault_code || null });
cacheResult(p.key, { result, reason: msg.fault_code || null });
await domain.audit.record({ robotId: p.robotId, deviceId: p.deviceId, subject: p.subject, operatorKind: p.operatorKind, role: p.role, kind: p.kind, value: p.value, result, reason: msg.fault_code || null, latencyMs: latency });
await domain.audit.record({ robotId: p.robotId, deviceId: p.deviceId, subject: p.subject, operatorKind: p.operatorKind, role: p.role, kind: p.kind, value: p.value, result, reason: msg.fault_code || null, latencyMs: latency });
- const data = p.computer && result === 'ack' && msg.data && typeof msg.data === 'object' && !Array.isArray(msg.data) ? msg.data : null;
+ const rawData = p.computer && result === 'ack' && msg.data && typeof msg.data === 'object' && !Array.isArray(msg.data) ? msg.data : null;
+ const data = rawData && p.kind === 'browser' ? boundPage(rawData) : rawData;
const message = p.computer && result === 'nack' && typeof msg.message === 'string' ? msg.message.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 300) : null;
const message = p.computer && result === 'nack' && typeof msg.message === 'string' ? msg.message.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 300) : null;
sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: message || msg.fault_code || null, latency_ms: latency, ...(data ? { data } : {}) });
sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: message || msg.fault_code || null, latency_ms: latency, ...(data ? { data } : {}) });
}
}
@@ -806,15 +809,23 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -806,15 +809,23 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
};
};
const screen = msg.screen === true && kind === SCREEN_KIND;
const screen = msg.screen === true && kind === SCREEN_KIND;
if (!screen && !COMPUTER_CAP[kind]) return refuse('bot.invalid_input', `kind is ${COMPUTER_KINDS.join(', ')}`);
if (!screen && !COMPUTER_CAP[kind]) return refuse('bot.invalid_input', `kind is ${COMPUTER_KINDS.join(', ')}`);
+ if (kind === 'browser' && Object.hasOwn(value, 'origins')) {
+ try { parseOrigins(value.origins); } catch (e) { return refuse('bot.invalid_input', e.message); }
+ }
const profileAction = kind === 'browser' && ['profile_export', 'profile_import', 'profile_sign_out'].includes(value.action);
const profileAction = kind === 'browser' && ['profile_export', 'profile_import', 'profile_sign_out'].includes(value.action);
if (profileAction) {
if (profileAction) {
const lease = /^desk-[a-z0-9][a-z0-9-]{0,23}$/.test(value.desk || '') ? await domain.deskLeases.get(deviceId, value.desk) : null;
const lease = /^desk-[a-z0-9][a-z0-9-]{0,23}$/.test(value.desk || '') ? await domain.deskLeases.get(deviceId, value.desk) : null;
if (conn.principal.kind !== 'service' || conn.principal.sub !== 'svc:run' || !lease || lease.owner !== `user:${subject}` || !capabilities.grants(conn.principal.cap, 'bot.desk.session')) return refuse('bot.forbidden', 'Saved sign-ins belong to Run and its leased desk.');
if (conn.principal.kind !== 'service' || conn.principal.sub !== 'svc:run' || !lease || lease.owner !== `user:${subject}` || !capabilities.grants(conn.principal.cap, 'bot.desk.session')) return refuse('bot.forbidden', 'Saved sign-ins belong to Run and its leased desk.');
- const keys = value.action === 'profile_import' ? ['action', 'desk', 'profile'] : value.action === 'profile_sign_out' ? ['action', 'desk', 'site'] : ['action', 'desk'];
+ const keys = value.action === 'profile_import' ? ['action', 'desk', 'profile', 'origins'] : value.action === 'profile_sign_out' ? ['action', 'desk', 'site', 'origins'] : ['action', 'desk', 'origins'];
if (Object.keys(value).some(k => !keys.includes(k)) || JSON.stringify(value).length > 48000 || (value.action === 'profile_import' && (!value.profile || !Array.isArray(value.profile.cookies) || !Array.isArray(value.profile.origins))) || (value.action === 'profile_sign_out' && !/^[a-z0-9.-]{1,253}$/.test(value.site || ''))) return refuse('bot.invalid_input', 'Invalid saved sign-ins.');
if (Object.keys(value).some(k => !keys.includes(k)) || JSON.stringify(value).length > 48000 || (value.action === 'profile_import' && (!value.profile || !Array.isArray(value.profile.cookies) || !Array.isArray(value.profile.origins))) || (value.action === 'profile_sign_out' && !/^[a-z0-9.-]{1,253}$/.test(value.site || ''))) return refuse('bot.invalid_input', 'Invalid saved sign-ins.');
}
}
if (!screen && !profileAction) {
if (!screen && !profileAction) {
- const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value });
+ let checkedValue = value;
+ if (kind === 'browser' && Object.hasOwn(value, 'origins')) {
+ const { origins: _boundary, ...rest } = value;
+ checkedValue = rest; // the released contract predates the additive task boundary
+ }
+ const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value: checkedValue });
if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));
if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));
}
}
const summary = screen
const summary = screen
@@ -87,6 +87,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -87,6 +87,8 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(bad.status, 422, 'never main or private');
assert.strictEqual(bad.status, 422, 'never main or private');
const notShared = await t.call('POST', `/api/v1/machines/${plain.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(4) } });
const notShared = await t.call('POST', `/api/v1/machines/${plain.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(4) } });
assert.strictEqual(notShared.status, 404, 'a host that does not sandbox its desks is never leased to someone else');
assert.strictEqual(notShared.status, 404, 'a host that does not sandbox its desks is never leased to someone else');
+ const bounded = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(9), desk: 'desk-s09', origins: ['https://example.org'] } });
+ assert.deepStrictEqual([bounded.status, bounded.json.code], [409, 'bot.origin_boundary_unavailable'], 'a shared sandbox cannot silently skip the origin boundary');
});
});
await check('commands reach the leased desk alone, for its person alone', async () => {
await check('commands reach the leased desk alone, for its person alone', async () => {
@@ -138,8 +140,18 @@ const { boot, check, done } = require('./helpers/app');
@@ -138,8 +140,18 @@ const { boot, check, done } = require('./helpers/app');
await t.domain.machines.setSharedDesks(host.deviceId, false);
await t.domain.machines.setSharedDesks(host.deviceId, false);
const r = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(7), desk: 'desk-s07', kind: 'browser' } });
const r = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(7), desk: 'desk-s07', kind: 'browser' } });
assert.strictEqual(r.status, 404);
assert.strictEqual(r.status, 404);
- const own2 = await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(8), desk: 'desk-s08', kind: 'browser' } });
+ const body = { session: SES(8), desk: 'desk-s08', kind: 'browser', origins: ['https://example.org'] };
+ const own2 = await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body });
assert.deepStrictEqual([own2.status, own2.json.shared], [201, false], 'a person\'s own machine, where agents may work');
assert.deepStrictEqual([own2.status, own2.json.shared], [201, false], 'a person\'s own machine, where agents may work');
+ assert.equal((await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, origins: [] } })).status, 409, 'a retry cannot widen or change the boundary');
+ const url = `/api/v1/machines/${own.deviceId}/desk-leases/desk-s08/commands`;
+ const action = await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });
+ assert.equal(action.status, 200, action.text);
+ assert.deepStrictEqual(seen.at(-1).value.origins, ['https://example.org'], 'Bot attaches the stored list to every browser command');
+ const profile = await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'profile_export' } } });
+ assert.equal(profile.status, 200, profile.text);
+ assert.deepStrictEqual(seen.at(-1).value.origins, ['https://example.org']);
+ assert.equal((await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'read', origins: [] } } })).status, 422, 'the caller cannot change the list per command');
});
});
for (const m of [host, plain, own]) m.c.close();
for (const m of [host, plain, own]) m.c.close();
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const { withDelivery } = require('../server/command-delivery');
const { withDelivery } = require('../server/command-delivery');
+const { boundPage, MAX_PAGE } = require('../server/browser-content');
(async () => {
(async () => {
await check('a timeout or in-flight repeat never claims a safe retry', async () => {
await check('a timeout or in-flight repeat never claims a safe retry', async () => {
@@ -95,6 +96,8 @@ const { withDelivery } = require('../server/command-delivery');
@@ -95,6 +96,8 @@ const { withDelivery } = require('../server/command-delivery');
await check('the browser kind reaches the machine; a file: address is refused before it leaves', async () => {
await check('the browser kind reaches the machine; a file: address is refused before it leaves', async () => {
const open = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'open', url: 'https://example.org/account?token=abc' } }, { user: alex });
const open = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'open', url: 'https://example.org/account?token=abc' } }, { user: alex });
assert.deepStrictEqual([open.status, open.json.result, open.json.data.title], [200, 'ack', 'Example']);
assert.deepStrictEqual([open.status, open.json.result, open.json.data.title], [200, 'ack', 'Example']);
+ assert.match(open.json.data.text, /^<<<ov-text nonce:[0-9a-f]{32} origin:https:\/\/example.org>>>\nExample page\n<<<ov-text-end nonce:[0-9a-f]{32}>>>$/);
+ assert.equal(open.json.data.origin, 'https://example.org');
const fill = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'fill', selector: '#password', text: 'hunter3 secret' } }, { user: alex });
const fill = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'fill', selector: '#password', text: 'hunter3 secret' } }, { user: alex });
assert.strictEqual(fill.json.result, 'ack');
assert.strictEqual(fill.json.result, 'ack');
assert.deepStrictEqual(seen.filter((m) => m.kind === 'browser').map((m) => m.value.action), ['open', 'fill']);
assert.deepStrictEqual(seen.filter((m) => m.kind === 'browser').map((m) => m.value.action), ['open', 'fill']);
@@ -104,6 +107,28 @@ const { withDelivery } = require('../server/command-delivery');
@@ -104,6 +107,28 @@ const { withDelivery } = require('../server/command-delivery');
assert.deepStrictEqual(rows.map((r) => r.value), [{ action: 'open', host: 'example.org' }, { action: 'fill', selector: '#password', chars: 14 }],
assert.deepStrictEqual(rows.map((r) => r.value), [{ action: 'open', host: 'example.org' }, { action: 'fill', selector: '#password', chars: 14 }],
'the audit keeps the host (never the path or query) and how much was typed (never the text)');
'the audit keeps the host (never the path or query) and how much was typed (never the text)');
assert.ok(!JSON.stringify(rows).includes('hunter3') && !JSON.stringify(rows).includes('token=abc'));
assert.ok(!JSON.stringify(rows).includes('hunter3') && !JSON.stringify(rows).includes('token=abc'));
+ const bounded = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'read', origins: ['https://example.org'] } }, { user: alex });
+ assert.equal(bounded.status, 200, bounded.text);
+ assert.deepStrictEqual(seen.at(-1).value.origins, ['https://example.org']);
+ const invalidOrigins = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'read', origins: ['https://example.org/path'] } }, { user: alex });
+ assert.deepStrictEqual([invalidOrigins.status, invalidOrigins.json.code], [422, 'bot.invalid_input']);
+ });
+
+ await check('a verbose page is bounded before it reaches a caller', async () => {
+ const out = boundPage({ url: 'https://example.org/', title: 'A', text: 'é'.repeat(100000),
+ links: Array.from({ length: 60 }, () => ({ text: 'x', href: 'https://example.org/' + 'y'.repeat(5000) })),
+ elements: Array(120).fill('e1 button ' + 'z'.repeat(500)),
+ untrusted_extra: 'x'.repeat(200000), receipt: { appeared: ['a'.repeat(200000)], extra: 'y'.repeat(200000) } });
+ assert.equal(out.truncated, true);
+ assert.ok(Buffer.byteLength(JSON.stringify(out)) <= MAX_PAGE);
+ assert.equal(out.untrusted_extra, undefined);
+ assert.equal(out.receipt.extra, undefined);
+ assert.match(out.text, /<<<ov-text-end nonce:[0-9a-f]{32}>>>$/);
+ const receiptOnly = boundPage({ done: true, receipt: { appeared: ['a'.repeat(200000)], gone: 1, extra: 'x'.repeat(200000) } });
+ assert.ok(Buffer.byteLength(JSON.stringify(receiptOnly)) <= MAX_PAGE);
+ assert.equal(receiptOnly.receipt.gone, 1);
+ assert.equal(receiptOnly.receipt.extra, undefined);
+ assert.match(receiptOnly.receipt.appeared[0], /<<<ov-text-end nonce:[0-9a-f]{32}>>>$/);
});
});
await check('the private desk (contracts 0.214.0): start, stop and status reach a machine that has one, and desk: private acts there; one without says so', async () => {
await check('the private desk (contracts 0.214.0): start, stop and status reach a machine that has one, and desk: private acts there; one without says so', async () => {