fix: harden SSRF protection and add comprehensive tests

Fix gaps in isAllowedWebhookUrl: IPv6 bracket stripping for [::1],
[fe80::], [::ffff:7f00:1]; block 0.0.0.0 and [::] (IPv6 all-zeros);
handle URL-parser normalized ::ffff:7fxx IPv6-mapped localhost.

Add 20 SSRF test cases covering: file://, gopher://, data: schemes,
localhost, 127.0.0.1, 10.x, 172.16-31.x, 192.168.x, IPv6 ::1,
fe80::, fc00::/fd00::, octal/decimal IP bypass, metadata IP
169.254.169.254, .localhost TLD, overly long URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 09:21:16 +00:00
co-authored by Claude Opus 4.6
parent 2051a95e13
commit 0131949643
2 changed files with 79 additions and 14 deletions
+73 -10
View File
@@ -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,<h1>hi</h1>')).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)
})
})