TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import { isIP } from "node:net";2import { lookup } from "node:dns/promises";34/**5 * SSRF guard for user-supplied endpoint URLs. The PolyLLM server fetches these URLs with the user's6 * headers, so without a guard a user could probe the server's own network (cloud metadata, Redis,7 * the database…). Private, loopback, link-local and special-purpose ranges are rejected unless the8 * operator sets `ALLOW_PRIVATE_ENDPOINTS=1` (self-hosted PolyLLM next to Ollama / LM Studio).9 *10 * Pure helpers (`isPrivateIp`, `isBlockedHostname`, `checkEndpointUrlSync`) are unit-tested;11 * `assertEndpointUrlAllowed` additionally resolves the hostname so `evil.example → 10.0.0.1` is caught.12 */1314export const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);1516export function privateEndpointsAllowed(env: NodeJS.ProcessEnv = process.env): boolean {17 return env.ALLOW_PRIVATE_ENDPOINTS === "1" || env.ALLOW_PRIVATE_ENDPOINTS === "true";18}1920function ipv4ToInt(ip: string): number | null {21 const parts = ip.split(".");22 if (parts.length !== 4) return null;23 let n = 0;24 for (const p of parts) {25 if (!/^\d{1,3}$/.test(p)) return null;26 const v = Number(p);27 if (v > 255) return null;28 n = n * 256 + v;29 }30 return n;31}3233const V4_BLOCKS: [string, number][] = [34 ["0.0.0.0", 8], // "this" network35 ["10.0.0.0", 8], // private36 ["100.64.0.0", 10], // carrier-grade NAT37 ["127.0.0.0", 8], // loopback38 ["169.254.0.0", 16], // link-local (cloud metadata lives here)39 ["172.16.0.0", 12], // private40 ["192.0.0.0", 24], // IETF protocol assignments41 ["192.0.2.0", 24], // TEST-NET-142 ["192.168.0.0", 16], // private43 ["198.18.0.0", 15], // benchmarking44 ["198.51.100.0", 24], // TEST-NET-245 ["203.0.113.0", 24], // TEST-NET-346 ["224.0.0.0", 4], // multicast47 ["240.0.0.0", 4], // reserved + broadcast48];4950function inV4Block(ip: number, base: string, bits: number): boolean {51 const b = ipv4ToInt(base)!;52 const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;53 return ((ip & mask) >>> 0) === ((b & mask) >>> 0);54}5556export function isPrivateIpv4(ip: string): boolean {57 const n = ipv4ToInt(ip);58 if (n === null) return false;59 return V4_BLOCKS.some(([base, bits]) => inV4Block(n, base, bits));60}6162/** Expand an IPv6 literal into 8 hextets (handles `::` and embedded IPv4). Returns null when malformed. */63function expandIpv6(ip: string): number[] | null {64 let s = ip.toLowerCase();65 const zone = s.indexOf("%");66 if (zone >= 0) s = s.slice(0, zone);67 // embedded IPv4 tail → two hextets68 const lastColon = s.lastIndexOf(":");69 if (s.includes(".") && lastColon >= 0) {70 const v4 = ipv4ToInt(s.slice(lastColon + 1));71 if (v4 === null) return null;72 s = `${s.slice(0, lastColon)}:${((v4 >>> 16) & 0xffff).toString(16)}:${(v4 & 0xffff).toString(16)}`;73 }74 const halves = s.split("::");75 if (halves.length > 2) return null;76 const head = halves[0] ? halves[0].split(":") : [];77 const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];78 const fill = halves.length === 2 ? 8 - head.length - tail.length : 0;79 if (fill < 0 || (halves.length === 1 && head.length !== 8)) return null;80 const parts = [...head, ...Array<string>(fill).fill("0"), ...tail];81 if (parts.length !== 8) return null;82 const out: number[] = [];83 for (const p of parts) {84 if (!/^[0-9a-f]{1,4}$/.test(p)) return null;85 out.push(parseInt(p, 16));86 }87 return out;88}8990export function isPrivateIpv6(ip: string): boolean {91 const h = expandIpv6(ip);92 if (!h) return false;93 const allZero = h.every((x) => x === 0);94 if (allZero) return true; // ::95 if (h.slice(0, 7).every((x) => x === 0) && h[7] === 1) return true; // ::196 // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible → check the embedded v497 if (h.slice(0, 5).every((x) => x === 0) && (h[5] === 0xffff || h[5] === 0)) {98 const v4 = `${h[6] >> 8}.${h[6] & 0xff}.${h[7] >> 8}.${h[7] & 0xff}`;99 if (h[5] === 0xffff || h[6] !== 0 || h[7] > 1) return isPrivateIpv4(v4);100 }101 // 64:ff9b::/96 (NAT64) → embedded v4102 if (h[0] === 0x64 && h[1] === 0xff9b && h.slice(2, 6).every((x) => x === 0)) return isPrivateIpv4(`${h[6] >> 8}.${h[6] & 0xff}.${h[7] >> 8}.${h[7] & 0xff}`);103 if ((h[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local104 if ((h[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local105 if ((h[0] & 0xff00) === 0xff00) return true; // multicast106 if (h[0] === 0x2001 && h[1] === 0x0db8) return true; // documentation107 return false;108}109110export function isPrivateIp(ip: string): boolean {111 const v = isIP(ip);112 if (v === 4) return isPrivateIpv4(ip);113 if (v === 6) return isPrivateIpv6(ip);114 return false;115}116117/** Hostnames that always mean "this machine / this network" without needing DNS. */118export function isBlockedHostname(hostname: string): boolean {119 const h = hostname.toLowerCase().replace(/\.$/, "");120 if (h === "localhost" || h.endsWith(".localhost")) return true;121 if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".lan") || h.endsWith(".home") || h.endsWith(".home.arpa") || h.endsWith(".corp") || h.endsWith(".intranet")) return true;122 if (h === "metadata.google.internal" || h === "metadata") return true;123 if (!h.includes(".") && isIP(h) === 0) return true; // bare single-label names resolve inside the server's search domain124 return false;125}126127export interface EndpointUrlCheck {128 ok: boolean;129 /** Stable machine-readable reason. */130 reason?: "INVALID_URL" | "BAD_PROTOCOL" | "CREDENTIALS_IN_URL" | "PRIVATE_HOST" | "PRIVATE_IP" | "PRIVATE_DNS";131 message?: string;132 /** True when the URL points at a private/loopback host (allowed or not). */133 isPrivate: boolean;134 url?: URL;135}136137/** Synchronous part of the check (no DNS). */138export function checkEndpointUrlSync(raw: string, opts: { allowPrivate?: boolean } = {}): EndpointUrlCheck {139 let url: URL;140 try {141 url = new URL(raw.trim());142 } catch {143 return { ok: false, reason: "INVALID_URL", message: "Enter a full URL such as http://localhost:11434/v1.", isPrivate: false };144 }145 if (!ALLOWED_PROTOCOLS.has(url.protocol)) return { ok: false, reason: "BAD_PROTOCOL", message: "Only http:// and https:// endpoints are supported.", isPrivate: false, url };146 if (url.username || url.password) return { ok: false, reason: "CREDENTIALS_IN_URL", message: "Put credentials in the API key or headers, not in the URL.", isPrivate: false, url };147 const host = url.hostname.replace(/^\[|\]$/g, "");148 const allowPrivate = opts.allowPrivate ?? privateEndpointsAllowed();149 const literalIp = isIP(host) !== 0;150 const isPrivate = literalIp ? isPrivateIp(host) : isBlockedHostname(host);151 if (isPrivate && !allowPrivate) {152 return {153 ok: false,154 reason: literalIp ? "PRIVATE_IP" : "PRIVATE_HOST",155 message: "This address is on a private or local network the PolyLLM server cannot reach. Expose it through a tunnel (Cloudflare, ngrok, Tailscale Funnel…) or run PolyLLM locally with ALLOW_PRIVATE_ENDPOINTS=1.",156 isPrivate,157 url,158 };159 }160 return { ok: true, isPrivate, url };161}162163/** Full check: syntax + DNS resolution of the hostname against private ranges (DNS rebinding is out of scope). */164export async function assertEndpointUrlAllowed(raw: string, opts: { allowPrivate?: boolean } = {}): Promise<EndpointUrlCheck> {165 const sync = checkEndpointUrlSync(raw, opts);166 if (!sync.ok || !sync.url) return sync;167 const allowPrivate = opts.allowPrivate ?? privateEndpointsAllowed();168 if (allowPrivate || sync.isPrivate) return sync;169 const host = sync.url.hostname.replace(/^\[|\]$/g, "");170 if (isIP(host)) return sync;171 try {172 const addrs = await lookup(host, { all: true, verbatim: true });173 if (addrs.some((a) => isPrivateIp(a.address))) {174 return { ...sync, ok: false, reason: "PRIVATE_DNS", isPrivate: true, message: `${host} resolves to a private address the PolyLLM server will not call.` };175 }176 } catch {177 // Unresolvable hosts are reported by the connection test itself, not here.178 }179 return sync;180}181