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 (base17ea0dbc, no drift on the 4 paths through60c8dcf9). 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 ofa55915f0with 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:
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user