row 178 (#1676, P19 385): the Console checks a proxy proof header on public Hosts

webui serve takes --proof-header NAME --proof-file PATH, only with the --public-host preview flags.
Before listen it reads the file once through O_NOFOLLOW and fstat: a regular file owned by the
Console uid, mode 0400 or 0600, at least 32 base64url bytes with one optional final LF. Any file
problem gives one fixed refusal that names neither the path nor the contents. The header name can't
collide with Host, Origin, auth or tester names.

On a public Host the guard runs before any auth header is read. It needs exactly one proof header
and compares equal-length buffers with timingSafeEqual. Missing, wrong and repeated proofs get the
same fixed 401. A proof header on another Host or on the tester listener gets 403. The existing
auth, Origin, Fetch Metadata and human write checks are unchanged.

webui-service.sh renders both flags and checks the file's metadata and canonical location without
opening it, and the send-status regex accepts the proof pair. stack-release.sh only gains a comment:
it already carries Console flags through re-pin and rollback, and new tests pin that.

This is a partial fix. Same-uid seats can still read the proof file. Activation (middleware, unit
flags, restart, smoke) is a separate Mos-checked window and isn't part of this commit.

Dewey approved round 2 in #1676 comment 29386, candidate 9300a670. Round 1 changes were in 29374.
This commit is contained in:
rocko authored and Jason Woltje committed 2026-10-20 00:37:08 -05:00
1 parent 3cfa333e43
commit b2b33aee55
11 files changed
+376 -14

No files matched your search

+20
View File
@@ -856,3 +856,23 @@ The plan prints the final Console flags and any dropped `--tester-port N`;
the steps log records a drop too. `pm-on` keeps the pointer's Console flags.
These operations retain their existing Mos/Jason window authority. Nothing in
this row forwards the tester port through the tunnel or changes live units.
Console proxy proof (row 178, #1676): `serve`, `webui-service.sh render` and
`install` accept `--proof-header NAME --proof-file PATH` together, under preview
mode only. The header must differ from Host, Origin, the auth header and
x-mosaic-tester. Serve exits 2 unless PATH is a non-symlink regular file owned by
its uid, mode 0600 or 0400, containing at least 32 base64url characters in one
line (an optional final LF is allowed). Render checks metadata only and refuses
paths inside the checkout or copies; it never opens the file. Keep the value
out of argv, logs and messages. Serve reads it once, and prints only `proxy
proof on` or `proxy proof off`. The public Host needs exactly one matching proof
before sign-in is checked; bad or absent proof gets 401 and no bus or board
call. The proof header on another Host or the tester listener gets 403.
`stack-release.sh` preserves both flags unchanged on re-pin and rollback.
This row installs no proof file, middleware or live flags. Those belong to a
separate Mos plan-checked window: middleware first, Console flags second, then a
public sign-in smoke and a forged-loopback 401 check; rollback drops flags
before middleware. Same-uid callers can still read the file; separate service
uid is Jason's question on #385. The Authentik allowed-uid file remains an
identity input and is never reused for proof.
+23
View File
@@ -1286,3 +1286,26 @@ test('T46 row 181 item 4: every table in the bus schema is in exactly one of fou
for(const n of lists[3])assert.ok(!hasAt(n),`${n} has no at`);
db.close();
});
// Row 178: proof metadata uses temp fixture files, never the installed slot.
async function seedProof(e) {
await seed(e);
const file=join(e.root,'proof.fixture');writeFileSync(file,'fixture_proxy_proof_012345678901234567890123456789',{mode:0o600});
const extra=['--public-host','console.example','--auth-header','x-user','--allow-user','jason','--proof-header','x-console-proof','--proof-file',file];
const p=pointer(e);p.console.push(...extra);writeFileSync(e.pointer,JSON.stringify(p)+'\n');
const unit=join(e.units,CON);edit(unit,` --business acme\n`,` --business acme ${extra.join(' ')}\n`);
return p.console;
}
test('T178 proxy proof header and path survive re-pin and rollback unchanged in the pointer and unit',async t=>{
const e=await mk(t),expected=await seedProof(e);
let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,expected);assert.ok(consoleText(e).includes(expected.join(' ')));
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,expected);assert.ok(consoleText(e).includes(expected.join(' ')));
});
test('T178 proxy proof pair survives a tester-port replacement as well',async t=>{
const e=await mk(t),expected=await seedProof(e);
const r=await run(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...expected,'--tester-port','7333']);
assert.ok(consoleText(e).includes([...expected,'--tester-port','7333'].join(' ')));
});
+28
View File
@@ -971,3 +971,31 @@ and pointer carry it. A target that lacks support refuses an explicit flag befor
stopping anything. Re-pins/rollbacks preserve it for supporting targets and drop
it, visibly in the plan and steps log, for older ones. See docs/TOOLS.md. No live
listener, unit or tunnel changes are part of a build or test.
### Proxy proof (row 178)
Preview mode can additionally require `--proof-header NAME --proof-file PATH`.
Both flags go together and require the three preview flags. The file must be a
regular, non-symlink file owned by the service uid, mode 0600 or 0400, containing
one line of at least 32 base64url characters (`A-Z`, `a-z`, `0-9`, `_`, `-`),
with at most one final LF. Serve reads it once before listening. Only the header
name and file path go on argv; the value is never printed, returned in config,
or logged. Restarting with the new file is how an operator rotates the proof.
For the public Host, exactly one proof header must match before the auth header
is read. Missing, repeated or incorrect proof gets the same 401 `proxy proof
required` and reaches neither bus nor board. The proof header with any other
Host, including the tester listener, gets 403. Without the flags startup says
`proxy proof off`; with them it says `proxy proof on`.
`webui-service.sh render` accepts the pair, checks file metadata and refuses
paths inside the checkout or configured copies directory. It never opens the
file. `stack-release.sh` carries the pair unchanged across re-pin and rollback.
This is a partial boundary. It blocks a caller that cannot read the file; it
cannot block another process running as the Console uid. Separate service uid
is Jason's remaining infrastructure decision on #385. No live proof, middleware
or unit flags are installed by this code change. Activation is a separately
plan-checked Mos window: proxy middleware first, then Console flags and restart;
rollback removes flags before middleware. Do not reuse console-allowed-uid,
which is an identity rather than a proxy secret.
+10 -6
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { createChat } from './chat.mjs';
import { startServer, previewOptions, DEFAULT_BOARD } from './serve.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller } from './bus.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
@@ -11,11 +11,11 @@ import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
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] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
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 }, preview = {};
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, testerPort = null;
while (args.length) {
const flag = args.shift();
@@ -46,10 +46,13 @@ try {
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 if (flag === '--proof-header') proofFlags.proofHeader = value;
else if (flag === '--proof-file') proofFlags.proofFile = value;
else throw new Error(`unknown option: ${flag}`);
}
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
options.preview = previewOptions(preview);
const proof = proofOptions(proofFlags, options.preview);
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
// The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured.
@@ -79,14 +82,14 @@ try {
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
let testerServer = null;
if (testerPort !== null) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
tester: true, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
@@ -95,8 +98,9 @@ try {
} catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
}
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+42 -2
View File
@@ -1,5 +1,6 @@
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
import { timingSafeEqual } from 'node:crypto';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
@@ -211,6 +212,32 @@ export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
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 };
}
// Row 178: read the proof once, without following a final symlink. Refusals never
// include the path, bytes, or an underlying fs error. The tester gets only its header name.
export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
if (proofHeader === undefined && proofFile === undefined) return null;
if (!proofHeader || !proofFile || !preview) throw new Error('proxy proof needs both flags and preview mode');
const header = String(proofHeader).toLowerCase();
if (!HEADER.test(header) || ['host', 'origin', 'x-mosaic-tester', preview.header].includes(header)) {
throw new Error('proxy proof header must be distinct from Host, Origin, tester and auth headers');
}
let fd;
try {
fd = openSync(proofFile, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || ![0o600, 0o400].includes(st.mode & 0o7777)) {
throw new Error('invalid proof metadata');
}
const text = readFileSync(fd, 'utf8');
const line = text.endsWith('\n') ? text.slice(0, -1) : text;
if (line.length < 32 || /[^A-Za-z0-9_-]/.test(line)) throw new Error('invalid proof bytes');
return { header, value: Buffer.from(line, 'ascii') };
} catch {
throw new Error('proxy proof file must be a regular owned 0400 or 0600 file with one base64url line of at least 32 bytes');
} finally {
if (fd !== undefined) closeSync(fd);
}
}
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
async function settingsSend(req, res, { settingsWrite, preview, log }) {
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
@@ -436,7 +463,7 @@ async function projectFileRead(res, read, path, search) {
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
@@ -448,6 +475,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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');
}
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
@@ -456,12 +484,24 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// Presence, even with an empty value: never turn the public listener into a tester ingress.
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
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;
// The proof precedes every auth-header read, including the wrong-Host fence.
if (proof !== null) {
const values = req.headersDistinct[proof.header];
if (!named && values) return json(res, 403, { error: 'proxy proof Host refused' });
if (named) {
const value = values?.length === 1 ? Buffer.from(values[0], 'utf8') : null;
if (!value || value.length !== proof.value.length || !timingSafeEqual(value, proof.value)) {
return json(res, 401, { error: 'proxy proof required' });
}
}
}
// 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' });
@@ -3,6 +3,8 @@
# @PATH@, @NODE@ and the serve flags below are filled in at install time.
# @REPO@ is the read-only copy `install --copy ID` names, and the flags
# pass the canonical checkout as --checkout for the queue read (row 77).
# Row 178: optional --proof-header NAME --proof-file PATH checks proxy proof;
# the value is read once by serve and never carried in this unit.
# Edit the template and reinstall; do not edit the installed unit.
#
# The Console reads the bus through the human transport, so whoever starts
+85
View File
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { request } from 'node:http';
import { testerFixture } from './tester-fixture.mjs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CLI = new URL('../src/cli.mjs', import.meta.url).pathname;
const VALUE = 'fixture_proxy_proof_0123456789abcdef_0123456789';
const PREVIEW = ['--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'];
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'proof-cli-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const file = join(root, 'fixture.token'); writeFileSync(file, VALUE + '\n', { mode: 0o600 });
return { root, file, env: { ...process.env, MOSAIC_CONFIG: join(root, 'missing-config.json') } };
}
test('proof startup refuses partial flags non-preview and invalid fixture file metadata or bytes', t => {
const f = fixture(t), proof = ['--proof-header', 'x-console-proof', '--proof-file', f.file];
const sets = [[...PREVIEW, '--proof-header', 'x-console-proof'], [...PREVIEW, '--proof-file', f.file], proof];
const missing = join(f.root, 'missing'), dir = join(f.root, 'directory'), link = join(f.root, 'link');
mkdirSync(dir); symlinkSync(f.file, link);
for (const file of [missing, dir, link]) sets.push([...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file]);
const cases = [
...sets.map(flags => ({ flags })),
...[['mode', VALUE, 0o644], ['short', 'short_fixture', 0o600], ['newline', VALUE.slice(0, 20) + '\n' + VALUE.slice(20), 0o600], ['bad', VALUE + '!', 0o600], ['two-final-lines', VALUE + '\n\n', 0o600]].map(([name, value, mode]) => {
const file = join(f.root, name); writeFileSync(file, value, { mode });
return { flags: [...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], value };
}),
];
// Simulate another uid via the fixture child's fstat; never chown a host file.
const hook = join(f.root, 'other-owner.mjs');
writeFileSync(hook, `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';const original=fs.fstatSync;fs.fstatSync=(...args)=>{const s=original(...args);s.uid=process.getuid()+1;return s;};syncBuiltinESMExports();`);
cases.push({ flags: [...PREVIEW, ...proof], env: { ...f.env, NODE_OPTIONS: `--import=${hook}` } });
for (const c of cases) {
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
assert.equal(r.status, 2, 'invalid proof startup must refuse'); assert.match(r.stderr, /proof/i);
assert.ok(!r.stderr.includes(VALUE)); assert.ok(!r.stdout.includes(VALUE));
if (c.value) assert.ok(!r.stderr.includes(c.value));
}
});
test('serve argv carries only fixture proof path and header; startup explicitly reports proof on or off', async t => {
const f = fixture(t);
for (const on of [true, false]) {
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', err = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => err += c);
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('fixture CLI startup timeout')), 5000);
const check = () => { if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } };
child.stdout.on('data', check); child.once('exit', () => { clearTimeout(timer); reject(Error('fixture CLI exited before startup')); }); check();
});
assert.match(out, new RegExp(`proxy proof ${on ? 'on' : 'off'}`));
const cmdline = readFileSync(`/proc/${child.pid}/cmdline`, 'utf8');
assert.ok(!cmdline.includes(VALUE)); assert.ok(!out.includes(VALUE)); assert.ok(!err.includes(VALUE));
if (on) { assert.ok(cmdline.includes(f.file)); assert.ok(cmdline.includes('--proof-header')); }
child.kill('SIGTERM'); await once(child, 'close');
}
});
test('CLI applies proof on the main listener and rejects that header on its tester listener', async t => {
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
writeFileSync(file, VALUE, { mode: 0o600 });
const free = createServer(); free.listen(0, '127.0.0.1'); await once(free, 'listening');
const testerPort = free.address().port; await new Promise(r => free.close(r));
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-port', String(testerPort), '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('fixture tester startup timeout')), 5000);
const check = () => { if (output.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } };
child.stdout.on('data', check); child.once('exit', () => { clearTimeout(timer); reject(Error('fixture tester startup refused')); }); check();
});
const port = Number(output.match(/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)/)[1]);
const get = (port, headers) => new Promise((resolve, reject) => {
const q = request({ host: '127.0.0.1', port, path: '/api/config', headers }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, text }));
}); q.on('error', reject); q.end();
});
assert.equal((await get(port, { host: 'console.example', 'x-user': 'jason' })).status, 401);
const good = await get(port, { host: 'console.example', 'x-user': 'jason', 'x-console-proof': VALUE });
assert.equal(good.status, 200); assert.ok(!good.text.includes(VALUE));
assert.equal((await get(testerPort, { host: `127.0.0.1:${testerPort}`, 'x-console-proof': VALUE })).status, 403);
});
+82
View File
@@ -0,0 +1,82 @@
// Row 178: fixture proof values only. No installed proof, proxy or unit is read.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer, request } from 'node:http';
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as serve from '../src/serve.mjs';
import { close } from './fixture.mjs';
const HOST = 'console.example', HEADER = 'x-console-proof', VALUE = 'fixture_proxy_proof_0123456789abcdef_0123456789';
const preview = { host: HOST, header: 'x-user', user: 'jason' };
function ask(port, { host = HOST, headers = {}, path = '/api/bus/tasks', method = 'GET', body } = {}) {
return new Promise((resolve, reject) => {
const q = request({ host: '127.0.0.1', port, path, method, headers: Object.fromEntries(Object.entries({ host, 'x-user': 'jason', ...headers }).filter(([, value]) => value !== undefined)) }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, text, body: JSON.parse(text) }));
}); q.on('error', reject); q.end(body);
});
}
async function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'proof-http-')), file = join(root, 'fixture.token');
t.after(() => rmSync(root, { recursive: true, force: true }));
writeFileSync(file, VALUE + '\n', { mode: 0o600 });
const proof = serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview);
const calls = [], logs = [];
const board = createServer((q, r) => { calls.push('board'); r.setHeader('content-type', 'application/json'); r.end('{"sessions":[]}'); });
await new Promise(r => board.listen(0, '127.0.0.1', r)); t.after(() => close(board));
const server = await serve.startServer({ port: 0, preview, proof, board: `http://127.0.0.1:${board.address().port}`,
bus: { tasks: async () => { calls.push('tasks'); return []; } }, send: async () => { calls.push('send'); return { id: 'receipt-1' }; }, log: line => logs.push(line) });
t.after(() => close(server));
return { port: server.address().port, calls, logs, proof, file };
}
test('proxy proof admits named reads and sends, reads its fixture once, and never exposes the value', async t => {
const f = await setup(t), headers = { [HEADER]: VALUE };
writeFileSync(f.file, 'changed_fixture_value_012345678901234567890');
assert.equal((await ask(f.port, { headers })).status, 200);
assert.equal((await ask(f.port, { headers, path: '/api/board' })).status, 200);
const sent = await ask(f.port, { headers: { ...headers, origin: `https://${HOST}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, method: 'POST', path: '/api/bus/message', body: JSON.stringify({ to: 'coder', body: 'fixture' }) });
assert.equal(sent.status, 200); assert.equal(sent.body.id, 'receipt-1');
assert.deepEqual(f.calls, ['tasks', 'board', 'send']);
const config = await ask(f.port, { headers, path: '/api/config' });
assert.equal(config.status, 200); assert.ok(!config.text.includes(VALUE));
assert.ok(!f.logs.join('\n').includes(VALUE));
});
test('proxy proof refuses missing wrong short or repeated proof before auth and before any read', async t => {
const f = await setup(t);
for (const headers of [{}, { [HEADER]: 'x'.repeat(VALUE.length) }, { [HEADER]: VALUE.slice(1) }, { [HEADER]: [VALUE, VALUE] }]) {
for (const path of ['/api/bus/tasks', '/api/board']) {
const result = await ask(f.port, { headers, path });
assert.equal(result.status, 401); assert.deepEqual(result.body, { error: 'proxy proof required' });
assert.ok(!result.text.includes(VALUE));
}
}
// Writes are fenced by the same proof check, before the human send callback.
for (const headers of [{}, { [HEADER]: 'x'.repeat(VALUE.length) }, { [HEADER]: VALUE.slice(1) }, { [HEADER]: [VALUE, VALUE] }]) {
const result = await ask(f.port, { headers: { ...headers, origin: `https://${HOST}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, path: '/api/bus/message', method: 'POST', body: JSON.stringify({ to: 'coder', body: 'fixture' }) });
assert.equal(result.status, 401); assert.deepEqual(result.body, { error: 'proxy proof required' });
assert.ok(!result.text.includes(VALUE));
}
const noIdentity = await ask(f.port, { headers: { 'x-user': 'wrong' } });
assert.equal(noIdentity.status, 401); assert.deepEqual(noIdentity.body, { error: 'proxy proof required' });
assert.deepEqual(f.calls, []); assert.ok(!f.logs.join('\n').includes(VALUE));
});
test('proxy proof header is refused on other Hosts and on the tester listener', async t => {
const f = await setup(t);
for (const host of [`127.0.0.1:${f.port}`, 'other.example']) {
assert.equal((await ask(f.port, { host, headers: { 'x-user': undefined, [HEADER]: VALUE } })).status, 403);
}
assert.equal((await ask(f.port, { host: `127.0.0.1:${f.port}`, headers: { 'x-user': undefined, [HEADER]: '' } })).status, 403);
const server = await serve.startServer({ port: 0, tester: true, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
t.after(() => close(server));
const result = await ask(server.address().port, { host: `127.0.0.1:${server.address().port}`, headers: { 'x-user': undefined, [HEADER]: VALUE }, path: '/api/config' });
assert.equal(result.status, 403); assert.deepEqual(f.calls, []);
});
test('proof options reject unsafe or colliding header names without disclosing file contents', t => {
const f = mkdtempSync(join(tmpdir(), 'proof-options-')); t.after(() => rmSync(f, { recursive: true, force: true }));
const file = join(f, 'fixture.token'); writeFileSync(file, VALUE, { mode: 0o600 });
for (const proofHeader of ['host', 'origin', 'x-user', 'x-mosaic-tester', 'bad header', '']) {
assert.throws(() => serve.proofOptions({ proofHeader, proofFile: file }, preview), /proof/);
}
chmodSync(file, 0o400);
assert.equal(serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview).header, HEADER);
});
+53 -1
View File
@@ -5,7 +5,7 @@ import { createServer } from 'node:http';
import { once } from 'node:events';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
// scripts/webui-service.sh (row 75, #1569). Nothing here starts a unit or
@@ -756,3 +756,55 @@ test('status names the copy and whether refactor has moved past it, and says whe
// status reads; it calls nothing that changes the unit.
assert.deepEqual(readFileSync(join(dir, readdirSync(dir).find((d) => d.startsWith('si-')), 'calls.txt'), 'utf8').split('\n').filter(Boolean).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service( |$)/.test(c)), []);
});
// Row 178: only fixture paths and stand-in systemctl; render reads metadata, not content.
test('proxy proof render carries both flags but never opens the fixture file', async t => {
const dir = tmp(t), file = join(dir, 'proof.fixture');
// Invalid proof bytes deliberately prove render does not validate/read contents.
writeFileSync(file, 'not-a-valid-proof', { mode: 0o600 });
const r = await run(dir, ['render', ...PREVIEW, '--proof-header', 'X-Console-Proof', '--proof-file', file]);
assert.equal(r.status, 0, r.stderr);
assert.ok(execStart(r.stdout).includes(` --proof-header X-Console-Proof --proof-file ${file}`));
assert.ok(!r.stdout.includes('not-a-valid-proof')); assert.equal(r.stderr, '');
});
test('proxy proof render refuses incomplete non-preview missing unsafe-mode and repo/copy paths', async t => {
const dir = tmp(t), file = join(dir, 'proof.fixture'); writeFileSync(file, 'fixture', { mode: 0o600 });
const insideDir = mkdtempSync(join(REPO, 'packages/webui/tests/proof-render-'));
t.after(() => rmSync(insideDir, { recursive: true, force: true }));
const inside = join(insideDir, 'proof.fixture'); writeFileSync(inside, 'fixture', { mode: 0o600 });
const copy = join(copyRoot(dir), 'proof.fixture'); writeFileSync(copy, 'fixture', { mode: 0o600 });
const flags = ['--proof-header', 'x-console-proof', '--proof-file', file];
const spaced = join(dir, 'proof fixture'); writeFileSync(spaced, 'fixture', { mode: 0o600 });
const proxy = ['--public-host', 'console.example.com', '--auth-header', 'x-user', '--allow-user', 'jason'];
const sets = [[...PREVIEW, '--proof-header', 'x-console-proof'], [...PREVIEW, '--proof-file', file], flags,
...[join(dir, 'missing'), inside, copy].map(f => [...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', f]),
// C1 (#1676 comment 29374): each case isolates a render guard with otherwise valid metadata.
[...PREVIEW, '--proof-header', 'bad_header', '--proof-file', file],
[...proxy, '--proof-header', 'X-User', '--proof-file', file],
[...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', relative(process.cwd(), file)],
[...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', spaced]];
for (const args of sets) {
const r = await run(dir, ['render', ...args]); assert.equal(r.status, 1); assert.equal(r.stdout, ''); assert.match(r.stderr, /proof/);
}
// Fake the render caller's uid, leaving the fixture's actual owner unchanged.
const bin = quiet(dir), id = join(bin, 'id');
writeFileSync(id, `#!/bin/sh\nif [ "$#" -eq 1 ] && [ "$1" = -u ]; then printf '%s\\n' '${process.getuid() + 1}'; else exec /usr/bin/id "$@"; fi\n`, { mode: 0o755 });
try {
const owner = await run(dir, ['render', ...PREVIEW, ...flags], bin);
assert.equal(owner.status, 1); assert.equal(owner.stdout, ''); assert.match(owner.stderr, /proof/);
} finally { rmSync(id); }
chmodSync(file, 0o644);
const bad = await run(dir, ['render', ...PREVIEW, ...flags]); assert.equal(bad.status, 1); assert.match(bad.stderr, /proof/);
});
test('proxy proof status retains send and tester reporting with both proof flags', async t => {
const dir = tmp(t), unitDir = join(dir, 'units'), file = join(dir, 'proof.fixture');
writeFileSync(file, 'fixture', { mode: 0o600 });
const port = await closedPort(), bin = standIn(dir);
const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send', '--tester-port', '1024', '--proof-header', 'x-console-proof', '--proof-file', file], bin);
assert.equal(r.status, 0, r.stderr);
const status = await run(dir, ['status', '--dir', unitDir], bin);
assert.equal(status.status, 0, status.stderr);
assert.match(status.stdout, /send: on \(--send; human messages to roles\)/);
assert.match(status.stdout, /tester: http:\/\/127\.0\.0\.1:1024/);
});
+2
View File
@@ -635,6 +635,8 @@ flags_drift() {
[ -z "$d" ] || refuse unit-flags-drift "the units differ from the pointer; settle it first:$d"
}
# Row 178: --proof-header and --proof-file are an opaque pair in P_CONSOLE.
# Carry them unchanged on re-pin and rollback; never read the proof value.
# Resolve desired flags independently of the pointer and effective units, so a hand-edited
# unit still refuses drift and a failed attempt can restore exactly the captured stack.
# Read the target's own Console, because old copies cannot start with the new flag.
+29 -5
View File
@@ -46,7 +46,9 @@
# (row 83), taken only with the preview set: the unit's Console then sends
# human messages to roles and needs the system config at start. systemd
# expands % and $ and splits on spaces, so each value is held to a plain
# character set and refused otherwise. None is a secret.
# character set and refused otherwise. --proof-header NAME --proof-file PATH
# go together under preview; only the path is carried here, never the secret.
# render checks ownership, mode and location without opening the proof file.
#
# There is no start, stop, enable or restart verb. The Console reads the
# main listener's bus through the human transport; the optional loopback
@@ -66,7 +68,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -89,6 +91,9 @@ PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
SEND=0
PROOF_HEADER=""
PROOF_FILE=""
PROOF_SET=0
SERVE_SET=0
parse_flags() {
@@ -106,6 +111,8 @@ parse_flags() {
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;
--allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--send) SEND=1; SERVE_SET=1; shift ;;
*) usage ;;
esac
@@ -177,6 +184,23 @@ serve_flags() {
fi
flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER"
fi
if [ "$PROOF_SET" = 1 ]; then
[ -n "$PROOF_HEADER" ] && [ -n "$PROOF_FILE" ] && [ "$preview" -eq 3 ] || die "proxy proof needs both flags and preview mode"
[[ "$PROOF_HEADER" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,63}$ ]] || die "invalid proxy proof header"
case "${PROOF_HEADER,,}" in host|origin|x-mosaic-tester|"${AUTH_HEADER,,}") die "proxy proof header must differ from Host, Origin, tester and auth headers" ;; esac
case "$PROOF_FILE" in /*) ;; *) die "proxy proof file must be an absolute path" ;; esac
case "$PROOF_FILE" in *[!A-Za-z0-9/._+-]*) die "proxy proof path cannot be carried by the unit" ;; esac
# Metadata only: render never opens the proof, even to validate its contents.
[ ! -L "$PROOF_FILE" ] && [ -f "$PROOF_FILE" ] || die "invalid proxy proof file metadata"
local proof_path copies_path proof_mode proof_uid
proof_path="$(realpath -e -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
copies_path="$(realpath -m -- "$COPIES")" || die "cannot resolve copies directory"
case "$proof_path" in "$REPO"|"$REPO"/*|"$copies_path"|"$copies_path"/*) die "proxy proof file must be outside the repository and copies" ;; esac
proof_mode="$(stat -c '%a' -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
proof_uid="$(stat -c '%u' -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
{ [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
fi
[ "$SEND" = 0 ] || flags="$flags --send"
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
echo "$flags"
@@ -361,10 +385,10 @@ status_unit() {
else
echo "copy: none; the unit runs from $wd"
fi
# render puts --send right after the allowed user, before the optional
# tester port. The allowed user
# render puts --send after the allowed user and optional proof pair,
# before the tester port. The allowed user
# starts with a letter or digit (N5), so it is never the value --send.
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]* --send( --tester-port [0-9]+)?$' "$target"; then
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
echo "send: off (read-only)"