SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
7.8 KB · 181 lines typescript
Raw Blame History
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