const BLOCKED_REQUEST_HEADERS = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization', 'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'accept-encoding', ]) const BLOCKED_RESPONSE_HEADERS = new Set([...BLOCKED_REQUEST_HEADERS, 'set-cookie', 'content-encoding']) export class ProxyRequestError extends Error { constructor(code) { super(code === 'unsupported_content_type' ? 'unsupported proxy content type' : 'invalid proxy path') this.code = code } } const invalidPath = () => new ProxyRequestError('invalid_path') export function safeProxyPath(rest = '') { const raw = String(rest || '') if (!raw || raw.startsWith('/') || raw.includes('\\')) throw invalidPath() let value = raw const assertSafe = candidate => { if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw invalidPath() if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw invalidPath() } for (let depth = 0; depth < 16; depth += 1) { assertSafe(value) let decoded try { decoded = decodeURIComponent(value) } catch { throw invalidPath() } if (decoded === value) return `/${raw}` value = decoded } // Refuse inputs whose semantics still change after a bounded number of decodes. throw invalidPath() } export function proxyHeaders(input = {}) { const headers = new Headers() for (const [name, value] of Object.entries(input)) { if (!BLOCKED_REQUEST_HEADERS.has(name.toLowerCase()) && value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : String(value)) } return headers } export async function proxyToInstance({ client, request, reply, rest }) { const targetPath = safeProxyPath(rest) const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '' const target = new URL(`${targetPath}${query}`, new URL(client.instance.url).origin) const origin = new URL(client.instance.url).origin if (target.origin !== origin) throw invalidPath() const headers = proxyHeaders(request.headers) let body if (!['GET', 'HEAD'].includes(request.method)) { const type = (headers.get('content-type') || '').split(';')[0].trim().toLowerCase() if (request.body === undefined && !type) body = undefined else if (type.startsWith('multipart/')) throw new ProxyRequestError('unsupported_content_type') else if (type === 'application/json' || type.endsWith('+json')) body = request.body === undefined ? undefined : JSON.stringify(request.body) else if (type.startsWith('text/') || type === 'application/x-www-form-urlencoded') body = request.body else if (Buffer.isBuffer(request.body)) body = request.body else throw new ProxyRequestError('unsupported_content_type') } const upstream = await client.request(target.toString(), { method: request.method, headers, body, redirect: 'manual' }) reply.code(upstream.status) upstream.headers.forEach((value, name) => { if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value) }) return reply.send(Buffer.from(await upstream.arrayBuffer())) }