diff --git a/src/lib/__tests__/ssrf.test.ts b/src/lib/__tests__/ssrf.test.ts new file mode 100644 index 00000000..4fb481ec --- /dev/null +++ b/src/lib/__tests__/ssrf.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { isPrivateIP } from '../ai/research' + +describe('SSRF & Private Network Protection', () => { + const blockedHosts = [ + // IPv4 standard representation + '127.0.0.1', + '127.0.0.2', + '127.12.34.56', + '10.0.0.1', + '10.255.255.254', + '172.16.0.1', + '172.31.255.254', + '192.168.1.1', + '192.168.254.254', + '169.254.169.254', + '0.0.0.0', + '0.1.2.3', + '100.64.0.1', + '100.127.255.254', + '192.0.0.1', + '192.0.2.1', + '198.51.100.1', + '203.0.113.1', + '224.0.0.1', + '240.0.0.1', + '255.255.255.255', + + // Local hostnames & domain suffixes + 'localhost', + 'my-router', + 'nas-server', + 'router.local', + 'service.internal', + 'app.localhost', + 'workstation.lan', + 'test-suite.test', + 'site.invalid', + 'api.example', + 'secret.onion', + + // Decimal IPv4 representation + '2130706433', // 127.0.0.1 + '167772161', // 10.0.0.1 + + // Hexadecimal IPv4 representation + '0x7f000001', // 127.0.0.1 + '0x0a000001', // 10.0.0.1 + '0x7f.0x0.0x0.0x1', // mixed hex 127.0.0.1 + + // Octal IPv4 representation + '017700000001', // 127.0.0.1 + '0177.0.0.1', // 127.0.0.1 mixed + '012.0.0.1', // 10.0.0.1 octal/mixed + + // IPv6 Loopback, Link-Local, Unique Local, Multicast + '::1', + '::', + '0:0:0:0:0:0:0:1', + '0:0:0:0:0:0:0:0', + 'fe80::1', + 'fe80::dead:beef', + 'fc00::', + 'fdff::1234', + 'ff02::1', + 'ff02::fb', + + // IPv4-mapped IPv6 + '::ffff:127.0.0.1', + '::ffff:10.0.0.1', + '::ffff:0x7f000001', + '::ffff:2130706433', + ] + + const allowedHosts = [ + 'google.com', + 'github.com', + 'wikipedia.org', + '8.8.8.8', + '1.1.1.1', + '134744072', // 8.8.8.8 decimal representation + '16843009', // 1.1.1.1 decimal representation + '2001:4860:4860::8888', // Google Public DNS IPv6 + 'example.com', + ] + + describe('Blocked Hosts (Private/Reserved/Local)', () => { + it.each(blockedHosts)('should identify "%s" as a private/local host', (host) => { + expect(isPrivateIP(host)).toBe(true) + }) + }) + + describe('Allowed Hosts (Public)', () => { + it.each(allowedHosts)('should identify "%s" as a public/allowed host', (host) => { + expect(isPrivateIP(host)).toBe(false) + }) + }) +}) diff --git a/src/lib/ai/research.ts b/src/lib/ai/research.ts index 1d7d0d5d..8453ade8 100644 --- a/src/lib/ai/research.ts +++ b/src/lib/ai/research.ts @@ -2,13 +2,180 @@ const JINA_READER_ENDPOINT = 'https://r.jina.ai/' const URL_REGEX = /https?:\/\/[^\s<>")\]]+/gi const MAX_CONTENT_LENGTH = 8000 +// Private and reserved network constants for IPv4 +const IPV4_CLASS_A_PRIVATE = 10 +const IPV4_LOOPBACK_BYTE = 127 +const IPV4_LOCAL_NET_BYTE = 0 +const IPV4_CLASS_C_PRIVATE = 0xC0A8 // 192.168 +const IPV4_LINK_LOCAL = 0xA9FE // 169.254 +const IPV4_CLASS_B_PRIVATE_PREFIX = 0xAC1 // 172.16 +const IPV4_CARRIER_NAT_PREFIX = 401 // 100.64 +const IPV4_IANA_SPECIAL = 0xC00000 // 192.0.0 +const IPV4_IANA_SPECIAL_2 = 0xC00002 // 192.0.2 +const IPV4_IANA_SPECIAL_3 = 0xC63364 // 198.51.100 +const IPV4_IANA_SPECIAL_4 = 0xCB0071 // 203.0.113 +const IPV4_MULTICAST_NIBBLE = 14 // 224.0.0.0/4 +const IPV4_RESERVED_NIBBLE = 15 // 240.0.0.0/4 + +function parseIPv4(hostname: string): number | null { + if (!/^[0-9a-f.x]+$/i.test(hostname)) { + return null + } + + const parts = hostname.split('.') + if (parts.length > 4 || parts.length === 0) { + return null + } + + const values: number[] = [] + for (const part of parts) { + if (part === '') return null + let val: number + if (part.toLowerCase().startsWith('0x')) { + val = parseInt(part, 16) + } else if (part.startsWith('0') && part.length > 1) { + if (/[89]/.test(part)) { + return null + } + val = parseInt(part, 8) + } else { + val = parseInt(part, 10) + } + + if (isNaN(val) || val < 0 || val > 0xffffffff) { + return null + } + values.push(val) + } + + const len = values.length + for (let i = 0; i < len - 1; i++) { + if (values[i] > 255) return null + } + + let ipVal = 0 + if (len === 4) { + if (values[3] > 255) return null + ipVal = (values[0] << 24) | (values[1] << 16) | (values[2] << 8) | values[3] + } else if (len === 3) { + if (values[2] > 65535) return null + ipVal = (values[0] << 24) | (values[1] << 16) | values[2] + } else if (len === 2) { + if (values[1] > 16777215) return null + ipVal = (values[0] << 24) | values[1] + } else if (len === 1) { + ipVal = values[0] + } + + return ipVal >>> 0 +} + +function isPrivateIPv4(ipVal: number): boolean { + const byte1 = ipVal >>> 24 + if (byte1 === IPV4_CLASS_A_PRIVATE) return true + if (byte1 === IPV4_LOOPBACK_BYTE) return true + if (byte1 === IPV4_LOCAL_NET_BYTE) return true + + const byte12 = ipVal >>> 16 + if (byte12 === IPV4_CLASS_C_PRIVATE) return true + if (byte12 === IPV4_LINK_LOCAL) return true + + if ((ipVal >>> 20) === IPV4_CLASS_B_PRIVATE_PREFIX) return true + if ((ipVal >>> 22) === IPV4_CARRIER_NAT_PREFIX) return true + + const byte123 = ipVal >>> 8 + if (byte123 === IPV4_IANA_SPECIAL) return true + if (byte123 === IPV4_IANA_SPECIAL_2) return true + if (byte123 === IPV4_IANA_SPECIAL_3) return true + if (byte123 === IPV4_IANA_SPECIAL_4) return true + + const byte1_nibble = byte1 >>> 4 + if (byte1_nibble === IPV4_MULTICAST_NIBBLE) return true + if (byte1_nibble === IPV4_RESERVED_NIBBLE) return true + + return false +} + +function isPrivateIPv6(normalized: string): boolean { + if ( + normalized === '::' || + normalized === '::1' || + normalized === '0:0:0:0:0:0:0:1' || + normalized === '0:0:0:0:0:0:0:0' + ) { + return true + } + + const ipv4MappedMatch = normalized.match(/^::ffff:(.+)$/i) + if (ipv4MappedMatch) { + const ipv4Part = ipv4MappedMatch[1] + const parsed = parseIPv4(ipv4Part) + if (parsed !== null) { + return isPrivateIPv4(parsed) + } + } + + if (/^fe[89ab][0-9a-f]:/i.test(normalized)) { + return true + } + + if (/^f[cd][0-9a-f]{2}:/i.test(normalized)) { + return true + } + + if (/^ff[0-9a-f]{2}:/i.test(normalized)) { + return true + } + + return false +} + +export function isPrivateIP(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.$/, '').replace(/^\[(.+)\]$/, '$1') + + if (normalized === 'localhost') { + return true + } + + if ( + normalized.endsWith('.local') || + normalized.endsWith('.internal') || + normalized.endsWith('.localhost') || + normalized.endsWith('.lan') || + normalized.endsWith('.test') || + normalized.endsWith('.invalid') || + normalized.endsWith('.example') || + normalized.endsWith('.onion') + ) { + return true + } + + const ipv4Val = parseIPv4(normalized) + if (ipv4Val !== null) { + return isPrivateIPv4(ipv4Val) + } + + if (isPrivateIPv6(normalized)) { + return true + } + + if (!normalized.includes('.') && !normalized.includes(':')) { + return true + } + + return false +} + export function extractUrls(text: string): string[] { const matches = text.match(URL_REGEX) if (!matches) return [] return [...new Set(matches)].filter((url) => { try { const parsed = new URL(url) - return parsed.protocol === 'http:' || parsed.protocol === 'https:' + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false + } + return !isPrivateIP(parsed.hostname) } catch { return false } @@ -28,6 +195,14 @@ export async function fetchUrlContent( signal?: AbortSignal, ): Promise { try { + const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Blocked URL scheme: ${parsed.protocol}`) + } + if (isPrivateIP(parsed.hostname)) { + throw new Error(`Blocked private/reserved IP or local domain: ${parsed.hostname}`) + } + const encodedUrl = encodeURIComponent(url) const res = await fetch(`${JINA_READER_ENDPOINT}${encodedUrl}`, { headers: {