183 lines
13 KiB
JavaScript
183 lines
13 KiB
JavaScript
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)
|
|
})
|