From 0f070e27e3de3f3da08b82399f96968911780fb3 Mon Sep 17 00:00:00 2001 From: KingYuss Date: Wed, 30 Sep 2026 06:15:13 +0100 Subject: [PATCH 1/3] security: Pin resolved upstream addresses to defeat DNS rebinding (#1261) --- src/lib/upstreamTarget.ts | 22 +++++++++--- src/routes/proxyRoutes.ts | 57 ++++++++++++++++++++++++++++++ src/webhooks/webhook.dispatcher.ts | 45 ++++++++++++++++++++++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/lib/upstreamTarget.ts b/src/lib/upstreamTarget.ts index 3b24819e..5353da1c 100644 --- a/src/lib/upstreamTarget.ts +++ b/src/lib/upstreamTarget.ts @@ -29,6 +29,12 @@ export interface UpstreamTargetValidationOptions { allowedHosts?: readonly string[]; } +export interface ValidatedUpstreamTarget { + canonicalUrl: string; + host: string; + addresses: LookupAddress[]; +} + function normalizeHost(host: string): string { const trimmed = host.trim().toLowerCase(); @@ -76,7 +82,7 @@ function matchesAllowEntry(host: string, entry: string): boolean { } function isExplicitlyAllowed(host: string, allowlist: readonly string[]): boolean { - return allowlist.some((entry) => entry !== '*' && matchesAllowEntry(host, entry)); + return allowlist.some((entry) => entry !== '*' && matchesAllowEntry"host, entry)); } function isAllowedHost(host: string, allowlist: readonly string[]): boolean { @@ -145,14 +151,14 @@ export function validateUpstreamBaseUrl( return parseAndValidateBaseUrl(rawUrl, options).canonicalUrl; } -export async function validateResolvedUpstreamTarget( +export async function resolveUpstreamTarget( rawUrl: string, options?: UpstreamTargetValidationOptions, -): Promise { +): Promise { const { canonicalUrl, host, allowlist } = parseAndValidateBaseUrl(rawUrl, options); if (isIP(host) !== 0) { - return canonicalUrl; + return { canonicalUrl, host, addresses: [{ address: host, family: isIP(host) }] }; } let addresses: LookupAddress[]; @@ -175,6 +181,14 @@ export async function validateResolvedUpstreamTarget( } } + return { canonicalUrl, host, addresses }; +} + +export async function validateResolvedUpstreamTarget( + rawUrl: string, + options?: UpstreamTargetValidationOptions, +): Promise { + const { canonicalUrl } = await resolveUpstreamTarget(rawUrl, options); return canonicalUrl; } diff --git a/src/routes/proxyRoutes.ts b/src/routes/proxyRoutes.ts index 95aa2017..24f5ddfa 100644 --- a/src/routes/proxyRoutes.ts +++ b/src/routes/proxyRoutes.ts @@ -1,5 +1,6 @@ import { Router, Request, Response, NextFunction } from 'express'; import { randomUUID } from 'node:crypto'; +import { Agent } from 'undici'; import { ProxyDeps, ProxyConfig, ApiRegistryEntry, EndpointPricing } from '../types/gateway.js'; import { resolveEndpointPrice } from '../data/apiRegistry.js'; import { @@ -15,6 +16,7 @@ import { buildHopByHopSet } from '../lib/hopByHop.js'; import { buildUpstreamTargetUrl, DEFAULT_UPSTREAM_HOST_ALLOWLIST, + resolveUpstreamAddresses, validateResolvedUpstreamTarget, } from '../lib/upstreamTarget.js'; import { @@ -94,6 +96,9 @@ export function createProxyRouter(deps: ProxyDeps): Router { const { billing, rateLimiter, usageStore, registry, circuitBreakerStore, drainState } = deps; const config = resolveConfig(deps.proxyConfig); const router = Router(); + // Cache of pinned undici Agents keyed by `${hostname}:${port}` so that the + // connection reuses the exact IP that passed validation (defeats DNS rebinding). + const pinnedAgentCache = new Map(); const circuitBreaker = new CircuitBreaker({ failureThreshold: env.PROXY_BREAKER_FAILURE_THRESHOLD, cooldownMs: env.PROXY_BREAKER_COOLDOWN_MS, @@ -180,6 +185,7 @@ export function createProxyRouter(deps: ProxyDeps): Router { const wildcardPath = req.params[0] ?? ''; const upstreamTarget = buildUpstreamTargetUrl(apiEntry.base_url, wildcardPath); let safeUpstreamTarget: string; + let pinnedAddresses: string[]; try { safeUpstreamTarget = await validateResolvedUpstreamTarget(upstreamTarget, { @@ -192,6 +198,22 @@ export function createProxyRouter(deps: ProxyDeps): Router { throw new BadGatewayError(message, 'UPSTREAM_TARGET_BLOCKED'); } + // Re-resolve once more, but this time keep the exact addresses so we can + // pin them into the undici Agent's lookup hook. Any subsequent DNS + // answer returned to the connection layer is ignored — the connection + // will only ever dial the addresses captured here. + try { + const parsed = new URL(safeUpstreamTarget); + pinnedAddresses = await resolveUpstreamAddresses(parsed.hostname, { + allowedHosts: config.allowedHosts, + }); + } catch (error) { + const message = error instanceof Error + ? error.message + : 'Configured upstream target is not allowed.'; + throw new BadGatewayError(message, 'UPSTREAM_TARGET_BLOCKED'); + } + // 6. Build forwarded headers — strip hop-by-hop and gateway-internal headers. // buildHopByHopSet() also strips any additional names listed in the // incoming Connection header value (RFC 7230 §6.1). @@ -214,6 +236,39 @@ export function createProxyRouter(deps: ProxyDeps): Router { let upstreamStatus = 502; const timer = startUpstreamTimer(apiEntry.id, req.method); + // Build (or reuse) a pinned undici Agent whose lookup hook returns only + // the addresses that passed validation. TLS SNI/Host remain the + // original hostname because we never rewrite the URL — only the DNS + // resolution step is overridden. + const parsedTarget = new URL(safeUpstreamTarget); + const pinnedPort = parsedTarget.port + ? Number(parsedTarget.port) + : (parsedTarget.protocol === 'https:' ? 443 : 80); + const agentKey = `${parsedTarget.hostname}:${pinnedPort}`; + let pinnedAgent = pinnedAgentCache.get(agentKey); + if (!pinnedAgent) { + const addresses = pinnedAddresses.slice(); + let cursor = 0; + pinnedAgent = new Agent({ + connect: { + // undici calls lookup(hostname, options, callback). We ignore the + // hostname and hand back one of the pre-validated addresses, + // rotating through them for basic failover. + lookup: (_hostname, options, callback) => { + const addr = addresses[cursor % addresses.length]; + cursor += 1; + const family = addr.includes(':') ? 6 : 4; + if (typeof options === 'object' && options !== null && options.all) { + callback(null, [{ address: addr, family }]); + } else { + callback(null, addr, family); + } + }, + }, + }); + pinnedAgentCache.set(agentKey, pinnedAgent); + } + try { const executeWithRetry = async (attempt = 1): Promise => { try { @@ -222,6 +277,8 @@ export function createProxyRouter(deps: ProxyDeps): Router { headers: forwardHeaders, body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body), signal: AbortSignal.timeout(config.timeoutMs), + // @ts-expect-error undici's fetch accepts a dispatcher option + dispatcher: pinnedAgent, }); } catch (e) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method) && attempt < 3) { diff --git a/src/webhooks/webhook.dispatcher.ts b/src/webhooks/webhook.dispatcher.ts index 00ffcbb2..79dc183f 100644 --- a/src/webhooks/webhook.dispatcher.ts +++ b/src/webhooks/webhook.dispatcher.ts @@ -1,9 +1,11 @@ import crypto from 'crypto'; +import { Agent, fetch as undiciFetch } from 'undici'; import { WebhookConfig, WebhookPayload } from './webhook.types.js'; import { WebhookStore } from './webhook.store.js'; import { logger } from '../logger.js'; import { getCorrelationId, getRequestId } from '../utils/asyncContext.js'; import { getEffectiveRetryPolicy } from '../services/webhookRetry.js'; +import { resolveUpstreamTarget, isBlockedAddress } from '../lib/upstreamTarget.js'; let acceptingDispatches = true; const inFlightDispatches = new Set>(); @@ -15,6 +17,37 @@ function signPayload(secret: string, body: string): string { return crypto.createHmac('sha256', secret).update(body).digest('hex'); } +/** + * Builds an undici Agent whose connect.lookup returns only the previously + * validated address. This pins the TCP connection to the exact IP that passed + * SSRF validation, defeating DNS rebinding between validation and connect. + * TLS SNI/Host verification still uses the original hostname because the + * request URL is unchanged. + */ +function createPinnedAgent(hostname: string, pinnedAddress: string, pinnedFamily: number): Agent { + return new Agent({ + connect: { + lookup: (_host, _options, callback) => { + // Ignore the resolver entirely; always return the validated IP. + callback(null, [{ address: pinnedAddress, family: pinnedFamily }]); + }, + }, + }); +} + +async function resolvePinnedTarget(url: string): Promise<{ address: string; family: number }> { + const parsed = new URL(url); + const resolved = await resolveUpstreamTarget(parsed.hostname); + if (!resolved || resolved.addresses.length === 0) { + throw new Error(`Unable to resolve upstream target for ${parsed.hostname}`); + } + const chosen = resolved.addresses[0]; + if (isBlockedAddress(chosen.address)) { + throw new Error(`Blocked upstream address for ${parsed.hostname}: ${chosen.address}`); + } + return { address: chosen.address, family: chosen.family }; +} + function trackDispatch(operation: Promise): Promise { const tracked = operation.finally(() => { inFlightDispatches.delete(tracked as Promise); @@ -85,12 +118,18 @@ export async function dispatchWebhook( let lastError: unknown; for (let attempt = 0; attempt < maxRetries; attempt++) { + let agent: Agent | undefined; try { - const response = await fetch(config.url, { + const pinned = await resolvePinnedTarget(config.url); + const parsed = new URL(config.url); + agent = createPinnedAgent(parsed.hostname, pinned.address, pinned.family); + + const response = await undiciFetch(config.url, { method: 'POST', body, headers, signal: AbortSignal.timeout(10_000), // 10s timeout per attempt + dispatcher: agent, }); if (response.ok) { @@ -112,6 +151,10 @@ export async function dispatchWebhook( `[webhook] Error delivering to ${config.url}, attempt ${attempt + 1}:`, (err as Error).message ); + } finally { + if (agent) { + await agent.close().catch(() => {}); + } } if (attempt < maxRetries - 1) { From f44ce694f7930b49d95320c348b9d85411263201 Mon Sep 17 00:00:00 2001 From: KingYuss Date: Mon, 5 Oct 2026 13:34:53 +0100 Subject: [PATCH 2/3] fix: repair syntax/imports and declare undici dependency for pinned upstream agent --- package-lock.json | 4 ++-- package.json | 1 + src/lib/upstreamTarget.ts | 2 +- src/routes/proxyRoutes.ts | 6 +++--- src/webhooks/webhook.dispatcher.ts | 18 ++++++++++++------ 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9fafe50e..847b2750 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "prisma": "^7.4.1", "prom-client": "^15.1.0", "uuid": "^13.0.0", - "zod": "^4.3.6" + "zod": "^4.3.6", + "undici": "^5.29.0" }, "devDependencies": { "@types/axios": "^0.9.36", @@ -22232,7 +22233,6 @@ "version": "5.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "dev": true, "license": "MIT", "dependencies": { "@fastify/busboy": "^2.0.0" diff --git a/package.json b/package.json index 15513841..bb4b6f61 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "pino": "^10.3.1", "prisma": "^7.4.1", "prom-client": "^15.1.0", + "undici": "^5.29.0", "uuid": "^13.0.0", "zod": "^4.3.6" }, diff --git a/src/lib/upstreamTarget.ts b/src/lib/upstreamTarget.ts index 0c628d74..a6e29ad1 100644 --- a/src/lib/upstreamTarget.ts +++ b/src/lib/upstreamTarget.ts @@ -94,7 +94,7 @@ function matchesAllowEntry(host: string, entry: string): boolean { } function isExplicitlyAllowed(host: string, allowlist: readonly string[]): boolean { - return allowlist.some((entry) => entry !== '*' && matchesAllowEntry"host, entry)); + return allowlist.some((entry) => entry !== '*' && matchesAllowEntry(host, entry)); } function isAllowedHost(host: string, allowlist: readonly string[]): boolean { diff --git a/src/routes/proxyRoutes.ts b/src/routes/proxyRoutes.ts index b9d3dab2..9e1e19a3 100644 --- a/src/routes/proxyRoutes.ts +++ b/src/routes/proxyRoutes.ts @@ -17,7 +17,7 @@ import { buildHopByHopSet } from '../lib/hopByHop.js'; import { buildUpstreamTargetUrl, DEFAULT_UPSTREAM_HOST_ALLOWLIST, - resolveUpstreamAddresses, + resolveUpstreamTarget, validateResolvedUpstreamTarget, } from '../lib/upstreamTarget.js'; import { @@ -215,10 +215,10 @@ export function createProxyRouter(deps: ProxyDeps): Router { // answer returned to the connection layer is ignored — the connection // will only ever dial the addresses captured here. try { - const parsed = new URL(safeUpstreamTarget); - pinnedAddresses = await resolveUpstreamAddresses(parsed.hostname, { + const resolved = await resolveUpstreamTarget(safeUpstreamTarget, { allowedHosts: config.allowedHosts, }); + pinnedAddresses = resolved.addresses.map((entry) => entry.address); } catch (error) { const message = error instanceof Error ? error.message diff --git a/src/webhooks/webhook.dispatcher.ts b/src/webhooks/webhook.dispatcher.ts index a19d1efe..85f21e11 100644 --- a/src/webhooks/webhook.dispatcher.ts +++ b/src/webhooks/webhook.dispatcher.ts @@ -1,13 +1,14 @@ import crypto from 'crypto'; +import dns from 'node:dns/promises'; +import ipRangeCheck from 'ip-range-check'; import { Agent, fetch as undiciFetch } from 'undici'; import { WebhookConfig, WebhookPayload } from './webhook.types.js'; import { WebhookStore } from './webhook.store.js'; import { logger } from '../logger.js'; import { getCorrelationId, getRequestId } from '../utils/asyncContext.js'; import { getEffectiveRetryPolicy, calculateBackoff } from '../services/webhookRetry.js'; -import { resolveUpstreamTarget, isBlockedAddress } from '../lib/upstreamTarget.js'; import { computeJitteredDelay, type RandomSource } from '../lib/retry.js'; -import { validateWebhookUrl, WebhookValidationError } from './webhook.validator.js'; +import { validateWebhookUrl, WebhookValidationError, BLOCKED_RANGES } from './webhook.validator.js'; export const MAX_WEBHOOK_RESPONSE_BYTES = 64 * 1024; @@ -107,12 +108,17 @@ function createPinnedAgent(hostname: string, pinnedAddress: string, pinnedFamily async function resolvePinnedTarget(url: string): Promise<{ address: string; family: number }> { const parsed = new URL(url); - const resolved = await resolveUpstreamTarget(parsed.hostname); - if (!resolved || resolved.addresses.length === 0) { + let addresses: { address: string; family: number }[]; + try { + addresses = await dns.lookup(parsed.hostname, { all: true }); + } catch { + throw new Error(`Unable to resolve upstream target for ${parsed.hostname}`); + } + if (addresses.length === 0) { throw new Error(`Unable to resolve upstream target for ${parsed.hostname}`); } - const chosen = resolved.addresses[0]; - if (isBlockedAddress(chosen.address)) { + const chosen = addresses[0]; + if (ipRangeCheck(chosen.address, BLOCKED_RANGES)) { throw new Error(`Blocked upstream address for ${parsed.hostname}: ${chosen.address}`); } return { address: chosen.address, family: chosen.family }; From 893fe8e562c33544b40bc98c0434f7d8433c1bae Mon Sep 17 00:00:00 2001 From: KingYuss Date: Tue, 6 Oct 2026 16:01:24 +0100 Subject: [PATCH 3/3] fix: cast undici Response to the DOM Response type used by the dispatcher undiciFetch() resolves to undici's own Response type, which TypeScript will not assign to the DOM Response that consumeCappedResponseBody() and the redirect checks in this module declare (TS2345 at lines 259/274/284). Cast at the call site: every member used at runtime (status, ok, headers, body, text) exists on both types, so behaviour is unchanged and no other file needs to change. --- src/webhooks/webhook.dispatcher.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/webhooks/webhook.dispatcher.ts b/src/webhooks/webhook.dispatcher.ts index 85f21e11..cbe97736 100644 --- a/src/webhooks/webhook.dispatcher.ts +++ b/src/webhooks/webhook.dispatcher.ts @@ -241,14 +241,17 @@ let agent: Agent | undefined; const parsed = new URL(config.url); agent = createPinnedAgent(parsed.hostname, pinned.address, pinned.family); - const response = await undiciFetch(config.url, { + // undici's Response is runtime-compatible with every member used + // here (status/headers/body/text); cast to the DOM Response type + // that consumeCappedResponseBody and the redirect checks declare. + const response = (await undiciFetch(config.url, { method: 'POST', body, headers, redirect: 'manual', signal: AbortSignal.timeout(10_000), // 10s timeout per attempt dispatcher: agent, - }); + })) as unknown as Response; const isRedirect = (response.status >= 300 && response.status < 400) ||