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