From de5c8423012b6e000710e6c1e9f691111e905f40 Mon Sep 17 00:00:00 2001 From: PizzaV <103120356+pizzav-xyz@users.noreply.github.com> Date: Fri, 5 Jun 2026 06:57:40 +0200 Subject: [PATCH] feat(proxy): auto-fallback proxy selection when validation fails (#3171) Integrated into release/v3.8.11 --- open-sse/services/proxyAutoSelector.ts | 53 ++++ open-sse/utils/proxyDispatcher.ts | 6 +- open-sse/utils/proxyFallback.ts | 378 +++++++++++++++++++++++ open-sse/utils/proxyFetch.ts | 47 ++- src/app/api/proxy-fallback/test/route.ts | 102 ++++++ src/lib/localDb.ts | 1 + src/lib/providers/validation.ts | 72 ++++- tests/unit/proxy-fallback-ssrf.test.ts | 87 ++++++ 8 files changed, 723 insertions(+), 23 deletions(-) create mode 100644 open-sse/services/proxyAutoSelector.ts create mode 100644 open-sse/utils/proxyFallback.ts create mode 100644 src/app/api/proxy-fallback/test/route.ts create mode 100644 tests/unit/proxy-fallback-ssrf.test.ts diff --git a/open-sse/services/proxyAutoSelector.ts b/open-sse/services/proxyAutoSelector.ts new file mode 100644 index 0000000000..3082905096 --- /dev/null +++ b/open-sse/services/proxyAutoSelector.ts @@ -0,0 +1,53 @@ +/** + * proxyAutoSelector.ts — Validation-layer proxy auto-selection service + * + * Wraps the low-level proxy fallback engine for use in the provider validation + * pipeline. When a direct validation request fails with a connectivity error, + * this service finds a working proxy and lets the caller retry with it. + * + * Proxies are discovered and returned for the current request only — they are + * NOT persisted to the proxy registry. Short-term caching (5-minute TTL) is + * handled by the in-memory cache in proxyFallback.ts. + */ + +import { findWorkingProxy, clearProxyFallbackCache } from "@omniroute/open-sse/utils/proxyFallback.ts"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Select a working proxy for the given target URL. + * + * Extracts the hostname, delegates to the underlying proxy fallback engine + * (candidate discovery + parallel testing + caching), and returns a working + * proxy URL or null if none can be found. + * + * The discovered proxy is NOT persisted to the registry — it is returned for + * the current request only. Short-term caching is handled in-memory within + * proxyFallback.ts (5-minute TTL). + * + * @param targetUrl The full URL to find a working proxy for. + * @returns A working proxy URL, or null if none was found. + */ +export async function selectProxyForValidation(targetUrl: string): Promise { + if (!targetUrl) return null; + + let hostname: string; + try { + hostname = new URL(targetUrl).hostname; + if (!hostname) return null; + } catch { + return null; + } + + return findWorkingProxy(hostname, targetUrl); +} + +/** + * Clear the in-memory proxy fallback cache. + * Useful after proxy config changes or in tests. + */ +export function clearProxySelectionCache(): void { + clearProxyFallbackCache(); +} diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index d4981378da..acd399315d 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -215,6 +215,9 @@ export function proxyConfigToUrl( } const config = proxyConfig as ProxyConfigObject; + + // Partial / empty config object — treat as no proxy instead of crashing + if (!config.host) return null; const type = String(config.type || "http").toLowerCase(); // Vercel Relay entries carry the relay URL in `host` — no dispatcher needed; @@ -233,9 +236,6 @@ export function proxyConfigToUrl( "[ProxyDispatcher] SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)" ); } - if (!config.host) { - throw new Error("[ProxyDispatcher] Context proxy host is required"); - } const port = normalizePort(config.port, protocol); diff --git a/open-sse/utils/proxyFallback.ts b/open-sse/utils/proxyFallback.ts new file mode 100644 index 0000000000..8e5b246072 --- /dev/null +++ b/open-sse/utils/proxyFallback.ts @@ -0,0 +1,378 @@ +/** + * proxyFallback.ts — Smart Proxy Fallback for Provider Validation + * + * When a direct fetch to a provider fails and no explicit proxy was configured, + * this module automatically gathers proxy candidates from all available sources, + * tests them in parallel against the provider URL, and returns the first working one. + * Results are cached per hostname to avoid repeated probing. + */ + +import { fetch as undiciFetch } from "undici"; +import { createProxyDispatcher, normalizeProxyUrl } from "./proxyDispatcher.ts"; +import { resolveProxyForScopeFromRegistry, listProxies, listOneproxyProxies } from "@/lib/localDb"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CacheEntry { + proxyUrl: string; + expiresAt: number; +} + +interface ProxyShape { + type: string; + host: string; + port: number; + username?: string; + password?: string; +} + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +const PROXY_FALLBACK_CACHE = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Clear the in-memory proxy fallback cache. + * Useful for testing or admin operations. + */ +export function clearProxyFallbackCache(): void { + PROXY_FALLBACK_CACHE.clear(); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a full proxy URL string from a proxy record's fields. + */ +function proxyRecordToUrl(proxy: ProxyShape): string { + const auth = + proxy.username + ? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password || "")}@` + : ""; + return `${proxy.type}://${auth}${proxy.host}:${proxy.port}`; +} + +/** + * Resolve the environment proxy URL (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY) + * for the given target URL. Returns null if no env proxy is configured or + * the target matches NO_PROXY. + */ +function resolveEnvProxyUrl(targetUrl: string): string | null { + // Honour NO_PROXY + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (noProxy) { + let hostname: string | undefined; + try { + hostname = new URL(targetUrl).hostname.toLowerCase(); + } catch { + return null; + } + const patterns = noProxy + .split(",") + .map((p) => p.trim().toLowerCase()) + .filter(Boolean); + const match = patterns.some((pattern) => { + if (pattern === "*") return true; + if (pattern.includes("*")) { + const re = new RegExp( + "^" + + pattern + .split("*") + .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*") + + "$" + ); + return re.test(hostname!); + } + return hostname === pattern || hostname!.endsWith(`.${pattern}`); + }); + if (match) return null; + } + + let protocol: string; + try { + protocol = new URL(targetUrl).protocol; + } catch { + return null; + } + + const proxyUrl = + protocol === "https:" + ? process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.ALL_PROXY || + process.env.all_proxy + : process.env.HTTP_PROXY || + process.env.http_proxy || + process.env.ALL_PROXY || + process.env.all_proxy; + + if (!proxyUrl) return null; + try { + return normalizeProxyUrl(proxyUrl, "environment proxy"); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Candidate collection +// --------------------------------------------------------------------------- + +/** + * Collect all available proxy candidates from every source: + * 1. Global proxy from registry + * 2. All user-configured proxies from the proxy registry + * 3. Top 5 1proxy marketplace proxies + * 4. Environment proxy (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY) + * + * @param targetUrl Optional. When provided, the env proxy is resolved for this URL. + * @returns Deduplicated array of normalized proxy URLs. + */ +export async function getProxyCandidates(targetUrl?: string): Promise { + const candidates = new Set(); + + // 1. Global proxy from registry + try { + const globalProxy = await resolveProxyForScopeFromRegistry("global"); + if (globalProxy?.proxy) { + candidates.add(proxyRecordToUrl(globalProxy.proxy as ProxyShape)); + } + } catch { + // Table may not exist yet + } + + // 2. All user-configured proxies (include secrets for auth) + try { + const allProxies = await listProxies({ includeSecrets: true }); + for (const p of allProxies) { + if (p.host && p.port) { + candidates.add(proxyRecordToUrl(p as unknown as ProxyShape)); + } + } + } catch { + // Table may not exist yet + } + + // 3. Top 5 1proxy marketplace proxies + try { + const oneproxyProxies = await listOneproxyProxies({ limit: 5 }); + for (const p of oneproxyProxies) { + if (p.host && p.port) { + candidates.add(proxyRecordToUrl(p as unknown as ProxyShape)); + } + } + } catch { + // Table may not exist yet + } + + // 4. Environment proxy (needs targetUrl to determine protocol) + if (targetUrl) { + try { + const envProxy = resolveEnvProxyUrl(targetUrl); + if (envProxy) candidates.add(envProxy); + } catch { + // Ignore env proxy errors + } + } + + return Array.from(candidates); +} + +// --------------------------------------------------------------------------- +// Proxy testing +// --------------------------------------------------------------------------- + +/** + * Test a single proxy against a target URL. + * Makes a lightweight HEAD request through the proxy with a short timeout. + * + * @param proxyUrl The proxy URL (e.g. "http://1.2.3.4:8080") + * @param targetUrl The provider URL to test reachability to + * @param timeoutMs Timeout in milliseconds (default 3000) + * @returns Object with success status and latency in ms + */ +export async function testSingleProxy( + proxyUrl: string, + targetUrl: string, + timeoutMs = 3000 +): Promise<{ ok: boolean; latencyMs: number | null }> { + const start = Date.now(); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + const dispatcher = createProxyDispatcher(proxyUrl); + await undiciFetch(targetUrl, { + method: "HEAD", + signal: controller.signal, + dispatcher, + headers: { + "User-Agent": "OmniRoute/1.0", + }, + }); + + clearTimeout(timeout); + const latencyMs = Date.now() - start; + + // Any response (including 4xx) means the proxy can reach the target + return { ok: true, latencyMs }; + } catch { + return { ok: false, latencyMs: null }; + } +} + +/** + * Bulk test multiple proxies against a target URL. + * Does NOT cache results (for manual API use). + * + * @param targetUrl The provider URL to test reachability to + * @param proxyUrls Array of proxy URLs to test + * @returns Array of results, one per proxy + */ +export async function testProxiesAgainstTarget( + targetUrl: string, + proxyUrls: string[] +): Promise> { + if (proxyUrls.length === 0) return []; + + const results = await Promise.allSettled( + proxyUrls.map(async (proxyUrl) => { + const result = await testSingleProxy(proxyUrl, targetUrl); + return { proxyUrl, ...result }; + }) + ); + + return results.map((r) => + r.status === "fulfilled" + ? r.value + : { proxyUrl: "unknown", ok: false, latencyMs: null } + ); +} + +// --------------------------------------------------------------------------- +// Find working proxy (with caching) +// --------------------------------------------------------------------------- + +/** + * Find a working proxy for the given target hostname and URL. + * + * Collects all proxy candidates, tests them in parallel against the provider + * URL, and returns the first one that responds. Results are cached per + * hostname for 5 minutes to avoid repeated probing. + * + * @param targetHostname The provider hostname (used as cache key) + * @param targetUrl The full provider URL to test against + * @returns A working proxy URL, or null if none found + */ +export async function findWorkingProxy( + targetHostname: string, + targetUrl: string +): Promise { + if (!targetHostname) return null; + + // Check cache first + const cached = PROXY_FALLBACK_CACHE.get(targetHostname); + if (cached) { + if (cached.expiresAt > Date.now()) { + // Cached hit — return the proxy (or null if previously all failed) + return cached.proxyUrl || null; + } + // Expired entry — remove it and re-probe + PROXY_FALLBACK_CACHE.delete(targetHostname); + } + + // Collect candidates + const candidates = await getProxyCandidates(targetUrl); + if (candidates.length === 0) { + return null; + } + + // Test all in parallel, return first that works + const results = await Promise.allSettled( + candidates.map(async (proxyUrl) => { + const { ok } = await testSingleProxy(proxyUrl, targetUrl); + return { proxyUrl, ok }; + }) + ); + + const working = results.find( + (r) => r.status === "fulfilled" && r.value.ok + ); + + if (working && working.status === "fulfilled") { + const proxyUrl = working.value.proxyUrl; + // Cache the working proxy + PROXY_FALLBACK_CACHE.set(targetHostname, { + proxyUrl, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + return proxyUrl; + } + + // All failed — cache the negative result to avoid re-probing too often + PROXY_FALLBACK_CACHE.set(targetHostname, { + proxyUrl: "", + expiresAt: Date.now() + CACHE_TTL_MS, + }); + + return null; +} + +// --------------------------------------------------------------------------- +// Auto-selection fallback (used by resolveProxyForConnection as step 11) +// --------------------------------------------------------------------------- + +/** + * Try to auto-select a working proxy as a last-resort fallback when no + * explicit proxy was configured. This wraps getProxyCandidates() and + * findWorkingProxy() into a single call that returns a result compatible + * with resolveProxyForConnection()'s return type. + * + * @param _connectionId Optional connection ID (reserved for future use). + * @returns A proxy resolution result with level "autoSelect", or null. + */ +export async function selectWorkingProxyFallback( + _connectionId?: string +): Promise<{ + proxy: { type: string; host: string; port: number; username: string; password: string } | null; + level: string; + levelId: string | null; + source: string; +} | null> { + const candidates = await getProxyCandidates(); + if (candidates.length === 0) return null; + + // Use a well-known AI API endpoint as the test target. If a proxy can + // reach this, it is likely suitable for routing AI traffic. + const targetUrl = "https://api.openai.com/v1/models"; + const targetHostname = "api.openai.com"; + + const workingUrl = await findWorkingProxy(targetHostname, targetUrl); + if (!workingUrl) return null; + + try { + const url = new URL(workingUrl); + return { + proxy: { + type: url.protocol.replace(":", "") || "http", + host: url.hostname, + port: parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80), + username: url.username ? decodeURIComponent(url.username) : "", + password: url.password ? decodeURIComponent(url.password) : "", + }, + level: "autoSelect", + levelId: null, + source: "automatic", + }; + } catch { + return null; + } +} diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 1e402cd480..9a1a3461f7 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -12,6 +12,7 @@ import { } from "./proxyDispatcher.ts"; import tlsClient from "./tlsClient.ts"; import { isProxyReachable } from "@/lib/proxyHealth"; +import { findWorkingProxy } from "./proxyFallback.ts"; function isTlsFingerprintEnabled() { return process.env.ENABLE_TLS_FINGERPRINT === "true"; @@ -121,11 +122,21 @@ function noProxyMatch(targetUrl) { } function isLocalAddress(hostname: string): boolean { - if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return true; - if (hostname.startsWith("192.168.")) return true; - if (hostname.startsWith("10.")) return true; - if (hostname.match(/^172\.(1[6-9]|2\d|3[0-1])\./)) return true; - if (hostname.endsWith(".local") || hostname.endsWith(".lan")) return true; + const host = hostname.replace(/^\[/, "").replace(/\]$/, "").replace(/^::ffff:/i, ""); + if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host === "::1") { + return true; + } + if (host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".internal")) return true; + // RFC1918 + loopback + link-local (169.254, incl. cloud metadata 169.254.169.254) + // + CGNAT (100.64/10). 127/8 covers all loopback, not just 127.0.0.1. + if (host.startsWith("192.168.")) return true; + if (host.startsWith("10.")) return true; + if (host.startsWith("127.")) return true; + if (host.startsWith("169.254.")) return true; + if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true; + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host)) return true; + // IPv6 ULA (fc00::/7 → fc/fd prefix) and link-local (fe80::/10) + if (/^f[cd][0-9a-f]*:/i.test(host) || host.startsWith("fe80:")) return true; return false; } @@ -312,7 +323,7 @@ async function patchedFetch( // Prefer the .code property when available (more stable across undici // versions than message-string matching); fall back to substring match // for errors that lack a structured code. - const errCode = (dispatcherError as { code?: string })?.code; + const errCode = (dispatcherError as { code?: unknown })?.code; if ( msg.includes("fetch failed") || errCode === "ECONNREFUSED" || @@ -326,7 +337,29 @@ async function patchedFetch( await new Promise((r) => setTimeout(r, 25 + Math.random() * 50)); continue; } - // All attempts exhausted — fall back to native fetch. + // All attempts exhausted — try proxy fallback before native fetch + if (source === "direct") { + let targetHostname = ""; + try { + targetHostname = new URL(targetUrl).hostname; + } catch { + // ignore + } + if (targetHostname) { + const fallbackProxyUrl = await findWorkingProxy( + targetHostname, + targetUrl + ); + if (fallbackProxyUrl) { + try { + const dispatcher = createProxyDispatcher(fallbackProxyUrl); + return await _undiciDirect(input, { ...options, dispatcher }); + } catch { + // Proxy also failed — fall through to native fetch + } + } + } + } // Preserve original phrase intact for monitoring: "Undici dispatcher failed, falling back to native fetch" console.warn( `[ProxyFetch] Undici dispatcher failed, falling back to native fetch (after retry): ${msg}` diff --git a/src/app/api/proxy-fallback/test/route.ts b/src/app/api/proxy-fallback/test/route.ts new file mode 100644 index 0000000000..bfd8d8235e --- /dev/null +++ b/src/app/api/proxy-fallback/test/route.ts @@ -0,0 +1,102 @@ +/** + * API: Proxy Fallback Test + * POST /api/proxy-fallback/test + * + * Bulk-test proxy candidates against a target provider URL. + * Returns which proxies can reach the target and their latency. + */ + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { isPrivateHost, arePrivateProviderUrlsAllowed } from "@/shared/network/outboundUrlGuard"; +import { + testProxiesAgainstTarget, + getProxyCandidates, +} from "@omniroute/open-sse/utils/proxyFallback"; + +const testSchema = z.object({ + targetUrl: z.string().url("Invalid target URL"), + proxyUrls: z.array(z.string()).optional(), +}); + +/** + * SSRF guard: this route fetches a caller-supplied targetUrl through + * caller-supplied proxies. Even behind management auth, never let it probe + * private / link-local / cloud-metadata hosts (169.254.x, 127/8, 10/8, + * 192.168/16, 172.16/12, ::1, fc00::/7, .internal, …) unless the operator has + * explicitly opted in via OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS. + */ +function blockedPrivateUrl(rawUrl: string): boolean { + if (arePrivateProviderUrlsAllowed()) return false; + try { + return isPrivateHost(new URL(rawUrl).hostname); + } catch { + // Unparseable URL → treat as blocked (fail closed). + return true; + } +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const rawBody = await request.json(); + const validation = validateBody(testSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { targetUrl, proxyUrls: providedUrls } = validation.data; + + // SSRF guard: refuse private/link-local/metadata targets and proxies. + if (blockedPrivateUrl(targetUrl)) { + return NextResponse.json( + { error: "Blocked private or local target URL" }, + { status: 400 } + ); + } + if (providedUrls && providedUrls.some((u) => blockedPrivateUrl(u))) { + return NextResponse.json( + { error: "Blocked private or local proxy URL" }, + { status: 400 } + ); + } + + // Auto-collect candidates if no proxyUrls provided + const proxyUrls = + providedUrls && providedUrls.length > 0 + ? providedUrls + : await getProxyCandidates(targetUrl); + + if (proxyUrls.length === 0) { + return NextResponse.json( + { + results: [], + message: "No proxy candidates available to test. Configure a proxy first.", + }, + { status: 200 } + ); + } + + const results = await testProxiesAgainstTarget(targetUrl, proxyUrls); + + const summary = { + total: results.length, + working: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + }; + + return NextResponse.json({ results, summary }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Failed to test proxy fallback"; + return NextResponse.json( + { error: sanitizeErrorMessage(error) || message }, + { status: 500 } + ); + } +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 533d642624..56d1082fa6 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -172,6 +172,7 @@ export { assignProxyToScope, resolveProxyForConnectionFromRegistry, resolveProxyForProvider, + resolveProxyForScopeFromRegistry, migrateLegacyProxyConfigToRegistry, getProxyHealthStats, bulkAssignProxyToScope, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index d451dc60f5..96e7845464 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { getEmbeddingProvider } from "@omniroute/open-sse/config/embeddingRegistry.ts"; import { getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { selectProxyForValidation } from "@omniroute/open-sse/services/proxyAutoSelector.ts"; import { buildClaudeCodeCompatibleHeaders, buildClaudeCodeCompatibleValidationPayload, @@ -26,7 +27,7 @@ import { getSafeOutboundFetchErrorStatus, safeOutboundFetch, } from "@/shared/network/safeOutboundFetch"; -import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { getProviderOutboundGuard, isPrivateHost } from "@/shared/network/outboundUrlGuard"; import { buildGrokCookieHeader, extractCookieValue, @@ -268,20 +269,65 @@ function buildTokenHeaders(apiKey: string, providerSpecificData: any = {}) { return applyCustomUserAgent(headers, providerSpecificData); } +/** + * Wrapped fetch call that auto-retries with a proxy when the direct connection + * fails. This happens transparently so individual validators don't need to + * think about proxy fallback. + */ +async function fetchWithProxyFallback( + url: string, + init: RequestInit, + presets: typeof SAFE_OUTBOUND_FETCH_PRESETS.validationRead, + isLocal: boolean +): Promise { + try { + return await safeOutboundFetch(url, { + ...presets, + guard: isLocal ? "none" : getProviderOutboundGuard(), + ...init, + }); + } catch (err: unknown) { + // Only attempt proxy fallback for retryable errors (network / timeout) + // and only when the target is not a local / LAN address. + const fetchErr = err as SafeOutboundFetchError; + const isNetworkIssue = + fetchErr?.code === "NETWORK_ERROR" || fetchErr?.code === "TIMEOUT"; + const isRetryable = fetchErr?.isRetryable !== false; + const isValidTarget = !isLocal && isRetryableProxyTarget(url); + + if (isLocal || !isNetworkIssue || !isRetryable) throw err; + if (!isValidTarget) throw err; + + const proxyUrl = await selectProxyForValidation(url); + if (!proxyUrl) throw err; + + return safeOutboundFetch(url, { + ...presets, + guard: isLocal ? "none" : getProviderOutboundGuard(), + ...init, + proxyConfig: proxyUrl, + }); + } +} + +export function isRetryableProxyTarget(url: string): boolean { + try { + const hostname = new URL(url).hostname.toLowerCase(); + // Never proxy-fallback to a private/link-local/metadata host. Delegates to + // the canonical SSRF guard (covers 169.254, 0.0.0.0, 172.16/12, CGNAT, + // IPv6 fc/fd/fe80, .internal — gaps the previous inline check missed). + return !isPrivateHost(hostname); + } catch { + return false; + } +} + async function validationRead(url: string, init: RequestInit, isLocal: boolean = false) { - return safeOutboundFetch(url, { - ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, - guard: isLocal ? "none" : getProviderOutboundGuard(), - ...init, - }); + return fetchWithProxyFallback(url, init, SAFE_OUTBOUND_FETCH_PRESETS.validationRead, isLocal); } async function validationWrite(url: string, init: RequestInit, isLocal: boolean = false) { - return safeOutboundFetch(url, { - ...SAFE_OUTBOUND_FETCH_PRESETS.validationWrite, - guard: isLocal ? "none" : getProviderOutboundGuard(), - ...init, - }); + return fetchWithProxyFallback(url, init, SAFE_OUTBOUND_FETCH_PRESETS.validationWrite, isLocal); } function toValidationErrorResult(error: unknown) { @@ -3891,7 +3937,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi max_tokens: 1, }), }, - isLocal + isLocal, ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid API key" }; @@ -3961,7 +4007,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi max_tokens: 1, }), }, - isLocal + isLocal, ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid API key" }; diff --git a/tests/unit/proxy-fallback-ssrf.test.ts b/tests/unit/proxy-fallback-ssrf.test.ts new file mode 100644 index 0000000000..cc90a0fef9 --- /dev/null +++ b/tests/unit/proxy-fallback-ssrf.test.ts @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Isolate DATA_DIR before importing validation.ts (it initializes the DB on load) +// so the test never touches the developer's real ~/.omniroute database. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-proxy-ssrf-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { isRetryableProxyTarget } = await import("../../src/lib/providers/validation.ts"); +const { isPrivateHost } = await import("../../src/shared/network/outboundUrlGuard.ts"); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +/** + * SSRF hardening for the proxy auto-fallback (PR #3171). When provider + * validation fails, the proxy-fallback path must NEVER auto-discover a proxy and + * re-fetch a private / link-local / cloud-metadata target. The original PR's + * inline host check missed several ranges; both the validation gate and the + * canonical guard must now reject all of them. + */ + +const PRIVATE_TARGETS = [ + "http://169.254.169.254/latest/meta-data/", // AWS/GCP/Azure metadata — the classic SSRF target + "http://169.254.170.2/", // ECS task metadata + "http://0.0.0.0:8080/", + "http://127.0.0.1/", + "http://127.10.20.30/", // 127/8, not just 127.0.0.1 + "http://10.0.0.5/", + "http://192.168.1.1/", + "http://172.16.0.1/", + "http://172.31.255.255/", + "http://100.64.0.1/", // CGNAT + "http://[::1]/", + "http://[fc00::1]/", // IPv6 ULA + "http://[fd12:3456::1]/", + "http://[fe80::1]/", // IPv6 link-local + "http://metadata.google.internal/", // .internal metadata hostname + "http://service.local/", +]; + +const PUBLIC_TARGETS = [ + "https://api.openai.com/v1/models", + "https://api.anthropic.com/v1/messages", + "https://example.com/", + "http://8.8.8.8/", +]; + +test("isRetryableProxyTarget rejects every private / link-local / metadata host", () => { + for (const url of PRIVATE_TARGETS) { + assert.equal( + isRetryableProxyTarget(url), + false, + `${url} must NOT be eligible for proxy-fallback (SSRF)` + ); + } +}); + +test("isRetryableProxyTarget allows public provider targets", () => { + for (const url of PUBLIC_TARGETS) { + assert.equal(isRetryableProxyTarget(url), true, `${url} should be a valid proxy-fallback target`); + } +}); + +test("isRetryableProxyTarget fails closed on an unparseable URL", () => { + assert.equal(isRetryableProxyTarget("not a url"), false); +}); + +test("canonical isPrivateHost covers the SSRF ranges the route guard relies on", () => { + for (const host of [ + "169.254.169.254", + "0.0.0.0", + "127.10.20.30", + "172.16.0.1", + "100.64.0.1", + "fc00::1", + "fe80::1", + "metadata.google.internal", + ]) { + assert.equal(isPrivateHost(host), true, `${host} must be classified private`); + } + assert.equal(isPrivateHost("api.openai.com"), false); +});