From 44263c65f2d2d302f683577c9ac8b4531a24872e Mon Sep 17 00:00:00 2001 From: Tom Wilson Date: Wed, 10 Jun 2026 16:49:43 -0400 Subject: [PATCH] fix(proxy): use undici's own fetch so the dispatcher version matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real root cause of the proxy 502s (UND_ERR_INVALID_ARG): the code passed an npm-undici `Agent` as the `dispatcher` to Node's **built-in** global `fetch`. Built-in fetch validates the dispatcher against its own bundled undici's Dispatcher class; an Agent from the npm `undici` package is a different class when the two undici versions differ, so it is rejected with UND_ERR_INVALID_ARG — before any lookup/connector code runs. That is why #42 (lookup forms) and #43 (connector override) both failed with the exact same error, and why it worked locally (local Node's bundled undici matches npm undici 6.26). Fix: call undici's own `fetch` (imported from the same package as Agent), so dispatcher and fetch are guaranteed the same version. SSRF validation and connector IP-pinning are unchanged. Verified locally end-to-end (undici fetch + pinned dispatcher + full response body read): https/http public hosts return 200; SSRF targets remain blocked. tsc + build clean; proxy.test.ts 52/52. Co-Authored-By: Claude Fable 5 --- src/routes/proxy.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/routes/proxy.ts b/src/routes/proxy.ts index 631960a..d947577 100644 --- a/src/routes/proxy.ts +++ b/src/routes/proxy.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { Agent, buildConnector } from 'undici'; +import { Agent, buildConnector, fetch as undiciFetch } from 'undici'; import { config } from '../config.js'; import { validateProxyRequest, ProxyRequest } from '../utils/validation.js'; import { resolveAndValidate } from '../utils/ssrf.js'; @@ -141,14 +141,14 @@ proxy.post('/', async (c) => { let currentUrl = proxyReq.url; let redirectCount = 0; - let response: Response | null = null; + let response: Awaited> | null = null; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { while (redirectCount <= maxRedirects) { - const fetchOptions: RequestInit & { dispatcher?: Agent } = { + const fetchOptions: Parameters[1] = { method: redirectCount === 0 ? method : 'GET', // Follow redirects with GET headers: outgoingHeaders, signal: controller.signal, @@ -159,10 +159,13 @@ proxy.post('/', async (c) => { // Only include body on first request and if method supports it if (redirectCount === 0 && proxyReq.body !== undefined && !['GET', 'HEAD'].includes(method)) { - fetchOptions.body = JSON.stringify(proxyReq.body); + fetchOptions!.body = JSON.stringify(proxyReq.body); } - response = await fetch(currentUrl, fetchOptions); + // Use undici's own fetch so the dispatcher (also undici) is the SAME + // version. Passing an npm-undici Agent to Node's built-in global fetch + // fails with UND_ERR_INVALID_ARG when the two undici versions differ. + response = await undiciFetch(currentUrl, fetchOptions); // Check for redirect if ([301, 302, 303, 307, 308].includes(response.status)) {