From 20a85cd83e68b197e5297080c049d4075ee5188c Mon Sep 17 00:00:00 2001 From: Alex Nazaruk Date: Fri, 3 Jul 2026 00:44:50 +0200 Subject: [PATCH] fix(api): harden /api/swarm SSRF guard for caller-supplied baseUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildModel() only rejected a baseUrl matching /localhost|127.0.0.1/, so a signed-in caller could point baseUrl at other internal targets — IPv6 loopback [::1], 0.0.0.0, the rest of 127.0.0.0/8, cloud metadata 169.254.169.254, RFC1918 ranges, and CGNAT 100.64.0.0/10 — and make the server issue requests there (SSRF). Add ssrf-guard.ts (isBlockedHost) covering loopback/unspecified/link-local/ ULA/private/CGNAT for both IPv4 and IPv6 (incl. IPv4-mapped) plus localhost/ .local/.internal, and apply it in buildModel to the parsed hostname AND to the DNS-resolved addresses (defense against a public name resolving to a private IP). Public provider hosts (api.openai.com, etc.) are unaffected. Adds ssrf-guard.test.ts; logic verified standalone (24 blocked / 12 allowed). --- services/api/src/ssrf-guard.test.ts | 38 ++++++++++++++++++++++ services/api/src/ssrf-guard.ts | 49 +++++++++++++++++++++++++++++ services/api/src/swarm.ts | 25 ++++++++++++++- 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 services/api/src/ssrf-guard.test.ts create mode 100644 services/api/src/ssrf-guard.ts diff --git a/services/api/src/ssrf-guard.test.ts b/services/api/src/ssrf-guard.test.ts new file mode 100644 index 0000000..1989937 --- /dev/null +++ b/services/api/src/ssrf-guard.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { isBlockedHost } from './ssrf-guard.js'; + +describe('isBlockedHost', () => { + it('blocks loopback / unspecified / metadata / private / CGNAT IPv4', () => { + for (const ip of [ + '127.0.0.1', '127.0.0.2', '127.255.255.255', + '0.0.0.0', '10.0.0.5', '172.16.0.1', '172.31.255.255', + '192.168.1.1', '169.254.169.254', '100.64.0.1', '100.127.255.255', + ]) { + expect(isBlockedHost(ip), ip).toBe(true); + } + }); + + it('blocks loopback / link-local / ULA / mapped IPv6', () => { + for (const ip of ['::1', '[::1]', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1']) { + expect(isBlockedHost(ip), ip).toBe(true); + } + }); + + it('blocks internal hostnames', () => { + for (const h of ['localhost', 'foo.localhost', 'db.local', 'svc.internal']) { + expect(isBlockedHost(h), h).toBe(true); + } + expect(isBlockedHost('')).toBe(true); + expect(isBlockedHost(undefined)).toBe(true); + }); + + it('allows public LLM provider hosts and public IPs', () => { + for (const h of [ + 'api.openai.com', 'generativelanguage.googleapis.com', 'api.deepseek.com', + 'example.com', '8.8.8.8', '1.1.1.1', + '172.15.0.1', '172.32.0.1', '192.169.0.1', '100.63.0.1', '100.128.0.1', '11.0.0.1', + ]) { + expect(isBlockedHost(h), h).toBe(false); + } + }); +}); diff --git a/services/api/src/ssrf-guard.ts b/services/api/src/ssrf-guard.ts new file mode 100644 index 0000000..150e8de --- /dev/null +++ b/services/api/src/ssrf-guard.ts @@ -0,0 +1,49 @@ +/** + * SSRF guard for caller-supplied outbound base URLs (e.g. the BYO-key + * `baseUrl` in /api/swarm). Returns true when a hostname or IP literal points + * at a loopback / private / link-local / cloud-metadata target that a signed-in + * caller must not be able to make the server reach. + * + * `isBlockedHost` is intentionally pure and synchronous so it can be unit + * tested and also applied to DNS-resolved addresses (defense against a public + * hostname that resolves to a private IP). + */ + +function ipv4Blocked(ip: string): boolean { + const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return false; + const o = m.slice(1).map(Number); + if (o.some((n) => n > 255)) return false; + const [a, b] = o; + if (a === 0) return true; // 0.0.0.0/8 + if (a === 127) return true; // loopback 127.0.0.0/8 + if (a === 10) return true; // private 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true; // private 172.16.0.0/12 + if (a === 192 && b === 168) return true; // private 192.168.0.0/16 + if (a === 169 && b === 254) return true; // link-local / cloud metadata 169.254.0.0/16 + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10 + return false; +} + +export function isBlockedHost(host: string | undefined | null): boolean { + if (!host) return true; + // normalize: lowercase, trim, strip IPv6 brackets and any zone id + let h = host.toLowerCase().trim().replace(/^\[/, '').replace(/\]$/, ''); + h = h.replace(/%.*$/, ''); + + if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) { + return true; + } + + if (h.includes(':')) { + // IPv6 + if (h === '::' || h === '::1') return true; // unspecified / loopback + if (/^fe[89ab]/.test(h)) return true; // fe80::/10 link-local + if (/^f[cd]/.test(h)) return true; // fc00::/7 unique-local + const mapped = h.match(/(?:^|:)ffff:(\d+\.\d+\.\d+\.\d+)$/); // ::ffff:1.2.3.4 + if (mapped) return ipv4Blocked(mapped[1]); + return false; + } + + return ipv4Blocked(h); +} diff --git a/services/api/src/swarm.ts b/services/api/src/swarm.ts index ddda0ac..f89493c 100644 --- a/services/api/src/swarm.ts +++ b/services/api/src/swarm.ts @@ -11,6 +11,7 @@ import { createLLMJudge, type SwarmRunner, } from '@logicsrc/agentswarm'; +import { isBlockedHost } from './ssrf-guard.js'; /** OpenAI-compatible provider bases (mirrors /api/models). anthropic is special-cased. */ const OPENAI_COMPAT_BASE: Record = { @@ -36,9 +37,31 @@ async function buildModel( return new ChatAnthropic({ apiKey, model: model || 'claude-sonnet-4-6' }); } const base = (baseUrl || OPENAI_COMPAT_BASE[provider] || '').replace(/\/$/, ''); - if (!base || /localhost|127\.0\.0\.1/.test(base)) { + if (!base) { throw new Error(`unsupported or disallowed provider/baseUrl: ${provider}`); } + // SSRF guard: a signed-in caller can pass an arbitrary baseUrl, so block + // requests to loopback / private / link-local / cloud-metadata targets. + let hostname: string; + try { + hostname = new URL(base).hostname; + } catch { + throw new Error(`invalid baseUrl: ${provider}`); + } + if (isBlockedHost(hostname)) { + throw new Error(`disallowed baseUrl (private/loopback host): ${provider}`); + } + // Defense-in-depth against a public hostname that resolves to a private IP. + let resolved: string[] = []; + try { + const dns = await import('node:dns/promises'); + resolved = (await dns.lookup(hostname, { all: true })).map((a) => a.address); + } catch { + resolved = []; // transient DNS failure — the request itself will fail later + } + if (resolved.some((ip) => isBlockedHost(ip))) { + throw new Error(`disallowed baseUrl (resolves to a private address): ${provider}`); + } const { ChatOpenAI } = await import('@langchain/openai'); return new ChatOpenAI({ apiKey, model: model || 'gpt-4o-mini', configuration: { baseURL: base } }); }