Files
stack/packages/webui/tests/dev-lan-bind.test.mjs
jason.woltje 665a5148d4 Open the dev owner Console to allowed LAN names
mosaic console start --dev-local-owner --dev-insecure-bind ADDR
--dev-allow-host NAME[,NAME...] binds 0.0.0.0 or an IPv4 address and
answers only NAME:<bound port> plus the loopback names. Both flags are
refused without the dev owner, the bind without an allowed name, and a
specific ADDR that is not itself a name. Start and console status print
the no-sign-in warning; the state file records bind and allowHosts.

serve.mjs takes devAllowHosts only with the dev owner; SSO, preview and
tester listeners stay loopback. Writes need Origin http://<same Host>.
Chromium sends no Fetch Metadata from a LAN http origin, so for an
allowed name a write with that exact Origin and no Sec-Fetch-Site counts
as same-origin; an explicit header is still judged by the routes.
chat.js builds its operation keys without crypto.randomUUID, which an
insecure context lacks.
2026-11-08 21:13:53 -06:00

137 lines
10 KiB
JavaScript

// 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/);
});