Files

58 lines
3.1 KiB
JavaScript

import { proxyToInstance, safeProxyPath, ProxyRequestError } from './service.js'
import { defaultProxyPolicy } from './policy.js'
import { ConfirmationStore, confirmationBinding } from './confirmations.js'
function requestTarget(request, rest) {
const path = safeProxyPath(rest)
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
return `${path}${query}`
}
export function registerProxyRoutes(app, {
findClient,
proxy = proxyToInstance,
policy = defaultProxyPolicy,
configStore,
confirmationStore = new ConfirmationStore(),
}) {
app.post('/api/proxy-confirmations/prepare', async (request, reply) => {
try {
const input = request.body || {}
const client = findClient(input.instanceId, reply)
if (!client) return
const method = String(input.method || '').toUpperCase()
const target = String(input.path || '')
const parsed = new URL(target, 'http://confirmation.local')
if (parsed.origin !== 'http://confirmation.local') throw new Error('invalid confirmation path')
const decision = policy.authorize(method, parsed.pathname)
if (!decision.allowed) return reply.code(decision.statusCode).send({ error: decision.reason })
if (!decision.dangerous) return reply.code(400).send({ error: 'confirmation is only available for dangerous writes' })
const binding = confirmationBinding({ instance: client.instance, revision: configStore.snapshot.revision, method, target, body: input.body })
return confirmationStore.prepare(binding)
} catch (error) { return reply.code(400).send({ error: error.message }) }
})
app.all('/api/proxy/:id/*', async (request, reply) => {
const client = findClient(request.params.id, reply)
if (!client) return
try {
const target = requestTarget(request, request.params['*'])
const pathname = new URL(target, 'http://proxy.local').pathname
const decision = policy.authorize(request.method, pathname)
if (!decision.allowed) return reply.code(decision.statusCode).send({ error: decision.reason })
if (decision.dangerous) {
const binding = confirmationBinding({ instance: client.instance, revision: configStore.snapshot.revision, method: request.method, target, body: request.body })
const token = request.headers['x-confirmation-token']
if (!token) return reply.code(428).send({ error: 'confirmation token required' })
if (!confirmationStore.consume(token, binding)) return reply.code(403).send({ error: 'invalid, expired, replayed, or mismatched confirmation token' })
}
return await proxy({ client, request, reply, rest: request.params['*'] })
} catch (error) {
if (error instanceof ProxyRequestError && error.code === 'invalid_path') return reply.code(400).send({ error: 'invalid proxy path' })
if (error instanceof ProxyRequestError && error.code === 'unsupported_content_type') return reply.code(415).send({ error: 'unsupported proxy content type' })
request.log?.error?.({ err: error }, 'upstream proxy failed')
return reply.code(502).send({ error: 'upstream request failed' })
}
})
}