Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions services/api/src/ssrf-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
49 changes: 49 additions & 0 deletions services/api/src/ssrf-guard.ts
Original file line number Diff line number Diff line change
@@ -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);
}
25 changes: 24 additions & 1 deletion services/api/src/swarm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand All @@ -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 } });
}
Expand Down
Loading