// 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 probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r)); const dead = `http://127.0.0.1:${probe.address().port}`; await close(probe); 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']); });