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 91415650..a6e29ad1 100644 --- a/src/lib/upstreamTarget.ts +++ b/src/lib/upstreamTarget.ts @@ -31,6 +31,12 @@ export interface UpstreamTargetValidationOptions { allowedHosts?: readonly string[]; } +export interface ValidatedUpstreamTarget { + canonicalUrl: string; + host: string; + addresses: LookupAddress[]; +} + function isDevelopmentOrTestEnvironment(): boolean { const nodeEnv = process.env.NODE_ENV ?? 'development'; return nodeEnv === 'development' || nodeEnv === 'test'; @@ -41,7 +47,6 @@ export function getDefaultUpstreamHostAllowlist(): readonly string[] { ? DEVELOPMENT_UPSTREAM_HOST_ALLOWLIST : DEFAULT_UPSTREAM_HOST_ALLOWLIST; } - function normalizeHost(host: string): string { const trimmed = host.trim().toLowerCase(); @@ -168,14 +173,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[]; @@ -198,6 +203,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 cafcf43d..9e1e19a3 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 { @@ -16,6 +17,7 @@ import { buildHopByHopSet } from '../lib/hopByHop.js'; import { buildUpstreamTargetUrl, DEFAULT_UPSTREAM_HOST_ALLOWLIST, + resolveUpstreamTarget, validateResolvedUpstreamTarget, } from '../lib/upstreamTarget.js'; import { @@ -106,6 +108,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, @@ -192,6 +197,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, { @@ -204,6 +210,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 resolved = await resolveUpstreamTarget(safeUpstreamTarget, { + allowedHosts: config.allowedHosts, + }); + pinnedAddresses = resolved.addresses.map((entry) => entry.address); + } 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). @@ -226,6 +248,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 { @@ -234,6 +289,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 f8fa54a3..cbe97736 100644 --- a/src/webhooks/webhook.dispatcher.ts +++ b/src/webhooks/webhook.dispatcher.ts @@ -1,11 +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 { 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; @@ -68,7 +71,6 @@ export async function consumeCappedResponseBody( return ''; } } - let acceptingDispatches = true; const inFlightDispatches = new Set>(); @@ -86,6 +88,42 @@ 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); + 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 = 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 }; +} + function trackDispatch(operation: Promise): Promise { const tracked = operation.finally(() => { inFlightDispatches.delete(tracked as Promise); @@ -196,15 +234,24 @@ export async function dispatchWebhook( } for (let attempt = 0; attempt < maxRetries; attempt++) { +let agent: Agent | undefined; attemptsMade = attempt + 1; 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); + + // 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) || @@ -250,6 +297,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) {