import test from 'node:test' import assert from 'node:assert/strict' import { mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { normalizeConfig } from '../server/config/schema.js' import { FileConfigStore } from '../server/config/file-config-store.js' import { ClientRegistry } from '../server/clients/client-registry.js' import { buildApp } from '../server/app.js' import { createProxyPolicy } from '../server/proxy/policy.js' import { ConfirmationStore } from '../server/proxy/confirmations.js' async function storeFor(instances = [{ id: 'one', url: 'http://192.168.8.1', auth: { password: 'old-secret' } }]) { const dir = await mkdtemp(path.join(tmpdir(), 'msa-p2-')) const configPath = path.join(dir, 'config.json') const examplePath = path.join(dir, 'example.json') await writeFile(examplePath, JSON.stringify({ instances })) return FileConfigStore.open({ configPath, examplePath, env: {} }) } function fakeRegistry(store, calls = []) { return new ClientRegistry({ createClient: instance => ({ instance, jar: { clear() {} }, clearEphemeralSecret() {}, fetchJson: async () => ({ ok: true, status: 200, data: {} }), ensureAuthenticated: async () => ({ authenticated: true }), request: async (url, options) => { calls.push({ url: String(url), options }); return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) }, }) }).reconcile(store.snapshot.instances) } test('listen schema defaults to loopback and rejects every non-loopback host', () => { assert.equal(normalizeConfig({}, {}).server.host, '127.0.0.1') for (const host of ['0.0.0.0', '192.168.1.2', 'example.com', '::', '169.254.1.1']) { assert.throws(() => normalizeConfig({ server: { host } }, {}), /loopback/i) } for (const host of ['localhost', '127.0.0.2', '127.255.255.255', '::1', '[::1]']) { assert.equal(normalizeConfig({ server: { host } }, {}).server.host, host) } }) test('instance targets allow LAN but reject credentials and local/metadata targets', () => { assert.equal(normalizeConfig({ instances: [{ id: 'lan', url: 'http://192.168.1.1' }] }, {}).instances[0].url, 'http://192.168.1.1') for (const url of [ 'http://169.254.169.254/latest', 'http://169.254.1.1', 'http://127.0.0.1', 'http://localhost', 'http://device.example.test', 'http://[::1]', 'http://[fe80::1]', 'http://[fe90::1]', 'http://[::ffff:127.0.0.1]', 'http://[::ffff:169.254.169.254]', 'http://0x7f000001', 'http://2130706433', 'http://100.100.100.200/latest/meta-data', 'http://[64:ff9b::7f00:1]', 'http://[64:ff9b::a9fe:a9fe]', 'http://168.63.129.16', 'http://[2002:7f00:1::]', 'http://user:***@192.168.1.1', ]) { assert.throws(() => normalizeConfig({ instances: [{ id: 'x', url }] }, {}), /credentials|target/i) } }) test('control plane rejects non-loopback Host and cross-origin browser requests', async t => { const store = await storeFor(); const registry = fakeRegistry(store) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788' } })).statusCode, 200) assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:1' } })).statusCode, 421) assert.equal((await app.inject({ url: '/api/health', headers: { host: 'localhost:8788' } })).statusCode, 421) assert.equal((await app.inject({ url: '/api/health', headers: { host: 'attacker.example' } })).statusCode, 421) assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]evil.example' } })).statusCode, 421) assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:bad:evil' } })).statusCode, 421) assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'https://attacker.example' } })).statusCode, 403) assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'http://127.0.0.1:9999' } })).statusCode, 403) assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'https://127.0.0.1:8788' } })).statusCode, 403) assert.equal((await app.inject({ url: '/api/health', headers: { host: '127.0.0.1:8788', origin: 'http://127.0.0.1:8788' } })).statusCode, 200) }) test('expanded IPv6 loopback config accepts equivalent exact authorities', async t => { const dir = await mkdtemp(path.join(tmpdir(), 'msa-p2-v6-')) const configPath = path.join(dir, 'config.json'), examplePath = path.join(dir, 'example.json') await writeFile(examplePath, JSON.stringify({ server: { host: '0:0:0:0:0:0:0:1', port: 8788 }, instances: [{ id: 'one', url: 'http://192.168.8.1' }] })) const store = await FileConfigStore.open({ configPath, examplePath, env: {} }) const registry = fakeRegistry(store) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]:8788' } })).statusCode, 200) assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788' } })).statusCode, 200) assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788', origin: 'http://[0:0:0:0:0:0:0:1]:8788' } })).statusCode, 200) assert.equal((await app.inject({ url: '/api/health', headers: { host: '[0:0:0:0:0:0:0:1]:8788', origin: 'http://[::1]:8788' } })).statusCode, 200) assert.equal((await app.inject({ url: '/api/health', headers: { host: '[::1]:8789' } })).statusCode, 421) }) test('proxy failures return a stable error without leaking upstream exception details', async t => { const store = await storeFor() const registry = new ClientRegistry({ createClient: instance => ({ instance, request: async () => { throw new Error('SECRET_UPSTREAM_DETAIL') } }) }).reconcile(store.snapshot.instances) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) const response = await app.inject({ url: '/api/proxy/one/api/device' }) assert.equal(response.statusCode, 502) assert.equal(response.body.includes('SECRET_UPSTREAM_DETAIL'), false) }) test('upstream cannot impersonate controlled proxy errors to leak details', async t => { const store = await storeFor() for (const error of [new Error('SECRET invalid proxy path DETAIL'), Object.assign(new Error('SECRET_UNSUPPORTED_DETAIL'), { statusCode: 415 })]) { const registry = new ClientRegistry({ createClient: instance => ({ instance, request: async () => { throw error } }) }).reconcile(store.snapshot.instances) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) const response = await app.inject({ url: '/api/proxy/one/api/device' }) assert.equal(response.statusCode, 502) assert.equal(response.body.includes('SECRET'), false) } }) test('confirmation store bounds pending tokens and invalidates a token on first consume attempt', () => { const store = new ConfirmationStore({ maxEntries: 2, ttlMs: 1000, now: () => 0 }) const first = store.prepare({ id: 1 }).token store.prepare({ id: 2 }); store.prepare({ id: 3 }) assert.equal(store.consume(first, { id: 1 }), false) const token = store.prepare({ id: 4 }).token assert.equal(store.consume(token, { id: 999 }), false) assert.equal(store.consume(token, { id: 4 }), false) }) test('password update has preserve/set/clear semantics and rejects legacy empty password', async () => { const store = await storeFor() assert.equal(store.snapshot.revision, 0) await store.update('one', { name: 'renamed', passwordAction: 'preserve' }) assert.equal(store.snapshot.instances[0].name, 'renamed') assert.equal(store.snapshot.instances[0].auth.password, 'old-secret') assert.equal(store.snapshot.revision, 1) await store.update('one', { name: 'changed' }) assert.equal(store.snapshot.instances[0].auth.password, 'old-secret') assert.equal(store.snapshot.revision, 2) await assert.rejects(store.update('one', { auth: { password: '' } }), /ambiguous/i) await assert.rejects(store.update('one', { passwordAction: 'set', password: '' }), /non-empty/i) await store.update('one', { passwordAction: 'set', password: 'new-secret' }) assert.equal(store.snapshot.instances[0].auth.password, 'new-secret') await store.update('one', { passwordAction: 'clear' }) assert.equal(store.snapshot.instances[0].auth.password, '') }) test('metadata update API never returns passwords and legacy empty password is 400', async t => { const store = await storeFor(); const registry = fakeRegistry(store) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) const bad = await app.inject({ method: 'PUT', url: '/api/instances/one', payload: { auth: { password: '' } } }) assert.equal(bad.statusCode, 400); assert.match(bad.json().error, /ambiguous/i) const good = await app.inject({ method: 'PUT', url: '/api/instances/one', payload: { name: 'safe' } }) assert.equal(good.statusCode, 200); assert.equal(JSON.stringify(good.json()).includes('old-secret'), false) }) test('production proxy policy rejects unknown/auth paths and disallowed methods before upstream', async t => { const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false }); t.after(() => app.close()) assert.equal((await app.inject('/api/proxy/one/api/device')).statusCode, 200) assert.equal((await app.inject('/api/proxy/one/api/x')).statusCode, 403) assert.equal((await app.inject('/api/proxy/one/api/auth/login')).statusCode, 403) assert.equal((await app.inject({ method: 'POST', url: '/api/proxy/one/api/device' })).statusCode, 405) assert.equal(calls.length, 1) }) test('injectable policy can allow fake paths while dangerous writes require bound one-use confirmation', async t => { let now = 1000 const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls) const policy = createProxyPolicy({ readPaths: ['/api/x'], writePaths: { '/api/x': ['POST'] } }) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: policy, confirmationOptions: { now: () => now, ttlMs: 100 } }); t.after(() => app.close()) const missing = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?b=2&a=1', payload: { value: 1 } }) assert.equal(missing.statusCode, 428); assert.equal(calls.length, 0) const prepared = await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x?b=2&a=1', body: { value: 1 } } }) assert.equal(prepared.statusCode, 200); const token = prepared.json().token const tampered = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?b=2&a=1', headers: { 'x-confirmation-token': token }, payload: { value: 2 } }) assert.equal(tampered.statusCode, 403); assert.equal(calls.length, 0) const okToken = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x?b=2&a=1', body: { value: 1 } } })).json().token const ok = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?a=1&b=2', headers: { 'x-confirmation-token': okToken }, payload: { value: 1 } }) assert.equal(ok.statusCode, 200); assert.equal(calls.length, 1) const replay = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x?a=1&b=2', headers: { 'x-confirmation-token': okToken }, payload: { value: 1 } }) assert.equal(replay.statusCode, 403); assert.equal(calls.length, 1) const expiring = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x', body: null } })).json().token now += 101 assert.equal((await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'x-confirmation-token': expiring }, payload: null })).statusCode, 403) assert.equal(calls.length, 1) }) test('confirmation is invalidated by target revision/origin change', async t => { const store = await storeFor(); const calls = []; const registry = fakeRegistry(store, calls) const policy = createProxyPolicy({ writePaths: { '/api/x': ['POST'] } }) const app = await buildApp({ configStore: store, clientRegistry: registry, staticFiles: false, proxyPolicy: policy }); t.after(() => app.close()) const token = (await app.inject({ method: 'POST', url: '/api/proxy-confirmations/prepare', payload: { instanceId: 'one', method: 'POST', path: '/api/x', body: {} } })).json().token await store.update('one', { name: 'revision bump' }) const response = await app.inject({ method: 'POST', url: '/api/proxy/one/api/x', headers: { 'x-confirmation-token': token }, payload: {} }) assert.equal(response.statusCode, 403); assert.equal(calls.length, 0) })