Files
stack/packages/webui/tests/proof.test.mjs
T
rocko b2b33aee55 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.
2026-10-20 00:37:08 -05:00

83 lines
5.9 KiB
JavaScript

// 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);
});