58 lines
3.1 KiB
JavaScript
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' })
|
|
}
|
|
})
|
|
}
|