Files
stack/packages/webui/tests/proxy-admin-http.test.mjs
jason.woltje 75f6d5533a fix(proxy): review fixes for the Console proxy setup page (#1873)
Security review:
- Release verification cache keys on ctime as well as mtime, in
  nanoseconds. A same-size rewrite with mtime set back now re-hashes and
  reports proxy-install-invalid.

Build review:
- The setup page hides the account panels on any failed accounts read, not
  only proxy-down, and brings them back once the proxy answers.
- The organizations table shows a dash, not a made-up 0, when a bus does
  not answer or sends no model list (orgCatalogue in proxy-admin.mjs).
- A fence clear the bus did not answer keeps the confirmed restart for a
  retry.
- A fence with no readable time names `mosaic proxy fence clear
  --proxy-restarted` on the page, in the refusal table and in the README.
- CLI `init --generate-key` goes through the setup controller and writes
  the same init receipt as the page.
- Status carries the last ten receipts, so the page shows them after a
  reload. An initialized proxy keeps the init form folded.
- README: proxy-admin-busy is per process; the cross-process locks that
  serialize the writes are named.
2026-11-11 16:32:31 -06:00

206 lines
14 KiB
JavaScript

// #1873 Proxy setup routes over the real HTTP boundary: only the instance owner reaches them, a
// pasted key crosses only loopback or HTTPS, and no key reaches a response or a log line.
import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { PROXY_ADMIN_PATHS, keyTransportOk, orgCatalogue } from '../src/proxy-admin.mjs';
import { consoleAccess } from '../src/access.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
import { REFUSALS } from '../../bus/src/refusals.mjs';
const SENTINEL = 'pa-http-sentinel-key-0123456789abcdef';
const ROUTES = [
['status', 'GET'], ['unit/render', 'GET'], ['fetch', 'POST', {}], ['init', 'POST', { generate: true }],
['unit/install', 'POST', {}], ['unit/start', 'POST', {}], ['unit/stop', 'POST', {}], ['unit/restart', 'POST', {}],
['fence/clear', 'POST', {}], ['init-lock/clear', 'POST', {}],
];
function fakeAdmin() {
const calls = [];
const note = name => async (...args) => { calls.push([name, ...args.filter(a => typeof a !== 'function')]); return { ok: name }; };
return {
calls,
unit: 'mosaic-proxy.service',
status: note('status'), render: note('render'), unitAction: note('unitAction'), initLockClear: note('initLockClear'),
fetchStart: () => { calls.push(['fetchStart']); return { state: 'running' }; },
init: async (call, args) => { calls.push(['init', args]); await call('proxy.init', args); return { settings: { port: 18432 }, receipt: { op: 'init' } }; },
fenceClear: async call => { calls.push(['fenceClear']); return call('proxy.fence.clear', { evidence: {} }); },
};
}
const send = (port, path, { method = 'GET', headers = {}, body } = {}) => new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, path, method, headers }, res => {
let text = ''; res.on('data', c => { text += c; }); res.on('end', () => resolve({ status: res.statusCode, text, json: (() => { try { return JSON.parse(text); } catch { return null; } })() }));
});
req.on('error', reject); req.end(body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body));
});
async function devServer(t, options = {}) {
const admin = fakeAdmin(), busCalls = [], logs = [];
const s = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), proxyAdmin: admin,
proxyCall: async (verb, args) => { busCalls.push([verb, args]); return { cleared: true }; }, log: line => logs.push(line), ...options });
t.after(() => new Promise(r => s.close(r)));
const port = s.address().port;
const hostFor = name => `${name}:${port}`;
const headers = (name = '127.0.0.1') => ({ host: hostFor(name), origin: `http://${hostFor(name)}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
return { s, port, admin, busCalls, logs, headers };
}
const hit = (f, route, extra = {}) => send(f.port, `/api/proxy/admin/${route[0]}`, { method: route[1], headers: { ...f.headers(), ...extra }, body: route[2] });
test('the route table is exactly the brief: status, render, fetch, init, four unit verbs, fence clear, lock clear', () => {
assert.deepEqual(PROXY_ADMIN_PATHS, ROUTES.map(([p]) => `/api/proxy/admin/${p}`));
});
test('the dev local owner reaches every route; init and fence clear go to the bus; the page config offers setup', async t => {
const f = await devServer(t);
for (const route of ROUTES) {
const r = await hit(f, route);
assert.equal(r.status, route[0] === 'fetch' ? 202 : 200, route[0]);
}
assert.deepEqual(f.admin.calls.map(c => c[0]), ['status', 'render', 'fetchStart', 'init', 'unitAction', 'unitAction', 'unitAction', 'unitAction', 'fenceClear', 'initLockClear']);
assert.deepEqual(f.admin.calls.filter(c => c[0] === 'unitAction').map(c => c[1]), ['install', 'start', 'stop', 'restart']);
assert.deepEqual(f.busCalls.map(c => c[0]), ['proxy.init', 'proxy.fence.clear']);
assert.deepEqual(f.busCalls[0][1], { generate: true });
const config = await send(f.port, '/api/config', { headers: { host: `127.0.0.1:${f.port}` } });
assert.equal(config.json.proxyAdmin, true);
assert.equal(config.json.proxyAdminSend, true);
for (const line of f.logs.filter(l => l.includes('/api/proxy/admin/'))) assert.match(line, /^\S+ (GET|POST) \/api\/proxy\/admin\/[a-z/-]+ \d{3} user=/);
});
test('without a proxy admin the config offers no setup and the routes answer not-configured', async t => {
const f = await devServer(t, { proxyAdmin: null });
const config = await send(f.port, '/api/config', { headers: { host: `127.0.0.1:${f.port}` } });
assert.equal(Object.hasOwn(config.json, 'proxyAdmin'), false);
assert.deepEqual((await hit(f, ROUTES[0])).json, { error: 'not-configured' });
});
test('writes refuse a foreign Origin, missing Fetch Metadata, a read-only Console and a wrong method', async t => {
const f = await devServer(t);
for (const route of ROUTES.filter(r => r[1] === 'POST')) {
assert.equal((await hit(f, route, { origin: 'http://evil.example' })).status, 403, `${route[0]} cross-origin`);
assert.equal((await hit(f, route, { 'sec-fetch-site': 'cross-site' })).status, 403, `${route[0]} cross-site`);
const noMeta = { ...f.headers() }; delete noMeta['sec-fetch-site'];
assert.equal((await send(f.port, `/api/proxy/admin/${route[0]}`, { method: 'POST', headers: noMeta, body: route[2] })).status, 403, `${route[0]} no fetch metadata`);
assert.equal((await send(f.port, `/api/proxy/admin/${route[0]}`, { method: 'GET', headers: f.headers() })).status, 405, `${route[0]} GET`);
}
assert.deepEqual(f.admin.calls, []);
const readOnly = await devServer(t, { send: null });
for (const route of ROUTES.filter(r => r[1] === 'POST')) assert.deepEqual((await hit(readOnly, route)).json, { error: 'read-only' }, route[0]);
assert.deepEqual(readOnly.admin.calls, []);
assert.equal((await hit(f, ['nope', 'GET'])).status, 404);
});
test('a pasted key: refused over plain-HTTP LAN before it is judged, accepted on loopback; mismatch and bad keys refuse', async t => {
const f = await devServer(t, { devAllowHosts: ['lan.example'] });
const lan = { ...f.headers('lan.example') }; delete lan['sec-fetch-site'];
const paste = { key: SENTINEL, confirm: SENTINEL };
const refused = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: lan, body: paste });
assert.equal(refused.status, 403); assert.deepEqual(refused.json, { error: 'proxy-key-transport' });
const refusedMismatch = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: lan, body: { key: SENTINEL, confirm: 'other' } });
assert.deepEqual(refusedMismatch.json, { error: 'proxy-key-transport' }, 'transport is checked before the key');
const generated = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: lan, body: { generate: true } });
assert.equal(generated.status, 200, 'a LAN page still gets the generated key');
assert.deepEqual(f.busCalls.map(c => c[1]), [{ generate: true }]);
assert.deepEqual((await hit(f, ['init', 'POST', { key: SENTINEL, confirm: 'different' }])).json, { error: 'proxy-key-mismatch' });
assert.deepEqual((await hit(f, ['init', 'POST', { key: 'a\nb', confirm: 'a\nb' }])).json, { error: 'proxy-key-invalid' });
for (const body of [{ key: SENTINEL, confirm: SENTINEL, generate: true }, { generate: true, extra: 1 }, { generate: false }, {}])
assert.deepEqual((await hit(f, ['init', 'POST', body])).json, { error: 'invalid-request' }, JSON.stringify(body));
assert.deepEqual((await hit(f, ['init', 'POST', { generate: true, port: 8317 }])).json, { error: 'proxy-config-invalid' });
assert.deepEqual((await hit(f, ['init', 'POST', { generate: true, rotateGraceMinutes: 0 }])).json, { error: 'proxy-rotate-grace-invalid' });
assert.deepEqual((await hit(f, ['init', 'POST', { generate: true, firstOutputSeconds: 99999 }])).json, { error: 'proxy-first-output-invalid' });
const ok = await hit(f, ['init', 'POST', { ...paste, port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 60 }]);
assert.equal(ok.status, 200);
assert.deepEqual(f.busCalls.at(-1), ['proxy.init', { key: SENTINEL, port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 60 }]);
assert.equal(f.logs.join('\n').includes(SENTINEL), false, 'no key in a log line');
assert.equal(JSON.stringify(f.admin.calls.map(c => c[0])).includes(SENTINEL), false);
});
test('keyTransportOk: HTTPS anywhere, HTTP only on a loopback name from a loopback peer', () => {
assert.equal(keyTransportOk('https://console.example', '10.0.0.5'), true);
assert.equal(keyTransportOk('http://127.0.0.1:7330', '127.0.0.1'), true);
assert.equal(keyTransportOk('http://localhost:7330', '::ffff:127.0.0.1'), true);
assert.equal(keyTransportOk('http://127.0.0.1:7330', '10.0.0.5'), false, 'a forwarded peer');
assert.equal(keyTransportOk('http://lan.example:7330', '127.0.0.1'), false);
assert.equal(keyTransportOk('http://10.0.0.2:7330', '10.0.0.5'), false);
assert.equal(keyTransportOk('not a url', '127.0.0.1'), false);
});
test('a refusal from the controller or bus reaches the page as its code only', async t => {
const f = await devServer(t);
const leak = Object.assign(new Error(`${SENTINEL} /home/owner/.config/mosaic-dev/proxy.json upstream body`), { code: 'proxy-unit-failed' });
f.admin.unitAction = async () => { throw leak; };
f.admin.status = async () => { throw new Error(SENTINEL); };
const unit = await hit(f, ['unit/start', 'POST', {}]);
assert.deepEqual(unit.json, { error: 'proxy-unit-failed' });
const status = await hit(f, ROUTES[0]);
assert.deepEqual(status.json, { error: 'outcome-unknown' });
for (const text of [unit.text, status.text, f.logs.join('\n')]) { assert.equal(text.includes(SENTINEL), false); assert.equal(text.includes('/home/'), false); }
const big = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: f.headers(), body: `{"key":"${SENTINEL}${'x'.repeat(17000)}"}` });
assert.deepEqual(big.json, { error: 'invalid-request' });
const broken = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: f.headers(), body: `{"key":"${SENTINEL}` });
assert.deepEqual(broken.json, { error: 'invalid-request' });
assert.equal(f.logs.join('\n').includes(SENTINEL), false);
});
test('an SSO root owner reaches the routes and may paste over HTTPS; a granted user gets human-required on every route', async t => {
const access = { accessVersion: 1, revision: 1,
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] };
const auth = await createSsoSession(t, { host: 'console.example', users: consoleAccess({ load: () => access, business: () => 'demo' }) });
const admin = fakeAdmin(), busCalls = [];
let bound = 0;
const s = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), proxyAdmin: admin, proxyCall: async (verb, args) => { busCalls.push([verb, args]); return {}; },
userOptions: () => { bound++; return {}; }, log: () => {} });
t.after(() => new Promise(r => s.close(r)));
const port = s.address().port;
const owner = auth.writeHeaders(await auth.signIn(port));
assert.equal((await send(port, '/api/proxy/admin/status', { headers: owner })).status, 200);
assert.equal((await send(port, '/api/config', { headers: owner })).json.proxyAdmin, true);
const pasted = await send(port, '/api/proxy/admin/init', { method: 'POST', headers: owner, body: { key: SENTINEL, confirm: SENTINEL } });
assert.equal(pasted.status, 200, 'HTTPS origin carries a pasted key');
assert.equal(pasted.text.includes(SENTINEL), false);
const before = admin.calls.length;
const alice = auth.writeHeaders(await auth.signIn(port, { sub: 'alice-sub' }));
for (const [route, method, body] of ROUTES) {
const r = await send(port, `/api/proxy/admin/${route}`, { method, headers: alice, body });
assert.equal(r.status, 403, route);
assert.deepEqual(r.json, { error: 'human-required' }, route);
}
assert.equal(bound, 0, 'refused before a user transport is bound');
assert.equal(Object.hasOwn((await send(port, '/api/config', { headers: alice })).json, 'proxyAdmin'), false);
assert.equal(admin.calls.length, before);
});
test('the tester listener gets human-required on every route, read or write', async t => {
const { startTesterSocket } = await import('../src/tester-socket.mjs');
const { testerRequest } = await import('../../cli/src/tester-http.mjs');
const root = mkdtempSync(join(tmpdir(), 'pa-tester-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const admin = fakeAdmin();
let calls = 0;
const s = await startTesterSocket({ dataRoot: root, proxyAdmin: admin, proxyCall: async () => { calls++; return {}; }, log: () => {} });
t.after(() => new Promise(r => s.close(r)));
for (const [route, method, body] of ROUTES) {
const r = await testerRequest({ dataRoot: root, path: `/api/proxy/admin/${route}`, method, body });
assert.equal(r.status, 403, route);
}
assert.equal(calls, 0);
assert.deepEqual(admin.calls, []);
});
test('every refusal code a setup route can answer has a refusal-table entry with a next step', () => {
for (const code of ['human-required', 'proxy-key-transport', 'proxy-key-mismatch', 'proxy-key-invalid', 'proxy-config-invalid', 'proxy-rotate-grace-invalid', 'proxy-first-output-invalid', 'proxy-admin-busy', 'proxy-restart-required', 'proxy-restart-unconfirmed', 'proxy-init-lock-held', 'proxy-init-lock-absent', 'proxy-unit-missing', 'proxy-unit-unhealthy', 'proxy-unit-failed', 'not-configured', 'outcome-unknown', 'invalid-request'])
assert.equal(typeof REFUSALS[code]?.nextStep, 'string', code);
});
test('an organization whose bus does not answer shows no model count, not a made-up 0', async () => {
assert.deepEqual(await orgCatalogue(async () => { throw new Error('down'); }), { status: 'bus-unavailable', models: null });
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'disabled' } })), { status: 'disabled', models: null });
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [{ id: 'a' }, { id: 'b' }] } })), { status: 'enabled', models: 2 });
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [] } })), { status: 'enabled', models: 0 });
});