leasePort({closed:true}) ignored its option: it probed an ephemeral port and
closed it, so a parallel test file could bind it and answer a request the
test expected to fail (preview.test.mjs:209 saw 200 instead of 502).
leaseClosedPort holds a bound, non-listening socket in a perl child until the
test's after hook. Connects still get ECONNREFUSED, bind() from another
process gets EADDRINUSE, and the kernel's ephemeral allocation skips the port.
Lane mode keeps its band and per-port lock, and releases the socket before
the lock. leasePort now refuses a closed option, so a stale call cannot get
the unheld port again.
Unanswerable callers (webui preview, serve, service) move to leaseClosedPort(t).
Callers that listen on the port themselves (stack-release rehearse, control-board
serve, tester-cli) keep leasePort and drop the ignored option.
269 lines
17 KiB
JavaScript
269 lines
17 KiB
JavaScript
import { leaseClosedPort } from '../../../scripts/testing/lane-fixtures.mjs';
|
|
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
|
|
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
|
|
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
|
|
// a repeated Host is 400, and HEADER with any Host but NAME is 403, so a proxy that rewrites Host
|
|
// fails closed instead of reaching the loopback path.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createServer, request } from 'node:http';
|
|
import { connect } from 'node:net';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { startServer, previewOptions } from '../src/serve.mjs';
|
|
import { close } from './fixture.mjs';
|
|
|
|
const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason';
|
|
const preview = { host: NAME, header: HEADER, user: ID };
|
|
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
|
|
|
// node:http, not fetch: fetch can't set Host. The length is explicit because node sends a
|
|
// DELETE or OPTIONS body without one, which breaks the next request on a kept-alive socket.
|
|
function send(port, { method = 'GET', path = '/api/bus/inbox', headers = {}, body } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
|
const req = request({ host: '127.0.0.1', port, method, path, headers: { host: NAME, ...length, ...headers } }, res => {
|
|
let text = '';
|
|
res.on('data', c => (text += c));
|
|
res.on('end', () => resolve({ status: res.statusCode, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
|
});
|
|
req.on('error', reject);
|
|
req.end(body);
|
|
});
|
|
}
|
|
// A raw request, for what node:http won't send: two Host lines.
|
|
function raw(port, lines) {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = connect(port, '127.0.0.1', () => socket.end([...lines, 'connection: close', '', ''].join('\r\n')));
|
|
let text = '';
|
|
socket.on('data', c => (text += c));
|
|
// json() answers chunked: one chunk, then the zero chunk.
|
|
socket.on('end', () => resolve({ status: Number(text.split(' ')[1]), body: JSON.parse(text.split('\r\n\r\n')[1].split('\r\n')[1]) }));
|
|
socket.on('error', reject);
|
|
});
|
|
}
|
|
const signedIn = { [HEADER]: ID };
|
|
const json = { 'content-type': 'application/json' };
|
|
|
|
// A board that counts what reaches it, and a bus that counts its reads.
|
|
async function setup(t, options = {}) {
|
|
const boardHits = [];
|
|
const upstream = createServer((req, res) => {
|
|
boardHits.push(`${req.method} ${req.url}`);
|
|
res.writeHead(200, json);
|
|
res.end(req.url === '/api/board' ? '{"sessions":[]}' : '{"ok":true}');
|
|
});
|
|
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
|
|
// Closed first and on its own, so a refused startServer doesn't leave it holding the process open.
|
|
t.after(() => close(upstream));
|
|
const busCalls = [];
|
|
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
|
|
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, ...options });
|
|
t.after(() => close(web));
|
|
return { port: web.address().port, boardHits, busCalls };
|
|
}
|
|
|
|
// startServer must refuse; a server it starts anyway is closed so the failure can't hang the run.
|
|
async function refused(options) {
|
|
const result = await startServer({ port: 0, preview: options }).catch(err => err);
|
|
if (!(result instanceof Error)) { await close(result); assert.fail(`served with ${JSON.stringify(options)}`); }
|
|
assert.match(result.message, /together/);
|
|
}
|
|
|
|
test('preview options are all three or none, and each is checked', async () => {
|
|
assert.equal(previewOptions({}), null);
|
|
assert.deepEqual(previewOptions({ publicHost: 'Mosaic-Preview.Example.com', authHeader: 'Remote-User', allowUser: ID }), preview);
|
|
for (const partial of [{ publicHost: NAME }, { authHeader: HEADER }, { allowUser: ID }, { publicHost: NAME, authHeader: HEADER }, { publicHost: NAME, allowUser: ID }, { authHeader: HEADER, allowUser: ID }]) {
|
|
assert.throws(() => previewOptions(partial), /all|together/, JSON.stringify(partial));
|
|
await refused({ host: partial.publicHost, header: partial.authHeader, user: partial.allowUser });
|
|
}
|
|
await refused({});
|
|
for (const host of ['https://' + NAME, NAME + ':443', NAME + '/', 'localhost', '127.0.0.1', '::1', 'preview', '', 'a..b.com', '-a.example.com'])
|
|
assert.throws(() => previewOptions({ publicHost: host, authHeader: HEADER, allowUser: ID }), /public host/, host);
|
|
for (const header of ['host', 'Origin', 'remote user', 'x:y', '', 'a'.repeat(65), 'remote_user', '_', '-remote-user'])
|
|
assert.throws(() => previewOptions({ publicHost: NAME, authHeader: header, allowUser: ID }), /auth header/, header);
|
|
for (const user of ['', 'jason woltje', 'a,b', 'é', 'a'.repeat(257)])
|
|
assert.throws(() => previewOptions({ publicHost: NAME, authHeader: HEADER, allowUser: user }), /allowed user/, user);
|
|
});
|
|
|
|
test('the CLI refuses a partial or invalid preview flag set at start', () => {
|
|
const sets = [
|
|
['--public-host', NAME], ['--auth-header', HEADER], ['--allow-user', ID],
|
|
['--public-host', NAME, '--auth-header', HEADER], ['--public-host', NAME, '--allow-user', ID], ['--auth-header', HEADER, '--allow-user', ID],
|
|
['--public-host', NAME + ':443', '--auth-header', HEADER, '--allow-user', ID],
|
|
];
|
|
for (const flags of sets) {
|
|
const result = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...flags], { encoding: 'utf8', timeout: 10000 });
|
|
assert.equal(result.status, 2, flags.join(' '));
|
|
assert.match(result.stderr, /^refused: (preview mode needs|public host)/, flags.join(' '));
|
|
assert.equal(result.stdout, '');
|
|
}
|
|
});
|
|
|
|
test('the CLI with all three flags serves the preview mode', async t => {
|
|
// No system config, so the bus routes answer not-configured; the gate runs before them.
|
|
const env = { ...process.env, MOSAIC_CONFIG: fileURLToPath(new URL('./no-such-config.json', import.meta.url)) };
|
|
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
t.after(() => child.kill());
|
|
let out = '';
|
|
const port = await new Promise((resolve, reject) => {
|
|
child.stdout.on('data', c => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); });
|
|
child.on('exit', code => reject(new Error(`exited ${code}: ${out}`)));
|
|
});
|
|
assert.match(out, new RegExp(`^Preview: https://${NAME}/ through the proxy, signed-in user from remote-user, read-only$`, 'm'));
|
|
assert.deepEqual((await send(port, {})).body, { error: 'sign-in required' });
|
|
assert.equal((await send(port, { path: '/healthz', headers: signedIn })).status, 200);
|
|
assert.equal((await send(port, { method: 'POST', path: '/api/seen', headers: { host: `127.0.0.1:${port}`, ...json }, body: '{}' })).status, 403);
|
|
});
|
|
|
|
test('a request for the public name needs the header equal to the user id, else 401 and nothing read', async t => {
|
|
const { port, boardHits, busCalls } = await setup(t);
|
|
for (const [why, headers] of [
|
|
['no header', {}],
|
|
['another user', { [HEADER]: 'mallory' }],
|
|
['the id with a suffix', { [HEADER]: ID + 'x' }],
|
|
['a different header', { 'x-remote-user': ID }],
|
|
['two values', { [HEADER]: [ID, ID] }],
|
|
]) {
|
|
for (const path of ['/api/bus/inbox', '/api/bus/trail?subject=row-1', '/api/board', '/']) {
|
|
const r = await send(port, { path, headers });
|
|
assert.equal(r.status, 401, `${why} ${path}`);
|
|
assert.deepEqual(r.body, { error: 'sign-in required' });
|
|
}
|
|
// HEAD is held to the same check; its answer has no body.
|
|
assert.equal((await send(port, { method: 'HEAD', path: '/', headers })).status, 401, `${why} HEAD`);
|
|
}
|
|
assert.deepEqual(busCalls, []);
|
|
assert.deepEqual(boardHits, []);
|
|
});
|
|
|
|
test('the header name matches without regard to case, and the value compare is exact', async t => {
|
|
// The shape of the first deployment: a mixed-case header flag and a 64-hex user id.
|
|
const uid = 'a3f1'.repeat(16);
|
|
const { port, busCalls } = await setup(t, { preview: { host: NAME, header: 'X-authentik-uid', user: uid } });
|
|
for (const name of ['x-authentik-uid', 'X-authentik-uid', 'X-AUTHENTIK-UID'])
|
|
assert.equal((await send(port, { headers: { [name]: uid } })).status, 200, name);
|
|
for (const value of [uid.toUpperCase(), uid.slice(1), ` ${uid}x`, uid + ',' + uid])
|
|
assert.equal((await send(port, { headers: { 'x-authentik-uid': value } })).status, 401, value);
|
|
assert.equal(busCalls.length, 3);
|
|
});
|
|
|
|
test('the public name with the header reads the bus views, and the Host and Origin are checked first', async t => {
|
|
const { port, boardHits, busCalls } = await setup(t);
|
|
for (const verb of ['inbox', 'tasks', 'agents']) {
|
|
const r = await send(port, { path: `/api/bus/${verb}`, headers: signedIn });
|
|
assert.equal(r.status, 200, verb); assert.deepEqual(r.body.rows, [{ verb }]);
|
|
}
|
|
const trail = await send(port, { path: '/api/bus/trail?subject=row-1', headers: { ...signedIn, origin: `https://${NAME}` } });
|
|
assert.equal(trail.status, 200);
|
|
assert.equal((await send(port, { path: '/api/board', headers: signedIn })).status, 200);
|
|
// A hostname compares without regard to case.
|
|
assert.equal((await send(port, { path: '/healthz', headers: { ...signedIn, host: 'Mosaic-Preview.Example.COM' } })).status, 200);
|
|
assert.equal((await send(port, { path: '/', headers: { 'Remote-User': ID } })).status, 200);
|
|
assert.equal((await send(port, { method: 'HEAD', path: '/', headers: signedIn })).status, 200);
|
|
assert.deepEqual(busCalls, [['inbox', undefined], ['tasks', undefined], ['agents', undefined], ['trail', 'row-1']]);
|
|
assert.deepEqual(boardHits, ['GET /api/board']);
|
|
// Any Host but NAME exactly is refused: without the header as non-local, with it as a proxy
|
|
// that didn't forward Host unchanged. GET and HEAD alike.
|
|
for (const host of ['evil.example', NAME + ':443', `${NAME}.evil.example`, `x.${NAME}`, `x${NAME}`]) {
|
|
for (const method of ['GET', 'HEAD']) {
|
|
const path = method === 'GET' ? '/api/bus/inbox' : '/';
|
|
const bare = await send(port, { method, path, headers: { host } });
|
|
assert.equal(bare.status, 403, `${method} ${host}`);
|
|
const signed = await send(port, { method, path, headers: { ...signedIn, host } });
|
|
assert.equal(signed.status, 403, `${method} ${host} signed`);
|
|
if (method === 'GET') {
|
|
assert.deepEqual(bare.body, { error: 'non-local Host refused' });
|
|
assert.deepEqual(signed.body, { error: 'proxy Host refused', message: 'the proxy must forward Host unchanged' });
|
|
}
|
|
}
|
|
}
|
|
for (const origin of [`http://${NAME}`, 'https://evil.example', `https://${NAME}:443`, `http://127.0.0.1:${port}`]) {
|
|
const r = await send(port, { headers: { ...signedIn, origin } });
|
|
assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' });
|
|
assert.equal((await send(port, { method: 'HEAD', path: '/', headers: { ...signedIn, origin } })).status, 403, `HEAD ${origin}`);
|
|
}
|
|
assert.equal(busCalls.length, 4);
|
|
});
|
|
|
|
test('in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing', async t => {
|
|
const { port, boardHits, busCalls } = await setup(t);
|
|
const loopback = { host: `127.0.0.1:${port}` };
|
|
const seen = JSON.stringify({ project: 'p', agent: 'a', lastActivity: 'x', seen: true }), reply = JSON.stringify({ agent: 'p/a', text: 'hi' });
|
|
for (const headers of [signedIn, loopback, { ...loopback, origin: `http://127.0.0.1:${port}` }]) {
|
|
for (const [path, body] of [['/api/seen', seen], ['/api/reply', reply], ['/api/bus/inbox', '{}'], ['/', '{}']]) {
|
|
const r = await send(port, { method: 'POST', path, headers: { ...headers, ...json }, body });
|
|
assert.equal(r.status, 403, `${headers.host ?? NAME} POST ${path}`);
|
|
assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' });
|
|
}
|
|
for (const method of ['PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
|
assert.equal((await send(port, { method, path: '/api/seen', headers: { ...headers, ...json }, body: seen })).status, 403, method);
|
|
}
|
|
// A write without the header is still a sign-in refusal first.
|
|
assert.equal((await send(port, { method: 'POST', path: '/api/reply', headers: json, body: reply })).status, 401);
|
|
assert.deepEqual(boardHits, []);
|
|
assert.deepEqual(busCalls, []);
|
|
});
|
|
|
|
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
|
|
const dead = `http://127.0.0.1:${await leaseClosedPort(t)}`;
|
|
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
|
|
const loopback = { host: `127.0.0.1:${port}` };
|
|
const inbox = await send(port, { headers: loopback });
|
|
assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]);
|
|
assert.equal((await send(port, { path: '/healthz', headers: { host: `localhost:${port}` } })).status, 200);
|
|
assert.equal((await send(port, { headers: { ...loopback, origin: `https://${NAME}` } })).status, 403);
|
|
assert.equal((await send(port, { headers: { host: `127.0.0.1:${port + 1}` } })).status, 403);
|
|
// The board panel's existing error; the bus views still answer.
|
|
const board = await send(port, { path: '/api/board', headers: signedIn });
|
|
assert.equal(board.status, 502); assert.equal(board.body.board, dead);
|
|
assert.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200);
|
|
assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']);
|
|
});
|
|
|
|
test('a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user', async t => {
|
|
// nginx's default proxy_set_header Host $proxy_host sends the upstream's Host, the operator's path.
|
|
const { port, boardHits, busCalls } = await setup(t);
|
|
for (const host of [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]) {
|
|
for (const [name, value] of [[HEADER, 'mallory'], [HEADER, ID], ['Remote-User', ID], [HEADER, '']]) {
|
|
for (const [method, path] of [['GET', '/api/bus/inbox'], ['GET', '/api/config'], ['HEAD', '/'], ['POST', '/api/seen']]) {
|
|
const r = await send(port, { method, path, headers: { host, [name]: value, ...(method === 'POST' ? json : {}) }, body: method === 'POST' ? '{}' : undefined });
|
|
assert.equal(r.status, 403, `${host} ${name}=${value} ${method} ${path}`);
|
|
if (method !== 'HEAD') assert.deepEqual(r.body, { error: 'proxy Host refused', message: 'the proxy must forward Host unchanged' });
|
|
}
|
|
}
|
|
// The operator's browser sends no HEADER and still gets through.
|
|
assert.equal((await send(port, { headers: { host } })).status, 200, host);
|
|
}
|
|
assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'inbox', 'inbox']);
|
|
assert.deepEqual(boardHits, []);
|
|
});
|
|
|
|
test('a repeated Host is 400 before any other check, in preview mode and without it', async t => {
|
|
for (const options of [{}, { preview: null }]) {
|
|
const { port, boardHits, busCalls } = await setup(t, options);
|
|
const loopback = `host: 127.0.0.1:${port}`, named = `host: ${NAME}`;
|
|
// Filbert's case: a loopback Host, then NAME, with no header; before round 2 this was 200.
|
|
for (const hosts of [[loopback, named], [named, loopback], [named, named], [loopback, loopback]]) {
|
|
for (const extra of [[], [`${HEADER}: ${ID}`]]) {
|
|
const r = await raw(port, ['GET /api/bus/inbox HTTP/1.1', ...hosts, ...extra]);
|
|
assert.equal(r.status, 400, hosts.concat(extra).join(' | '));
|
|
assert.deepEqual(r.body, { error: 'repeated Host refused' });
|
|
}
|
|
}
|
|
// One Host still works.
|
|
assert.equal((await raw(port, ['GET /api/bus/inbox HTTP/1.1', loopback])).status, 200);
|
|
assert.deepEqual(busCalls.map(c => c[0]), ['inbox']);
|
|
assert.deepEqual(boardHits, []);
|
|
}
|
|
});
|
|
|
|
test('without the preview flags the public name is refused and writes still reach the board', async t => {
|
|
const { port, boardHits } = await setup(t, { preview: null });
|
|
const r = await send(port, { headers: signedIn });
|
|
assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'non-local Host refused' });
|
|
const seen = await send(port, { method: 'POST', path: '/api/seen', headers: { host: `127.0.0.1:${port}`, ...json }, body: '{"seen":true}' });
|
|
assert.equal(seen.status, 200);
|
|
assert.deepEqual(boardHits, ['POST /api/seen']);
|
|
});
|