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 <[email protected]>
This commit is contained in:
2026-10-11 18:32:51 -05:00
co-authored by Claude Opus 5.5
parent 3417d21483
commit 351464ce42
4 changed files with 390 additions and 12 deletions
+69 -3
View File
@@ -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 <business>`).
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
+11 -5
View File
@@ -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}`);
+42 -4
View File
@@ -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;
+268
View File
@@ -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']);
});