From c0c7f79a2142a79e118f84aff535d6d0cbbb2fd8 Mon Sep 17 00:00:00 2001 From: Amal Date: Fri, 17 Jul 2026 00:21:49 -0700 Subject: [PATCH] feat: local LLM support via Ollama (OpenAI-compatible, behind ENABLE_OLLAMA) Mechanical port from amal66/mike@b3166dd (apps/api -> backend): - lib/llm/providers/ollama.ts: Ollama adapter registered through the provider registry, gated by ENABLE_OLLAMA (off by default) - lib/llm/baseUrl.ts: OPENAI_BASE_URL resolution with SSRF guard (OPENAI_ALLOW_LOCAL_BASE_URL) so the OpenAI adapter can point at a local Ollama server - lib/privateIp.ts: shared private/reserved IP classifier used by the base-URL guard - lib/llm/openai.ts: fetch openAIResponsesUrl() instead of the hard-coded api.openai.com constant - lib/llm/index.ts: register Ollama in registerBuiltinProviders() when ENABLE_OLLAMA=true - .env.example: document the opt-in Ollama configuration - tests for the ollama gate, base-URL resolution, and IP classifier Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC --- backend/.env.example | 12 ++ backend/src/lib/__tests__/privateIp.test.ts | 107 +++++++++++++++ backend/src/lib/llm/__tests__/baseUrl.test.ts | 58 +++++++++ backend/src/lib/llm/baseUrl.ts | 50 +++++++ backend/src/lib/llm/index.ts | 23 +++- backend/src/lib/llm/openai.ts | 4 +- .../llm/providers/__tests__/ollama.test.ts | 32 +++++ backend/src/lib/llm/providers/ollama.ts | 86 ++++++++++++ backend/src/lib/privateIp.ts | 122 ++++++++++++++++++ 9 files changed, 488 insertions(+), 6 deletions(-) create mode 100644 backend/src/lib/__tests__/privateIp.test.ts create mode 100644 backend/src/lib/llm/__tests__/baseUrl.test.ts create mode 100644 backend/src/lib/llm/baseUrl.ts create mode 100644 backend/src/lib/llm/providers/__tests__/ollama.test.ts create mode 100644 backend/src/lib/llm/providers/ollama.ts create mode 100644 backend/src/lib/privateIp.ts diff --git a/backend/.env.example b/backend/.env.example index d006aa6955..3878c14030 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,6 +16,18 @@ R2_BUCKET_NAME=mike GEMINI_API_KEY=your-gemini-key ANTHROPIC_API_KEY=your-anthropic-key OPENAI_API_KEY=your-openai-key +# Optional: OpenAI-compatible gateway endpoint, e.g. OpenRouter/Azure proxy/Ollama-compatible bridge. +# Must be https in production unless OPENAI_ALLOW_LOCAL_BASE_URL=true. +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_ALLOW_LOCAL_BASE_URL=false +# Local models via Ollama (opt-in, off by default). To enable: set ENABLE_OLLAMA=true +# and point the OpenAI-compatible client at your local Ollama server: +# ENABLE_OLLAMA=true +# OPENAI_BASE_URL=http://localhost:11434/v1 +# OPENAI_ALLOW_LOCAL_BASE_URL=true +# OPENAI_API_KEY=ollama # Ollama accepts any non-empty string +# OLLAMA_MODELS=my-custom-model # optional; extra models beyond the defaults +ENABLE_OLLAMA=false RESEND_API_KEY=your-resend-key USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret diff --git a/backend/src/lib/__tests__/privateIp.test.ts b/backend/src/lib/__tests__/privateIp.test.ts new file mode 100644 index 0000000000..2839f4dba4 --- /dev/null +++ b/backend/src/lib/__tests__/privateIp.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { isBlockedIp, isPrivateIpv4, isPrivateIpv6 } from "../privateIp"; + +describe("isPrivateIpv4", () => { + it("blocks RFC1918 / loopback / link-local / CGNAT / reserved ranges", () => { + for (const ip of [ + "0.0.0.0", + "10.0.0.1", + "10.255.255.255", + "127.0.0.1", + "100.64.0.1", + "100.127.255.255", + "169.254.169.254", // cloud metadata + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "192.0.2.1", + "198.18.0.1", + "198.19.255.255", + "224.0.0.1", // multicast + "255.255.255.255", + ]) { + expect(isPrivateIpv4(ip), ip).toBe(true); + } + }); + + it("allows public addresses", () => { + for (const ip of ["8.8.8.8", "1.1.1.1", "93.184.216.34", "203.0.100.5"]) { + expect(isPrivateIpv4(ip), ip).toBe(false); + } + }); + + it("fails closed on malformed input", () => { + for (const ip of ["", "1.2.3", "1.2.3.4.5", "abc"]) { + expect(isPrivateIpv4(ip), JSON.stringify(ip)).toBe(true); + } + }); +}); + +describe("isPrivateIpv6", () => { + it("blocks loopback / unspecified / ULA / link-local", () => { + for (const ip of [ + "::1", + "::", + "fc00::1", + "fd12:3456::1", + "fe80::1", + "feaf::1", + ]) { + expect(isPrivateIpv6(ip), ip).toBe(true); + } + }); + + it("blocks dotted IPv4-mapped addresses that embed a private IPv4", () => { + expect(isPrivateIpv6("::ffff:192.168.0.1")).toBe(true); + expect(isPrivateIpv6("::ffff:127.0.0.1")).toBe(true); + expect(isPrivateIpv6("::ffff:169.254.169.254")).toBe(true); + expect(isPrivateIpv6("::ffff:8.8.8.8")).toBe(false); + }); + + it("blocks hex-form IPv4-mapped addresses (::ffff:c0a8:0001)", () => { + expect(isPrivateIpv6("::ffff:c0a8:0001")).toBe(true); // 192.168.0.1 + expect(isPrivateIpv6("::ffff:7f00:0001")).toBe(true); // 127.0.0.1 + expect(isPrivateIpv6("::ffff:a9fe:a9fe")).toBe(true); // 169.254.169.254 + expect(isPrivateIpv6("::ffff:0808:0808")).toBe(false); // 8.8.8.8 + }); + + it("blocks NAT64 (64:ff9b::/96) addresses that embed a private IPv4", () => { + expect(isPrivateIpv6("64:ff9b::c0a8:1")).toBe(true); // 192.168.0.1 + expect(isPrivateIpv6("64:ff9b::10.0.0.1")).toBe(true); // dotted tail + expect(isPrivateIpv6("64:ff9b::a9fe:a9fe")).toBe(true); // 169.254.169.254 + expect(isPrivateIpv6("64:ff9b::808:808")).toBe(false); // 8.8.8.8 + }); + + it("blocks 6to4 (2002::/16) addresses that embed a private IPv4", () => { + expect(isPrivateIpv6("2002:c0a8:0001::")).toBe(true); // 192.168.0.1 + expect(isPrivateIpv6("2002:7f00:0001::")).toBe(true); // 127.0.0.1 + expect(isPrivateIpv6("2002:a9fe:a9fe::")).toBe(true); // 169.254.169.254 + expect(isPrivateIpv6("2002:0808:0808::")).toBe(false); // 8.8.8.8 + }); + + it("allows global unicast addresses", () => { + for (const ip of [ + "2606:4700:4700::1111", + "2001:4860:4860::8888", + "2620:fe::fe", + ]) { + expect(isPrivateIpv6(ip), ip).toBe(false); + } + }); +}); + +describe("isBlockedIp", () => { + it("routes IPv4 / IPv6 to the right classifier", () => { + expect(isBlockedIp("8.8.8.8")).toBe(false); + expect(isBlockedIp("10.0.0.1")).toBe(true); + expect(isBlockedIp("::1")).toBe(true); + expect(isBlockedIp("2002:c0a8:0001::")).toBe(true); + expect(isBlockedIp("2606:4700:4700::1111")).toBe(false); + }); + + it("fails closed on non-IP input", () => { + expect(isBlockedIp("example.com")).toBe(true); + expect(isBlockedIp("")).toBe(true); + expect(isBlockedIp("not-an-ip")).toBe(true); + }); +}); diff --git a/backend/src/lib/llm/__tests__/baseUrl.test.ts b/backend/src/lib/llm/__tests__/baseUrl.test.ts new file mode 100644 index 0000000000..bb031cc76c --- /dev/null +++ b/backend/src/lib/llm/__tests__/baseUrl.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { openAIResponsesUrl, resolveOpenAIBaseUrl } from "../baseUrl"; + +describe("resolveOpenAIBaseUrl", () => { + it("defaults to the official OpenAI v1 endpoint", () => { + expect(resolveOpenAIBaseUrl()).toBe("https://api.openai.com/v1"); + expect(openAIResponsesUrl()).toBe("https://api.openai.com/v1/responses"); + }); + + it("normalizes trailing slashes", () => { + expect(resolveOpenAIBaseUrl("https://gateway.example.com/v1///")).toBe( + "https://gateway.example.com/v1", + ); + }); + + it("allows local http endpoints outside production", () => { + expect(resolveOpenAIBaseUrl("http://localhost:11434/v1", "development")).toBe( + "http://localhost:11434/v1", + ); + }); + + it("rejects http endpoints in production", () => { + expect(() => + resolveOpenAIBaseUrl("http://gateway.example.com/v1", "production"), + ).toThrow(/https in production/); + }); + + it("rejects unsupported protocols", () => { + expect(() => resolveOpenAIBaseUrl("file:///tmp/openai")).toThrow( + /http or https/, + ); + }); + + it("rejects private/reserved IP literals in production (SSRF)", () => { + for (const host of [ + "https://10.0.1.50/v1", + "https://172.16.5.4/v1", + "https://192.168.1.10/v1", + "https://169.254.169.254/v1", // cloud metadata + "https://127.0.0.1/v1", + "https://[::1]/v1", + ]) { + expect(() => resolveOpenAIBaseUrl(host, "production")).toThrow( + /private or reserved IP|localhost/, + ); + } + }); + + it("allows a public IP / hostname in production", () => { + expect(resolveOpenAIBaseUrl("https://8.8.8.8/v1", "production")).toBe( + "https://8.8.8.8/v1", + ); + expect( + resolveOpenAIBaseUrl("https://gateway.example.com/v1", "production"), + ).toBe("https://gateway.example.com/v1"); + }); +}); diff --git a/backend/src/lib/llm/baseUrl.ts b/backend/src/lib/llm/baseUrl.ts new file mode 100644 index 0000000000..47ff5a3c89 --- /dev/null +++ b/backend/src/lib/llm/baseUrl.ts @@ -0,0 +1,50 @@ +import net from "net"; +import { isBlockedIp } from "../privateIp"; + +const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; + +function isLocalHostname(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname.endsWith(".local") + ); +} + +export function resolveOpenAIBaseUrl( + raw = process.env.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL, + nodeEnv = process.env.NODE_ENV, +): string { + const parsed = new URL(raw); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("OPENAI_BASE_URL must use http or https"); + } + if (nodeEnv === "production" && parsed.protocol !== "https:") { + throw new Error("OPENAI_BASE_URL must use https in production"); + } + if (nodeEnv === "production" && process.env.OPENAI_ALLOW_LOCAL_BASE_URL !== "true") { + // URL hostnames wrap IPv6 in brackets ([::1]); strip them for net.isIP. + const host = parsed.hostname.replace(/^\[|\]$/g, ""); + if (isLocalHostname(parsed.hostname)) { + throw new Error( + "OPENAI_BASE_URL cannot point at localhost in production unless OPENAI_ALLOW_LOCAL_BASE_URL=true", + ); + } + // Reject IP literals in private/reserved ranges (SSRF) — parity with the + // MCP egress guard. DNS hostnames are operator config, not resolved here. + if (net.isIP(host) !== 0 && isBlockedIp(host)) { + throw new Error( + "OPENAI_BASE_URL cannot point at a private or reserved IP in production unless OPENAI_ALLOW_LOCAL_BASE_URL=true", + ); + } + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); +} + +export function openAIResponsesUrl(baseUrl = resolveOpenAIBaseUrl()): string { + return `${baseUrl}/responses`; +} diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 3adc851f54..59cd133cc8 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -23,10 +23,12 @@ export * from "./models"; * Register a third-party LLM provider so it is available via * streamChatWithTools() and completeText(). * - * OpenAI-compatible providers can be added the same way — call - * registerProvider()/registerApiKeyProvider(), no core edits. + * Local models via Ollama are built in but opt-in: set ENABLE_OLLAMA=true (see + * setupOllamaFromEnv below). Other OpenAI-compatible providers can be added the + * same way — call registerProvider()/registerApiKeyProvider(), no core edits. */ export { registerProvider } from "./registry"; +import { setupOllamaFromEnv } from "./providers/ollama"; // --------------------------------------------------------------------------- // Register built-in providers @@ -35,8 +37,16 @@ export { registerProvider } from "./registry"; // test files mock e.g. "../claude" before this module loads, so the mocked // function is captured here and ends up in the registry. -/** Register the built-in LLM providers (claude/gemini/openai). */ -export function registerBuiltinProviders(): void { +/** + * Register the built-in LLM providers (claude/gemini/openai), plus local + * (Ollama) models when opted in via ENABLE_OLLAMA. + * + * Reads process.env by default and is exported so it can be exercised against + * a controlled env. + */ +export function registerBuiltinProviders( + env: NodeJS.ProcessEnv = process.env, +): void { registerProvider({ id: "claude", matchesModel: (m) => m.startsWith("claude"), @@ -58,6 +68,11 @@ export function registerBuiltinProviders(): void { complete: completeOpenAIText, models: { main: OPENAI_MAIN_MODELS, mid: OPENAI_MID_MODELS, low: OPENAI_LOW_MODELS }, }); + + // Local models — opt-in via ENABLE_OLLAMA. + if (setupOllamaFromEnv(env)) { + console.log("[llm] Ollama provider enabled (ENABLE_OLLAMA=true)"); + } } registerBuiltinProviders(); diff --git a/backend/src/lib/llm/openai.ts b/backend/src/lib/llm/openai.ts index 3bcffc1977..8d0dbdb647 100644 --- a/backend/src/lib/llm/openai.ts +++ b/backend/src/lib/llm/openai.ts @@ -6,9 +6,9 @@ import type { StreamChatParams, StreamChatResult, } from "./types"; +import { openAIResponsesUrl } from "./baseUrl"; import { createRawLlmStreamRecorder, logRawLlmStream } from "./rawStreamLog"; -const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"; const MAX_OUTPUT_TOKENS = 16384; const COURTLISTENER_CITATION_REMINDER_TOOL_NAMES = new Set([ "courtlistener_find_in_case", @@ -173,7 +173,7 @@ async function createResponse(params: { apiKey: string; signal?: AbortSignal; }): Promise { - const response = await fetch(OPENAI_RESPONSES_URL, { + const response = await fetch(openAIResponsesUrl(), { method: "POST", headers: { Authorization: `Bearer ${params.apiKey}`, diff --git a/backend/src/lib/llm/providers/__tests__/ollama.test.ts b/backend/src/lib/llm/providers/__tests__/ollama.test.ts new file mode 100644 index 0000000000..0e805ede56 --- /dev/null +++ b/backend/src/lib/llm/providers/__tests__/ollama.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { setupOllamaFromEnv } from "../ollama"; +import { getRegisteredProvider } from "../../registry"; + +describe("setupOllamaFromEnv (ENABLE_OLLAMA gate)", () => { + it("does nothing when the flag is unset or not 'true'", () => { + expect(setupOllamaFromEnv({})).toBe(false); + expect(setupOllamaFromEnv({ ENABLE_OLLAMA: "false" })).toBe(false); + expect(setupOllamaFromEnv({ ENABLE_OLLAMA: "1" })).toBe(false); + }); + + it("registers the Ollama provider when ENABLE_OLLAMA=true", () => { + expect(setupOllamaFromEnv({ ENABLE_OLLAMA: "true" })).toBe(true); + const provider = getRegisteredProvider("ollama"); + expect(provider).toBeDefined(); + // A default local model routes to this provider. + expect(provider?.matchesModel("llama3.3")).toBe(true); + // A cloud model does not. + expect(provider?.matchesModel("gpt-4o")).toBe(false); + }); + + it("registers extra models from OLLAMA_MODELS", () => { + setupOllamaFromEnv({ + ENABLE_OLLAMA: "true", + OLLAMA_MODELS: "my-custom-model, another-model", + }); + const provider = getRegisteredProvider("ollama"); + expect(provider?.matchesModel("my-custom-model")).toBe(true); + expect(provider?.matchesModel("another-model")).toBe(true); + }); +}); diff --git a/backend/src/lib/llm/providers/ollama.ts b/backend/src/lib/llm/providers/ollama.ts new file mode 100644 index 0000000000..fced9e8335 --- /dev/null +++ b/backend/src/lib/llm/providers/ollama.ts @@ -0,0 +1,86 @@ +/** + * Ollama provider — routes Ollama model IDs through the OpenAI-compatible + * streaming backend pointed at a local Ollama server. + * + * Prerequisites: + * OPENAI_BASE_URL=http://localhost:11434/v1 + * OPENAI_ALLOW_LOCAL_BASE_URL=true + * OPENAI_API_KEY=ollama # Ollama accepts any non-empty string + * + * Call setupOllama() once from your app bootstrap before any LLM calls: + * + * import { setupOllama } from "lib/llm/providers/ollama"; + * setupOllama(); + * // or pass a custom list: + * setupOllama({ models: ["llama3.3", "phi4", "my-custom-model"] }); + * + * This pattern demonstrates how to add any OpenAI-compatible provider + * (OpenRouter, Mistral AI, Together AI, Anyscale, etc.) without modifying + * any core file — only a call to registerProvider() and registerApiKeyProvider(). + */ + +import { registerProvider } from "../registry"; +import { streamOpenAI, completeOpenAIText } from "../openai"; +import { registerApiKeyProvider } from "../../../core/apiKeyProviders"; + +const DEFAULT_MODELS = [ + // Meta Llama + "llama3.3", "llama3.2", "llama3.1", "llama3", + // Mistral + "mistral", "mistral-nemo", "mistral-small", + // Microsoft Phi + "phi4", "phi4-mini", + // Alibaba Qwen + "qwen2.5", "qwen2.5-coder", + // Google Gemma + "gemma3", "gemma2", + // DeepSeek + "deepseek-r1", "deepseek-coder-v2", +]; + +export interface OllamaSetupOptions { + /** Model IDs to register (merged with defaults). */ + models?: string[]; +} + +/** + * Opt-in bootstrap: register the Ollama provider iff `ENABLE_OLLAMA=true`. + * + * Gated (not always-on) because registering ~20 local model IDs would pollute + * the model picker on cloud deployments that have no Ollama server, and because + * running Ollama also requires `OPENAI_ALLOW_LOCAL_BASE_URL=true` — a deliberate + * SSRF-guard relaxation we only want when a self-hoster asks for it. Optional + * `OLLAMA_MODELS` (comma-separated) adds custom models to the defaults. + * + * Returns true if it registered the provider. `env` is injectable for testing. + */ +export function setupOllamaFromEnv( + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (env.ENABLE_OLLAMA !== "true") return false; + const models = (env.OLLAMA_MODELS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + setupOllama({ models }); + return true; +} + +export function setupOllama(options: OllamaSetupOptions = {}): void { + const id = "ollama"; + const allModels = [...new Set([...DEFAULT_MODELS, ...(options.models ?? [])])]; + const modelSet = new Set(allModels); + + // No dedicated API key — Ollama reuses OPENAI_API_KEY (set to any string). + registerApiKeyProvider(id, ["OPENAI_API_KEY"]); + + registerProvider({ + id, + matchesModel: (m) => modelSet.has(m), + // Ollama exposes an OpenAI Responses-compatible API on port 11434. + // The base URL is resolved from OPENAI_BASE_URL by the openai adapter. + stream: streamOpenAI, + complete: completeOpenAIText, + models: { main: allModels, mid: [], low: [] }, + }); +} diff --git a/backend/src/lib/privateIp.ts b/backend/src/lib/privateIp.ts new file mode 100644 index 0000000000..c149b29efb --- /dev/null +++ b/backend/src/lib/privateIp.ts @@ -0,0 +1,122 @@ +import net from "net"; + +/** + * SSRF guard helpers: classify an IP literal as private/reserved/unsafe. + * Shared by the MCP connector egress check and the OpenAI base-URL validation + * so both reject the same ranges. Conservative: anything unparseable or + * unrecognized is treated as blocked. + */ +export function isPrivateIpv4(ip: string): boolean { + const parts = ip.split(".").map((part) => Number.parseInt(part, 10)); + if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) { + return true; + } + const [a, b] = parts; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 192 && b === 0) || + (a === 198 && (b === 18 || b === 19)) || + a >= 224 + ); +} + +/** + * Expand an IPv6 literal (possibly using `::` compression and/or a trailing + * dotted-quad IPv4 tail) into its eight 16-bit groups. Returns null if the + * input is not a well-formed IPv6 literal. Any embedded dotted IPv4 tail is + * folded into the final two hextets so callers can read the embedded address + * uniformly. + */ +function expandIpv6Groups(ip: string): number[] | null { + let s = ip.toLowerCase(); + const zone = s.indexOf("%"); + if (zone !== -1) s = s.slice(0, zone); + + // Fold a trailing dotted-quad IPv4 tail (e.g. `::ffff:1.2.3.4`) into two + // hex groups so the address is a pure list of hextets. + const dotted = s.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (dotted) { + const octets = dotted.slice(1, 5).map((o) => Number.parseInt(o, 10)); + if (octets.some((o) => o > 255)) return null; + const hi = ((octets[0] << 8) | octets[1]).toString(16); + const lo = ((octets[2] << 8) | octets[3]).toString(16); + s = s.slice(0, dotted.index) + `${hi}:${lo}`; + } + + const halves = s.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + + let groups: string[]; + if (halves.length === 2) { + const fill = 8 - head.length - tail.length; + if (fill < 0) return null; + groups = [...head, ...Array(fill).fill("0"), ...tail]; + } else { + groups = head; + } + if (groups.length !== 8) return null; + + const nums = groups.map((g) => Number.parseInt(g || "0", 16)); + if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff)) { + return null; + } + return nums; +} + +function embeddedIpv4(hi: number, lo: number): string { + return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; +} + +export function isPrivateIpv6(ip: string): boolean { + const normalized = ip.toLowerCase(); + if (normalized === "::1" || normalized === "::") return true; + if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; + // Link-local fe80::/10 — the first hextet ranges fe80..febf (four hex + // digits). The narrower /^fe[89ab]:/ form was a bug: it only matched the + // unrelated hextet "fe8:" and let fe80::1 through. + if (/^fe[89ab][0-9a-f]:/.test(normalized)) return true; + + const groups = expandIpv6Groups(normalized); + if (!groups) return false; + + // IPv4-mapped ::ffff:0:0/96 — covers both dotted (`::ffff:1.2.3.4`) and + // hex (`::ffff:c0a8:0001`) forms. The address *is* the embedded IPv4. + if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) { + return isPrivateIpv4(embeddedIpv4(groups[6], groups[7])); + } + // NAT64 well-known prefix 64:ff9b::/96 — last 32 bits are the target IPv4. + if ( + groups[0] === 0x64 && + groups[1] === 0xff9b && + groups[2] === 0 && + groups[3] === 0 && + groups[4] === 0 && + groups[5] === 0 + ) { + return isPrivateIpv4(embeddedIpv4(groups[6], groups[7])); + } + // 6to4 2002::/16 — the embedded IPv4 sits in the second and third hextets. + if (groups[0] === 0x2002) { + return isPrivateIpv4(embeddedIpv4(groups[1], groups[2])); + } + return false; +} + +/** + * True if `ip` is a private/reserved/unsafe address. Non-IP input returns true + * (fail closed) — callers should pass resolved IP literals. + */ +export function isBlockedIp(ip: string): boolean { + const family = net.isIP(ip); + if (family === 4) return isPrivateIpv4(ip); + if (family === 6) return isPrivateIpv6(ip); + return true; +}