From 351464ce42e4d52e3ee9afaae6310c43a6709cfd Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 11 Oct 2026 18:32:51 -0500 Subject: [PATCH] feat(webui): signed-in read-only Console preview behind the edge proxy (#1568, row 74) serve takes --public-host NAME --auth-header HEADER --allow-user ID, all three or none. With them, a request whose Host is NAME must carry HEADER equal to ID or gets 403; a request that carries HEADER with any other Host gets 403, so a proxy that rewrites Host can't reach the loopback path with a signed-in request; every POST is refused read-only. A repeated Host gets 400 in both modes. Host and header names compare case-insensitively (Traefik sends X-Authentik-Uid). The README states the proxy contract: the edge forwards Host unchanged and strips client copies of HEADER. Author: Dewey. Round 2 candidate manifest 17b6d7f62d106a23d99441eefa46e34ab2e9bd1442a1d02e219f126a82a8668d (base 17ea0dbc, no drift on the 4 paths through 60c8dcf9). Approved by Filbert (comment 27485) and Darkwing (comment 27486) on #1568. Their wording notes on the README (first signed-in request; the tunnel sentence) go to row 76, which owns the README next. Sage gate rerun on a worktree of a55915f0 with the patch: manifest OK; webui 61/61, control-board 124/124, cli 113/113; test-auth 15, conductor 17, config 24, extension-package 18, foundation 44, queue 27, release 14, task 98, all 0 failed. test-discord failed one timing test (engine: a timed-out run outlives the grace, 280 ms at load 30) on the sequential run and passed 66/66 on a solo rerun of the same worktree; the patch touches no discord file. Co-Authored-By: Claude Opus 5.5 --- packages/webui/README.md | 72 ++++++- packages/webui/src/cli.mjs | 16 +- packages/webui/src/serve.mjs | 46 ++++- packages/webui/tests/preview.test.mjs | 268 ++++++++++++++++++++++++++ 4 files changed, 390 insertions(+), 12 deletions(-) create mode 100644 packages/webui/tests/preview.test.mjs diff --git a/packages/webui/README.md b/packages/webui/README.md index e43118d50..883b5a6d1 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -16,12 +16,78 @@ Open http://127.0.0.1:7330/. Optional `--port N` and `--business ID` picks the business the bus views read; without it they read the running bus host's business (`mosaic bus start `). Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon, -installation, account, authentication or deployment is added. +installation, account, login or deployment is added. The preview mode below +checks one header a sign-in proxy sets; it signs nobody in itself. Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback -by default, rejects nonlocal Host and cross-origin requests, sends no CORS +by default, rejects a repeated Host (400), nonlocal Host and cross-origin +requests, sends no CORS headers, refuses redirects and accepts only JSON object POST bodies up to 4096 -bytes. Do not expose either unauthenticated server through a public proxy. +bytes. Do not put the board behind a public proxy. Put the Console behind one +only in the preview mode below, with a proxy that meets its contract. + +## Preview behind a sign-in proxy (row 74, #1568) + +```sh +node packages/webui/src/cli.mjs serve --port 7332 \ + --public-host NAME --auth-header HEADER --allow-user ID +``` + +The three flags go together; one or two refuse at start, before any config +is read. NAME is a DNS name, no scheme or port. HEADER is a header name other +than Host or Origin; Node lowercases incoming header names, so it matches +without regard to case (`X-Example-Uid` on the flag finds `x-example-uid`). +ID is the one user id, 1 to 256 visible ASCII characters without a comma, +and the value compare is exact. The Console still binds to loopback only. It +names no identity provider and has no login code: the proxy signs the user +in and limits sign-in to the right group. + +In preview mode: + +- A request whose Host is exactly NAME must carry HEADER once, with a value + equal to ID. Missing, different or repeated answers 401 `sign-in required` + before any route runs, so no bus read or board call is made. An Origin, + when sent, must be `https://NAME`. +- A request whose Host is loopback on the Console's port (`127.x`, + `localhost` or `[::1]`) is checked as before and needs no HEADER, so the + operator's browser on the host still works. Any other Host is 403. +- A request that carries HEADER with any Host but NAME answers 403 `proxy + Host refused`. The operator's browser never sends HEADER; a proxy that + rewrote Host does, and it fails on its first request instead of reaching + the loopback path unchecked. +- More than one Host header answers 400 before any other check, in this + mode or not. Node keeps the first, so a loopback Host followed by + `Host: NAME` would otherwise skip the sign-in check. +- Every method other than GET and HEAD answers 403 `read-only`, whatever the + Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply + fail in the page with that refusal. +- The GET routes are unchanged: the bus views, the Queue, Business and + Settings reads, the board panel and the assets. If the board is down, its + panel shows the existing error and the bus views still answer. + +What the proxy must do. The Console trusts HEADER only because the proxy +controls it: + +- It terminates TLS for NAME and is the only thing that reaches the port + from off the host. +- It strips HEADER from every client request and sets it only after the + identity provider signs the user in. +- It forwards only requests whose Host is NAME, and forwards that Host + unchanged. nginx rewrites Host to the upstream by default; set + `proxy_set_header Host $host;` (or the literal NAME). Caddy and Traefik + keep the client's Host by default. +- Any loopback Host on the Console's port, `localhost` included, is the + operator's path and skips the header check, so a proxy that forwarded a + client's `Host: 127.0.0.1:7332` would let anyone through without signing + in. A tunnel whose local end uses the same port number sends such a Host + and reaches the Console unchecked. +- Every hop on this host that reaches the loopback port is bound by this + contract too, not only the edge. + +The local exposure doesn't change ("Data and boundaries"): any process on +the host can still connect to the port and send `Host: NAME` with HEADER and +ID, or just a loopback Host, and read what a reader capability reads. The +user id is not a secret; the sign-in at the edge is what keeps others out. ## Use diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 64b3e7b42..5c6a5931e 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -1,16 +1,16 @@ #!/usr/bin/env node -import { startServer, DEFAULT_BOARD } from './serve.mjs'; +import { startServer, previewOptions, DEFAULT_BOARD } from './serve.mjs'; import { busReader } from './bus.mjs'; import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs'; import { configDir } from '../../business/src/business.mjs'; import { consoleReads, redactor } from './reads.mjs'; import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; const args = process.argv.slice(2); -const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID]'; +const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID]'; try { if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); } if (args.shift() !== 'serve') throw new Error(usage); - const options = { board: DEFAULT_BOARD, port: 7330 }; + const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}; while (args.length) { const flag = args.shift(), value = args.shift(); if (!value) throw new Error(`missing value for ${flag}`); @@ -22,8 +22,13 @@ try { if (!ID_PATTERN.test(value)) throw new Error('business must be a business id'); options.business = value; } + // Row 74 (#1568): the preview mode, all three or none, checked before any config is read. + else if (flag === '--public-host') preview.publicHost = value; + else if (flag === '--auth-header') preview.authHeader = value; + else if (flag === '--allow-user') preview.allowUser = value; else throw new Error(`unknown option: ${flag}`); } + options.preview = previewOptions(preview); // The bus views (slice 1 S5) need the system config. Without it the // board still serves and the bus routes answer not-configured. // The Queue, Business and Settings reads (row 54) need no bus; without @@ -37,8 +42,9 @@ try { busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`; } catch (err) { busLine = `Bus: not configured (${redactor({ configDir: configDir() })(err.message)})`; } const reads = consoleReads({ root: REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null }); - const server = await startServer({ board: options.board, port: options.port, bus, reads }); - console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}\nCtrl-C stops this server.`); + const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview }); + const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : ''; + console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${previewLine}\nCtrl-C stops this server.`); for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close()); } catch (err) { console.error(`refused: ${err.message}`); diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 4d51942a5..a1861d5dc 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -78,22 +78,60 @@ async function consoleRead(res, read) { return json(res, readStatus(code), { error: code, message: err instanceof ReadError ? err.message : code }); } } -export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null } = {}) { +// Row 74 (#1568, lead decision 93): the preview mode behind the edge proxy. Off unless +// all three are given. The bind stays loopback; the proxy signs the user in +// and sets the header; the Console checks it against the one user id and serves reads only. +// Nothing here names an identity provider: one header name and one user id, from the flags. +const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; +const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/; +const USER = /^[\x21-\x2b\x2d-\x7e]{1,256}$/; +export function previewOptions({ publicHost, authHeader, allowUser } = {}) { + const given = [publicHost, authHeader, allowUser].filter(v => v !== undefined && v !== null); + if (!given.length) return null; + if (given.length !== 3) throw new Error('preview mode needs --public-host, --auth-header and --allow-user together'); + const host = String(publicHost).toLowerCase(), header = String(authHeader).toLowerCase(); + if (!HOSTNAME.test(host) || isIP(host)) throw new Error('public host must be a DNS name without scheme or port'); + if (!HEADER.test(header) || ['host', 'origin'].includes(header)) throw new Error('auth header must be a header name other than Host or Origin'); + if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma'); + return { host, header, user: allowUser }; +} +export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null } = {}) { if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); if (host === 'localhost') host = '127.0.0.1'; const upstream = boardURL(board); + // An object with none of the three is refused too: asking for the preview never serves without it. + if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) { + throw new Error('preview mode needs --public-host, --auth-header and --allow-user together'); + } const server = createServer(async (req, res) => { res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('referrer-policy', 'no-referrer'); res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); let path, search; + // Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is + // never legitimate, so it is refused before any check reads the Host. + if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' }); + // Preview mode: a request for the public name, exactly, as the proxy forwards it. + const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host; + // HEADER with any other Host comes from a proxy that rewrote Host (nginx's default) and would + // otherwise land on the loopback path unchecked. The operator's browser never sends it. + if (preview !== null && !named && req.headersDistinct[preview.header]) return json(res, 403, { error: 'proxy Host refused', message: 'the proxy must forward Host unchanged' }); try { - const authority = new URL(`http://${req.headers.host}`); - if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' }); + if (!named) { + const authority = new URL(`http://${req.headers.host}`); + if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' }); + } ({ pathname: path, search } = new URL(req.url, 'http://localhost')); } catch { return json(res, 400, { error: 'invalid URL' }); } // No CORS. JSON content type and same-origin checks keep browser forms out. - if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' }); + if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' }); + if (named) { + // One value, equal to the user id; the proxy strips client copies and sets it after sign-in. + const values = req.headersDistinct[preview.header]; + if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' }); + } + // Nothing but GET and HEAD gets past here in preview mode, whatever the Host. + if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' }); // The conversation routes (#1507, CHAT-02) keep their query; the board validates it. const conversation = path === '/api/conversations' || path === '/api/conversation'; const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null; diff --git a/packages/webui/tests/preview.test.mjs b/packages/webui/tests/preview.test.mjs new file mode 100644 index 000000000..662add64e --- /dev/null +++ b/packages/webui/tests/preview.test.mjs @@ -0,0 +1,268 @@ +// 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']); +});