diff --git a/server/src/engine/orchestrator.test.ts b/server/src/engine/orchestrator.test.ts index 59c47e5..9aac794 100644 --- a/server/src/engine/orchestrator.test.ts +++ b/server/src/engine/orchestrator.test.ts @@ -76,30 +76,93 @@ describe('orchestrator utility functions', () => { }) describe('isAllowedWebhookUrl — SSRF protection', () => { - it('blocks localhost', () => { + it('blocks localhost variants', () => { expect(isAllowedWebhookUrl('http://localhost/webhook')).toBe(false) expect(isAllowedWebhookUrl('http://127.0.0.1/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://127.0.0.2/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://0.0.0.0/webhook')).toBe(false) }) - it('blocks private IP ranges', () => { + it('blocks private IPv4 ranges', () => { + // 10.x.x.x expect(isAllowedWebhookUrl('http://10.0.0.1/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://10.255.255.255/webhook')).toBe(false) + // 192.168.x.x expect(isAllowedWebhookUrl('http://192.168.1.1/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://192.168.0.1/webhook')).toBe(false) + // 172.16-31.x.x expect(isAllowedWebhookUrl('http://172.16.0.1/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://172.31.255.255/webhook')).toBe(false) + // 172.15 and 172.32 should be ALLOWED (outside private range) + expect(isAllowedWebhookUrl('http://172.15.0.1/webhook')).toBe(true) + expect(isAllowedWebhookUrl('http://172.32.0.1/webhook')).toBe(true) }) - it('blocks .local and .internal TLDs', () => { + it('blocks IPv6 loopback ::1', () => { + expect(isAllowedWebhookUrl('http://[::1]/webhook')).toBe(false) + }) + + it('blocks IPv6 all-zeros [::]', () => { + expect(isAllowedWebhookUrl('http://[::]/webhook')).toBe(false) + }) + + it('blocks IPv6 link-local fe80::', () => { + expect(isAllowedWebhookUrl('http://[fe80::1]/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://[fe80::abcd:1234]/webhook')).toBe(false) + }) + + it('blocks IPv6-mapped IPv4 localhost', () => { + expect(isAllowedWebhookUrl('http://[::ffff:127.0.0.1]/webhook')).toBe(false) + }) + + it('blocks IPv6 private ranges fc/fd', () => { + expect(isAllowedWebhookUrl('http://[fc00::1]/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://[fd00::1]/webhook')).toBe(false) + }) + + it('blocks .local, .internal, .localhost TLDs', () => { expect(isAllowedWebhookUrl('http://myapp.local/webhook')).toBe(false) expect(isAllowedWebhookUrl('http://service.internal/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://evil.localhost/webhook')).toBe(false) }) - it('allows public URLs', () => { - expect(isAllowedWebhookUrl('https://example.com/webhook')).toBe(true) - expect(isAllowedWebhookUrl('https://api.mybot.dev/fight')).toBe(true) - }) - - it('blocks file:// and other schemes', () => { + it('blocks file:// scheme', () => { expect(isAllowedWebhookUrl('file:///etc/passwd')).toBe(false) }) - // Note: IPv6 loopback (::1) is not currently blocked — tracked for Phase 5 security hardening + it('blocks gopher:// scheme', () => { + expect(isAllowedWebhookUrl('gopher://evil.com/')).toBe(false) + }) + + it('blocks data: scheme', () => { + expect(isAllowedWebhookUrl('data:text/html,

hi

')).toBe(false) + }) + + it('blocks link-local metadata IP', () => { + expect(isAllowedWebhookUrl('http://169.254.169.254/latest/meta-data')).toBe(false) + }) + + it('blocks octal/decimal IP bypass (URL parser normalizes)', () => { + // Node URL parser normalizes 0177.0.0.1 to 127.0.0.1 + expect(isAllowedWebhookUrl('http://0177.0.0.1/')).toBe(false) + // Decimal IP for 127.0.0.1 + expect(isAllowedWebhookUrl('http://2130706433/')).toBe(false) + }) + + it('blocks invalid/empty URLs', () => { + expect(isAllowedWebhookUrl('')).toBe(false) + expect(isAllowedWebhookUrl('not-a-url')).toBe(false) + expect(isAllowedWebhookUrl('javascript:alert(1)')).toBe(false) + }) + + it('blocks overly long URLs (>2048 chars)', () => { + expect(isAllowedWebhookUrl('https://example.com/' + 'a'.repeat(2100))).toBe(false) + }) + + it('allows valid public URLs', () => { + expect(isAllowedWebhookUrl('https://example.com/webhook')).toBe(true) + expect(isAllowedWebhookUrl('https://api.mybot.dev/fight')).toBe(true) + expect(isAllowedWebhookUrl('http://bot.ngrok.io/challenge')).toBe(true) + expect(isAllowedWebhookUrl('https://1.2.3.4/webhook')).toBe(true) + }) }) diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index d631d26..b2d0ad4 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -80,12 +80,14 @@ function isAllowedWebhookUrl(url: string): boolean { if (typeof url !== 'string' || url.length > 2048) return false const parsed = new URL(url) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false - const hostname = parsed.hostname.toLowerCase() + // Strip IPv6 brackets for consistent checking + const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '') // Localhost variants - if (hostname === 'localhost' || hostname === '::1') return false + if (hostname === 'localhost' || hostname === '::1' || hostname === '::') return false + if (hostname === '0.0.0.0') return false if (hostname.startsWith('127.')) return false - // IPv6-mapped IPv4 localhost - if (hostname.startsWith('::ffff:127.')) return false + // IPv6-mapped IPv4 localhost (::ffff:127.x or normalized ::ffff:7fxx:x) + if (hostname.startsWith('::ffff:127.') || hostname.startsWith('::ffff:7f')) return false // Private IPv4 ranges if (hostname.startsWith('10.')) return false if (hostname.startsWith('192.168.')) return false