import { isIP } from "node:net"; import { resolveFeatureFlag } from "@/shared/utils/featureFlags"; const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); export const PROVIDER_URL_BLOCKED_MESSAGE = "Blocked private or local provider URL"; export const CLOUD_METADATA_BLOCKED_MESSAGE = "Blocked cloud-metadata endpoint"; export const PRIVATE_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS"; // #5066: scoped to provider validation/use. Allows local/private provider endpoints // (127.0.0.1, localhost, LAN) so local-first OpenAI-compatible providers validate, while // cloud-metadata endpoints stay blocked. Defaults ON (OmniRoute is local-first); operators // who only use public providers can disable it to restore strict SSRF blocking. export const LOCAL_PROVIDER_URLS_ENV = "OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS"; // "block-metadata": allow private/LAN hosts but still reject cloud-metadata / link-local // endpoints (the SSRF→IAM-credential pivot). Used by the provider-validation path under the // local-first default; never relaxes the metadata block. export type OutboundUrlGuardMode = "none" | "public-only" | "block-metadata"; export type OutboundUrlGuardErrorCode = "OUTBOUND_URL_GUARD_BLOCKED" | "OUTBOUND_URL_INVALID"; type OutboundUrlGuardErrorInit = { code: OutboundUrlGuardErrorCode; url: string; hostname?: string | null; }; export class OutboundUrlGuardError extends Error { code: OutboundUrlGuardErrorCode; url: string; hostname?: string | null; constructor(message: string, init: OutboundUrlGuardErrorInit) { super(message); this.name = "OutboundUrlGuardError"; this.code = init.code; this.url = init.url; this.hostname = init.hostname ?? null; } } function normalizeHost(hostname: string) { const normalized = hostname.trim().toLowerCase(); if (normalized.startsWith("[") && normalized.endsWith("]")) { return normalized.slice(1, -1); } return normalized; } export function isPrivateHost(hostname: string) { const normalized = normalizeHost(hostname); if (!normalized) return true; if ( normalized === "localhost" || normalized === "0.0.0.0" || normalized === "127.0.0.1" || normalized === "::1" || normalized.endsWith(".localhost") || normalized.endsWith(".local") || // `.internal` is reserved for private use (ICANN-style) and is the // hostname suffix used by GCP/Azure metadata probes // (e.g. `metadata.google.internal`). normalized.endsWith(".internal") || normalized.startsWith("::ffff:") ) { return true; } if (isIP(normalized) === 4) { const octets = normalized.split(".").map((segment) => parseInt(segment, 10)); const [a, b] = octets; if (a === 0 || a === 10 || a === 127) return true; if (a === 169 && b === 254) return true; if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 100 && b >= 64 && b <= 127) return true; return false; } if (isIP(normalized) === 6) { return ( normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") ); } return false; } const CLOUD_METADATA_HOSTNAMES = new Set([ "169.254.169.254", // AWS / GCP / Azure / Oracle IMDS "metadata.google.internal", // GCP "metadata.goog", // GCP "100.100.100.200", // Alibaba Cloud "fd00:ec2::254", // AWS IPv6 IMDS ]); /** * Cloud-metadata and IPv4 link-local (169.254.0.0/16) endpoints are the classic * SSRF→IAM-credential pivot and have no legitimate webhook/automation use case. They are * blocked UNCONDITIONALLY — even when private targets are explicitly opted in. (#3269) */ export function isCloudMetadataHost(hostname: string): boolean { const host = normalizeHost(hostname); if (!host) return false; if (CLOUD_METADATA_HOSTNAMES.has(host)) return true; if (host.startsWith("169.254.")) return true; // IPv4 link-local /16 return false; } export function parseOutboundUrl(input: string | URL) { let url: URL; try { url = input instanceof URL ? input : new URL(String(input)); } catch { throw new OutboundUrlGuardError(`Invalid outbound URL: ${String(input)}`, { code: "OUTBOUND_URL_INVALID", url: String(input), }); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new OutboundUrlGuardError(`Invalid outbound URL protocol for ${url.toString()}`, { code: "OUTBOUND_URL_INVALID", url: url.toString(), hostname: url.hostname || null, }); } if (url.username || url.password) { throw new OutboundUrlGuardError("Blocked outbound URL with embedded credentials", { code: "OUTBOUND_URL_GUARD_BLOCKED", url: url.toString(), hostname: url.hostname || null, }); } return url; } export function parseAndValidatePublicUrl(input: string | URL) { const url = parseOutboundUrl(input); if (isPrivateHost(url.hostname)) { throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, { code: "OUTBOUND_URL_GUARD_BLOCKED", url: url.toString(), hostname: url.hostname || null, }); } return url; } /** * #5066: provider-validation variant. Allows private/LAN hosts (so a local OpenAI-compatible * provider at 127.0.0.1 validates) but ALWAYS rejects cloud-metadata / link-local endpoints — * the classic SSRF→IAM-credential pivot, which is never a legitimate provider endpoint. * Protocol and embedded-credential checks from {@link parseOutboundUrl} still apply. */ export function parseAndValidateNonMetadataUrl(input: string | URL) { const url = parseOutboundUrl(input); if (isCloudMetadataHost(url.hostname)) { throw new OutboundUrlGuardError(CLOUD_METADATA_BLOCKED_MESSAGE, { code: "OUTBOUND_URL_GUARD_BLOCKED", url: url.toString(), hostname: url.hostname || null, }); } return url; } /** * Webhook variant of {@link parseAndValidatePublicUrl}. Webhooks legitimately point at * internal services (n8n, Home Assistant, a LAN box) in Docker/self-hosted deployments, * so the private-host block is gated behind the same explicit opt-in used for private * provider URLs (`OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`, default OFF). Protocol and * embedded-credential checks in {@link parseOutboundUrl} remain unconditional. (#3269) */ export function parseAndValidateWebhookUrl(input: string | URL) { const url = parseOutboundUrl(input); // Cloud-metadata / link-local endpoints are NEVER a valid webhook target — block them // even when the private opt-in is enabled (SSRF→IAM-credential pivot). (#3269) if (isCloudMetadataHost(url.hostname)) { throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, { code: "OUTBOUND_URL_GUARD_BLOCKED", url: url.toString(), hostname: url.hostname || null, }); } if (!arePrivateProviderUrlsAllowed() && isPrivateHost(url.hostname)) { throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, { code: "OUTBOUND_URL_GUARD_BLOCKED", url: url.toString(), hostname: url.hostname || null, }); } return url; } function isTrueValue(raw: unknown): boolean { if (typeof raw !== "string") return false; return TRUE_ENV_VALUES.has(raw.trim().toLowerCase()); } export function arePrivateProviderUrlsAllowed() { // 1) DB override takes precedence — it represents an explicit user toggle in // the dashboard ("Allow Private Provider URLs"). This is critical for the // Electron build (#2575) where the server is spawned with the env value // captured at boot, so subsequent UI toggles only land in the DB and the // env-first ordering would otherwise mask them. try { const dbValue = resolveFeatureFlag(PRIVATE_PROVIDER_URLS_ENV); if (isTrueValue(dbValue)) return true; } catch { // DB not initialized yet — fall through to env-only check. } // 2) Explicit env opt-in (for headless/Docker users who set it before boot). if (isTrueValue(process.env[PRIVATE_PROVIDER_URLS_ENV])) return true; // 3) Legacy escape hatch — disabling the outbound guard implies allowing // private URLs. const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"]; if ( typeof legacyValue === "string" && ["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase()) ) { return true; } return false; } export function getProviderOutboundGuard(): OutboundUrlGuardMode { return arePrivateProviderUrlsAllowed() ? "none" : "public-only"; } /** * #5066: whether provider endpoints on local/private addresses are permitted. Defaults ON * (OmniRoute is local-first — local OpenAI-compatible providers should validate out of the * box). Disable via the `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` flag (DB toggle or env) to * restore strict public-only SSRF blocking. Cloud-metadata stays blocked regardless. */ export function areLocalProviderUrlsAllowed(): boolean { try { const dbValue = resolveFeatureFlag(LOCAL_PROVIDER_URLS_ENV); if (dbValue !== undefined && dbValue !== "") return isTrueValue(dbValue); } catch { // DB not initialized yet — fall through to env / default. } const envValue = process.env[LOCAL_PROVIDER_URLS_ENV]; if (typeof envValue === "string" && envValue !== "") return isTrueValue(envValue); // Default ON. return true; } /** * Guard mode for the provider VALIDATION/use path (not webhooks or remote images). Precedence: * 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users). * 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS). * 3. otherwise → "public-only" (strict). */ export function getProviderValidationGuard(): OutboundUrlGuardMode { if (arePrivateProviderUrlsAllowed()) return "none"; if (areLocalProviderUrlsAllowed()) return "block-metadata"; return "public-only"; }