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.
206 lines
14 KiB
JavaScript
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 });
|
|
});
|