From adce8abe0bd02e2d719ec8d212fb7ce832623a7a Mon Sep 17 00:00:00 2001 From: Daniel Carrillo Date: Thu, 23 Apr 2026 18:52:18 +0200 Subject: [PATCH] feat: add ssrf protection --- src/checks/http.ts | 12 +++ src/index.ts | 13 +++ src/utils/ssrf.test.ts | 176 +++++++++++++++++++++++++++++++++++++++++ src/utils/ssrf.ts | 148 ++++++++++++++++++++++++++++++++++ 4 files changed, 349 insertions(+) create mode 100644 src/utils/ssrf.test.ts create mode 100644 src/utils/ssrf.ts diff --git a/src/checks/http.ts b/src/checks/http.ts index 204c591..129c2c4 100644 --- a/src/checks/http.ts +++ b/src/checks/http.ts @@ -1,4 +1,5 @@ import type { HttpCheckRequest, CheckResult } from '../types.js'; +import { isBlockedURL } from '../utils/ssrf.js'; import { sleep } from './utils.js'; const DEFAULT_HEADERS: Record = { @@ -11,6 +12,17 @@ export async function executeHttpCheck(check: HttpCheckRequest): Promise { + describe('valid public URLs', () => { + it('allows standard https URLs', () => { + expect(isBlockedURL('https://example.com')).toBeNull(); + }); + + it('allows standard http URLs', () => { + expect(isBlockedURL('http://example.com')).toBeNull(); + }); + + it('allows URLs with paths', () => { + expect(isBlockedURL('https://api.example.com/v1/health')).toBeNull(); + }); + + it('allows URLs with ports', () => { + expect(isBlockedURL('https://api.example.com:8080/health')).toBeNull(); + }); + + it('allows subdomains', () => { + expect(isBlockedURL('https://monitoring.example.com')).toBeNull(); + }); + + it('allows public IP addresses', () => { + expect(isBlockedURL('https://8.8.8.8')).toBeNull(); + }); + + it('allows public IP addresses on common ranges', () => { + expect(isBlockedURL('https://93.184.216.34')).toBeNull(); + }); + }); + + describe('localhost blocking', () => { + it('blocks localhost hostname', () => { + const result = isBlockedURL('http://localhost'); + expect(result).toContain('Blocked'); + }); + + it('blocks localhost with port', () => { + const result = isBlockedURL('http://localhost:8080/health'); + expect(result).toContain('Blocked'); + }); + + it('blocks 127.0.0.1', () => { + const result = isBlockedURL('http://127.0.0.1'); + expect(result).toContain('Blocked'); + }); + + it('blocks 127.0.0.1 with path', () => { + const result = isBlockedURL('http://127.0.0.1/api/health'); + expect(result).toContain('Blocked'); + }); + + it('blocks 0.0.0.0', () => { + const result = isBlockedURL('http://0.0.0.0'); + expect(result).toContain('Blocked'); + }); + + it('blocks ::1 IPv6 loopback', () => { + const result = isBlockedURL('http://[::1]'); + expect(result).toContain('Blocked'); + }); + }); + + describe('RFC 1918 private IPs', () => { + it('blocks 10.x.x.x', () => { + expect(isBlockedURL('http://10.0.0.1')).toContain('Blocked'); + expect(isBlockedURL('http://10.255.255.255')).toContain('Blocked'); + }); + + it('blocks 172.16-31.x.x', () => { + expect(isBlockedURL('http://172.16.0.1')).toContain('Blocked'); + expect(isBlockedURL('http://172.31.255.255')).toContain('Blocked'); + }); + + it('blocks 192.168.x.x', () => { + expect(isBlockedURL('http://192.168.0.1')).toContain('Blocked'); + expect(isBlockedURL('http://192.168.255.255')).toContain('Blocked'); + }); + + it('does not block 172.32.x.x (public range)', () => { + expect(isBlockedURL('http://172.32.0.1')).toBeNull(); + }); + }); + + describe('RFC 6598 Carrier-grade NAT', () => { + it('blocks 100.64.x.x through 100.127.x.x', () => { + expect(isBlockedURL('http://100.64.0.1')).toContain('Blocked'); + expect(isBlockedURL('http://100.127.255.255')).toContain('Blocked'); + expect(isBlockedURL('http://100.127.0.1')).toContain('Blocked'); + }); + + it('does not block 100.63.x.x (public)', () => { + expect(isBlockedURL('http://100.63.0.1')).toBeNull(); + }); + + it('does not block 100.128.x.x (public)', () => { + expect(isBlockedURL('http://100.128.0.1')).toBeNull(); + }); + }); + + describe('reserved IPs', () => { + it('blocks 0.x.x.x', () => { + expect(isBlockedURL('http://0.0.0.1')).toContain('Blocked'); + }); + + it('blocks 127.x.x.x (not just 127.0.0.1)', () => { + expect(isBlockedURL('http://127.1.2.3')).toContain('Blocked'); + expect(isBlockedURL('http://127.255.255.255')).toContain('Blocked'); + }); + + it('blocks 169.254.x.x (link-local)', () => { + expect(isBlockedURL('http://169.254.1.1')).toContain('Blocked'); + }); + + it('blocks 255.x.x.x (broadcast)', () => { + expect(isBlockedURL('http://255.255.255.255')).toContain('Blocked'); + }); + }); + + describe('.local and .localhost TLDs', () => { + it('blocks .local hostnames', () => { + expect(isBlockedURL('http://my-service.local')).toContain('Blocked'); + }); + + it('blocks .localhost hostnames', () => { + expect(isBlockedURL('http://app.localhost')).toContain('Blocked'); + }); + + it('does not block .local in path', () => { + expect(isBlockedURL('https://example.com/local')).toBeNull(); + }); + }); + + describe('protocol blocking', () => { + it('blocks file:// URLs', () => { + const result = isBlockedURL('file:///etc/passwd'); + expect(result).toContain('Blocked protocol'); + }); + + it('blocks ftp:// URLs', () => { + const result = isBlockedURL('ftp://ftp.example.com'); + expect(result).toContain('Blocked protocol'); + }); + + it('blocks data: URLs', () => { + const result = isBlockedURL('data:text/plain,hello'); + expect(result).toContain('Blocked protocol'); + }); + }); + + describe('edge cases', () => { + it('rejects empty string', () => { + expect(isBlockedURL('')).toContain('Invalid URL'); + }); + + it('rejects garbage input', () => { + expect(isBlockedURL('not a url')).toContain('Invalid URL'); + }); + + it('blocks private IP via HTTPS', () => { + expect(isBlockedURL('https://192.168.1.1')).toContain('Blocked'); + }); + + it('does not block hostnames that might resolve to private IPs', () => { + // We only block by hostname if it's an explicit IP or localhost keyword + expect(isBlockedURL('http://internal.service')).toBeNull(); + }); + + it('blocks 10.0.0.0 exact', () => { + expect(isBlockedURL('http://10.0.0.0')).toContain('Blocked'); + }); + }); +}); diff --git a/src/utils/ssrf.ts b/src/utils/ssrf.ts new file mode 100644 index 0000000..5398319 --- /dev/null +++ b/src/utils/ssrf.ts @@ -0,0 +1,148 @@ +const PRIVATE_IP_PREFIXES = [ + '10.', + '172.16.', + '172.17.', + '172.18.', + '172.19.', + '172.20.', + '172.21.', + '172.22.', + '172.23.', + '172.24.', + '172.25.', + '172.26.', + '172.27.', + '172.28.', + '172.29.', + '172.30.', + '172.31.', + '192.168.', + '100.64.', + '100.65.', + '100.66.', + '100.67.', + '100.68.', + '100.69.', + '100.70.', + '100.71.', + '100.72.', + '100.73.', + '100.74.', + '100.75.', + '100.76.', + '100.77.', + '100.78.', + '100.79.', + '100.80.', + '100.81.', + '100.82.', + '100.83.', + '100.84.', + '100.85.', + '100.86.', + '100.87.', + '100.88.', + '100.89.', + '100.90.', + '100.91.', + '100.92.', + '100.93.', + '100.94.', + '100.95.', + '100.96.', + '100.97.', + '100.98.', + '100.99.', + '100.100.', + '100.101.', + '100.102.', + '100.103.', + '100.104.', + '100.105.', + '100.106.', + '100.107.', + '100.108.', + '100.109.', + '100.110.', + '100.111.', + '100.112.', + '100.113.', + '100.114.', + '100.115.', + '100.116.', + '100.117.', + '100.118.', + '100.119.', + '100.120.', + '100.121.', + '100.122.', + '100.123.', + '100.124.', + '100.125.', + '100.126.', + '100.127.', +]; + +const RESERVED_IP_PREFIXES = [ + '0.', + '127.', + '169.254.', + '198.18.', + '198.51.100.', + '203.0.113.', + '192.0.2.', + '255.', +]; + +const LOCALHOST_NAMES = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']); + +function ipMatchesAnyPrefix(ip: string, prefixes: string[]): boolean { + for (const prefix of prefixes) { + if (ip.startsWith(prefix)) { + return true; + } + } + return false; +} + +export function isBlockedURL(urlString: string): string | null { + let url: URL; + try { + url = new URL(urlString); + } catch { + return 'Invalid URL'; + } + + const protocol = url.protocol.toLowerCase(); + if (protocol !== 'http:' && protocol !== 'https:') { + return `Blocked protocol: ${protocol}`; + } + + const hostname = url.hostname.toLowerCase(); + + const strippedHostname = hostname.replaceAll(/^\[|\]$/gv, ''); + + if (LOCALHOST_NAMES.has(hostname) || LOCALHOST_NAMES.has(strippedHostname)) { + return `Blocked: localhost / loopback address`; + } + + if (strippedHostname.endsWith('.local') || strippedHostname.endsWith('.localhost')) { + return `Blocked: localhost / loopback address`; + } + + if (strippedHostname === '::1' || strippedHostname === '0:0:0:0:0:0:0:1') { + return 'Blocked: IPv6 loopback address'; + } + + // Check for bare IPv4 addresses against private/reserved ranges + if (hostname.includes('.')) { + if (ipMatchesAnyPrefix(hostname, PRIVATE_IP_PREFIXES)) { + return `Blocked: private IP address (${hostname})`; + } + if (ipMatchesAnyPrefix(hostname, RESERVED_IP_PREFIXES)) { + return `Blocked: reserved IP address (${hostname})`; + } + } + + return null; +}