// org-lan-bind: the dev owner Console on the LAN (--dev-insecure-bind ADDR --dev-allow-host NAME). // "Remote" requests bind 0.0.0.0 on port 0 and connect to 127.0.0.1 with a LAN Host header. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { request } from 'node:http'; import { connect } from 'node:net'; import { fileURLToPath } from 'node:url'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { close } from './fixture.mjs'; process.chdir(fileURLToPath(new URL('../../..', import.meta.url))); const LAN = '10.1.10.37'; function call(port, { host, method = 'GET', path = '/api/config', origin, site, body } = {}) { const data = body === undefined ? undefined : JSON.stringify(body); const headers = { host, ...(origin === undefined ? {} : { origin }), ...(site === undefined ? {} : { 'sec-fetch-site': site }), ...(data === undefined ? {} : { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) }) }; return new Promise((resolve, reject) => { const req = request({ host: '127.0.0.1', port, path, method, headers }, res => { let raw = ''; res.on('data', c => raw += c); res.on('end', () => { let parsed = raw; try { parsed = JSON.parse(raw); } catch {} resolve({ status: res.statusCode, type: res.headers['content-type'] ?? '', body: parsed }); }); }); req.on('error', reject); req.end(data); }); } // node:http will not send two Host lines, so the repeated-Host request is written by hand. function raw(port, text) { return new Promise((resolve, reject) => { const socket = connect(port, '127.0.0.1', () => socket.end(text)); let out = ''; socket.on('data', c => out += c); socket.on('end', () => resolve(out)); socket.on('error', reject); }); } // A refusal must come before listening; a server that starts anyway is closed and fails the test. async function refused(options, pattern) { let server; try { server = await startServer({ port: 0, log: () => {}, ...options }); } catch (err) { assert.match(err.message, pattern); return; } await close(server); assert.fail(`startServer listened with ${JSON.stringify(Object.keys(options))}`); } test('a non-loopback bind is refused without the dev owner and its allowed names', async () => { const sso = { publicHost: 'console.test', publicOrigin: 'https://console.test', session: () => null }; const preview = { host: 'console.test', header: 'x-auth', user: 'u' }; await refused({ host: '0.0.0.0' }, /refusing to bind to non-loopback host/); await refused({ host: '0.0.0.0', devAllowHosts: [LAN] }, /dev allowed hosts need the dev local owner/); await refused({ host: '0.0.0.0', sso: createDevOwner() }, /dev local owner must bind 127\.0\.0\.1/); await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: [] }, /dev local owner must bind 127\.0\.0\.1/); await refused({ host: 'localhost', sso: createDevOwner(), devAllowHosts: [LAN] }, /binds an IPv4 address/); for (const name of [`${LAN}:80`, 'LAN.test', 'lan_test', '', '-lan', '1.2.3', 'http://lan.test']) await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: [name] }, /without a port/); await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: 'lan.test' }, /without a port/); // SSO, preview and tester listeners stay loopback-only, with or without allowed names. for (const extra of [{ sso }, { preview }, { tester: true }]) { await refused({ host: '0.0.0.0', ...extra }, /refusing to bind to non-loopback host/); await refused({ host: '0.0.0.0', devAllowHosts: [LAN], ...extra }, /dev allowed hosts need the dev local owner/); await refused({ host: LAN, devAllowHosts: [LAN], ...extra }, /dev allowed hosts need the dev local owner/); } }); test('an allowed LAN name reads and writes with its own Origin only', async t => { const sent = []; const server = await startServer({ host: '0.0.0.0', port: 0, devAllowHosts: [LAN, 'lan.test'], sso: createDevOwner(), board: 'http://127.0.0.1:9', send: async args => { sent.push(args); return { id: `m${sent.length}` }; }, log: () => {} }); t.after(() => close(server)); const port = server.address().port, host = `${LAN}:${port}`, message = { to: 'chief', body: 'hello' }; assert.equal(server.address().address, '0.0.0.0'); const shell = await call(port, { host, path: '/' }); assert.equal(shell.status, 200); assert.match(shell.type, /text\/html/); const config = await call(port, { host }); assert.equal(config.status, 200); assert.equal(config.body.devOwner, true); assert.equal(config.body.send, true); // A browser on a LAN http origin sends Origin but no Sec-Fetch-Site; the exact Origin is enough. const write = await call(port, { host, method: 'POST', path: '/api/bus/message', origin: `http://${host}`, body: message }); assert.equal(write.status, 200, JSON.stringify(write.body)); assert.equal(write.body.id, 'm1'); const named = await call(port, { host: `lan.test:${port}`, method: 'POST', path: '/api/bus/message', origin: `http://lan.test:${port}`, body: message }); assert.equal(named.status, 200, JSON.stringify(named.body)); // A Fetch Metadata header the browser did send is still judged by the route. const crossSite = await call(port, { host, method: 'POST', path: '/api/bus/message', origin: `http://${host}`, site: 'cross-site', body: message }); assert.equal(crossSite.status, 403); assert.equal(crossSite.body.error, 'cross-site request refused'); for (const origin of [undefined, `http://127.0.0.1:${port}`, `http://localhost:${port}`, 'http://evil.example', `http://lan.test:${port}`, `https://${host}`, `http://${LAN}:${port + 1}`, 'null']) { const r = await call(port, { host, method: 'POST', path: '/api/bus/message', origin, site: 'same-origin', body: message }); assert.equal(r.status, 403, String(origin)); assert.equal(r.body.error, 'cross-origin request refused', String(origin)); } for (const other of ['10.1.10.38', 'evil.example', `evil.example:${port}`, `${LAN}:${port + 1}`, LAN, `LAN.TEST:${port}`, `lan.test.:${port}`, `${LAN}:${port}.`]) { const r = await call(port, { host: other }); assert.equal(r.status, 403, other); assert.equal(r.body.error, 'non-local Host refused', other); } const repeated = await raw(port, `GET /api/config HTTP/1.1\r\nHost: ${host}\r\nHost: ${host}\r\nConnection: close\r\n\r\n`); assert.match(repeated, /^HTTP\/1\.1 403 /); assert.match(repeated, /repeated Host refused/); // Loopback on the same server is unchanged: no Fetch Metadata stand-in there. const loop = `127.0.0.1:${port}`; assert.equal((await call(port, { host: loop })).status, 200); const bare = await call(port, { host: loop, method: 'POST', path: '/api/bus/message', origin: `http://${loop}`, body: message }); assert.equal(bare.status, 403); assert.equal(bare.body.error, 'cross-site request refused'); const loopWrite = await call(port, { host: loop, method: 'POST', path: '/api/bus/message', origin: `http://${loop}`, site: 'same-origin', body: message }); assert.equal(loopWrite.status, 200, JSON.stringify(loopWrite.body)); assert.equal(sent.length, 3); }); test('a loopback dev owner without allowed names refuses a LAN Host', async t => { const server = await startServer({ port: 0, sso: createDevOwner(), board: 'http://127.0.0.1:9', send: async () => ({ id: 'm1' }), log: () => {} }); t.after(() => close(server)); const port = server.address().port; assert.equal(server.address().address, '127.0.0.1'); assert.equal((await call(port, { host: `${LAN}:${port}` })).status, 403); const r = await call(port, { host: `${LAN}:${port}`, method: 'POST', path: '/api/bus/message', origin: `http://${LAN}:${port}`, body: { to: 'chief', body: 'x' } }); assert.equal(r.status, 403); assert.equal(r.body.error, 'non-local Host refused'); }); // The flags are refused before any configuration is read: an empty MOSAIC_CONFIG would otherwise // fail with the named-instance refusal instead. test('serve refuses the LAN flags unless they come together with --dev-local-owner', () => { const run = args => spawnSync(process.execPath, ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...args], { encoding: 'utf8', env: { ...process.env, MOSAIC_CONFIG: '' }, timeout: 10000 }); for (const [args, reason] of [ [['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/], [['--dev-insecure-bind', '0.0.0.0'], /--dev-insecure-bind needs --dev-local-owner/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/], [['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/], [['--dev-local-owner', '--dev-insecure-bind', 'lan.test', '--dev-allow-host', 'lan.test'], /0\.0\.0\.0 or an IPv4 address/], [['--dev-local-owner', '--dev-insecure-bind', '::', '--dev-allow-host', 'lan.test'], /0\.0\.0\.0 or an IPv4 address/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test,'], /without a port/], [['--dev-local-owner', '--dev-insecure-bind', LAN, '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /takes one address/], [['--dev-local-owner', '--sso', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /cannot be combined/], ]) { const r = run(args); assert.equal(r.status, 2, args.join(' ')); assert.match(r.stderr, reason, args.join(' ')); assert.doesNotMatch(r.stdout + r.stderr, /Mosaic Console:|WARNING: dev owner/, args.join(' ')); } // With valid flags the next refusal is the named-instance check, so parsing accepted them. const valid = run(['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', `lan.test,${LAN}`, '--dev-allow-host', 'other.lan']); assert.equal(valid.status, 2); assert.match(valid.stderr, /--dev-local-owner requires a named instance/); });