Skip to content

Commit 6c852ab

Browse files
committed
fix(security): isPrivateHost SSRF bypass — proper IP parsing + RFC 6598 + IPv6 ULA + DNS resolution (M2)
The old isPrivateHost used string prefix matching (same pattern as the old ClassifyURL in H2). This was bypassable with the same IP representation tricks (octal, hex, decimal integer, short forms). Additionally: - Missing RFC 6598 (100.64.0.0/10) carrier-grade NAT range - Missing IPv6 Unique Local Addresses (fc00::/7) - Missing cloud metadata hostnames (metadata.google.internal, *.internal) - Missing Docker internal hostnames (*.docker.internal) - Missing mDNS hostnames (*.local) - No DNS resolution at check time (hostnames resolving to private IPs via DNS rebinding were not caught) Fix: - Replace string prefix matching with parsePrivateIP() using browser-compatible inet_aton-style parsing (from H2) - Add isPrivateIP() covering loopback, private, link-local, unspecified, RFC 6598, and IPv6 ULA - Add hostname checks for metadata, Docker, mDNS, and *.internal - Add DNS resolution fallback: when the host is a hostname (not an IP), resolve it and check resolved IPs for private ranges Fixes M2 from security audit.
1 parent ade6fdc commit 6c852ab

2 files changed

Lines changed: 117 additions & 8 deletions

File tree

internal/skills/importer.go

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"encoding/json"
55
"fmt"
66
"io"
7+
"net"
78
"net/http"
89
"net/url"
910
"os"
1011
"path/filepath"
12+
"strconv"
1113
"strings"
1214
"time"
1315
)
@@ -321,19 +323,104 @@ func ImportSkill(opts ImportOptions, confirmFn func(assessment *ImportAssessment
321323

322324
// isPrivateHost returns true if the hostname is a private/internal IP
323325
// or localhost. Blocks SSRF via redirect in skill import.
326+
// Uses proper IP parsing (handles decimal, octal, hex, short, integer forms)
327+
// and checks RFC 1918, RFC 6598, IPv6 ULA, link-local, and metadata hostnames.
324328
func isPrivateHost(host string) bool {
325-
if host == "localhost" || host == "127.0.0.1" || host == "::1" ||
326-
host == "169.254.169.254" || host == "0.0.0.0" {
329+
// Well-known private hostnames (fast path, no network lookup)
330+
switch strings.ToLower(host) {
331+
case "localhost", "localhost.localdomain", "localhost6", "localhost6.localdomain6",
332+
"ip6-localhost", "ip6-loopback":
327333
return true
334+
case "metadata.google.internal", "metadata.internal":
335+
return true
336+
case "169.254.169.254":
337+
return true
338+
}
339+
340+
if strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".docker.internal") {
341+
return true
342+
}
343+
if strings.HasSuffix(host, ".local") {
344+
return true // mDNS — link-local
345+
}
346+
347+
// Try as an IP using browser-compatible parsing (handles all representations)
348+
if ip := parsePrivateIP(host); ip != nil {
349+
return isPrivateIP(ip)
328350
}
329-
// RFC 1918 private ranges
330-
for _, prefix := range []string{"10.", "172.16.", "172.17.", "172.18.",
331-
"172.19.", "172.20.", "172.21.", "172.22.", "172.23.",
332-
"172.24.", "172.25.", "172.26.", "172.27.", "172.28.",
333-
"172.29.", "172.30.", "172.31.", "192.168."} {
334-
if strings.HasPrefix(host, prefix) {
351+
352+
// If it's a hostname (not an IP), resolve it to check for private IPs.
353+
// This catches cases like DNS rebinding where the hostname resolves
354+
// to a private IP at connection time.
355+
ips, err := net.LookupHost(host)
356+
if err != nil {
357+
return false // can't resolve — let the connection attempt fail naturally
358+
}
359+
for _, ipStr := range ips {
360+
if ip := net.ParseIP(ipStr); ip != nil && isPrivateIP(ip) {
335361
return true
336362
}
337363
}
338364
return false
339365
}
366+
367+
// isPrivateIP checks if an IP is in a private/internal range.
368+
func isPrivateIP(ip net.IP) bool {
369+
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() {
370+
return true
371+
}
372+
// RFC 6598: Carrier-grade NAT (100.64.0.0/10)
373+
if ip4 := ip.To4(); ip4 != nil {
374+
if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
375+
return true
376+
}
377+
}
378+
// IPv6 Unique Local Address (fc00::/7)
379+
if ip.To16() != nil && len(ip) == net.IPv6len {
380+
if ip[0] == 0xfc || ip[0] == 0xfd {
381+
return true
382+
}
383+
}
384+
return false
385+
}
386+
387+
// parsePrivateIP parses a host string using browser-compatible IP parsing.
388+
func parsePrivateIP(host string) net.IP {
389+
if ip := net.ParseIP(host); ip != nil {
390+
return ip
391+
}
392+
// inet_aton-style: octal (0177.0.0.1), hex (0x7f000001), short (127.1),
393+
// single-integer (2130706433), hex-dotted (0x0.0x0.0x0.0x0)
394+
parts := strings.Split(host, ".")
395+
if len(parts) < 1 || len(parts) > 4 {
396+
return nil
397+
}
398+
var nums []uint32
399+
for _, p := range parts {
400+
var val uint64
401+
var err error
402+
switch {
403+
case strings.HasPrefix(p, "0x") || strings.HasPrefix(p, "0X"):
404+
val, err = strconv.ParseUint(p[2:], 16, 32)
405+
case strings.HasPrefix(p, "0") && len(p) > 1:
406+
val, err = strconv.ParseUint(p[1:], 8, 32)
407+
default:
408+
val, err = strconv.ParseUint(p, 10, 32)
409+
}
410+
if err != nil || val > 0xFFFFFFFF {
411+
return nil
412+
}
413+
nums = append(nums, uint32(val))
414+
}
415+
switch len(nums) {
416+
case 1:
417+
return net.IPv4(byte(nums[0]>>24), byte(nums[0]>>16), byte(nums[0]>>8), byte(nums[0]))
418+
case 2:
419+
return net.IPv4(byte(nums[0]), byte(nums[1]>>16), byte(nums[1]>>8), byte(nums[1]))
420+
case 3:
421+
return net.IPv4(byte(nums[0]), byte(nums[1]), byte(nums[2]>>8), byte(nums[2]))
422+
case 4:
423+
return net.IPv4(byte(nums[0]), byte(nums[1]), byte(nums[2]), byte(nums[3]))
424+
}
425+
return nil
426+
}

internal/skills/importer_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,28 @@ func TestIsPrivateHost(t *testing.T) {
418418
{"8.8.8.8", false},
419419
{"example.com", false},
420420
{"172.32.0.1", false},
421+
// Bypass vectors (old string-prefix classifier missed these)
422+
{"0", true}, // 0.0.0.0
423+
{"0177.0.0.1", true}, // octal 127.0.0.1
424+
{"2130706433", true}, // decimal 127.0.0.1
425+
{"0x7f000001", true}, // hex 127.0.0.1
426+
{"127.1", true}, // short form 127.0.0.1
427+
{"0x0.0x0.0x0.0x0", true}, // hex-dotted
428+
// RFC 6598 carrier-grade NAT
429+
{"100.64.0.1", true},
430+
{"100.127.255.254", true},
431+
{"100.128.0.1", false}, // outside RFC 6598
432+
// IPv6 ULA
433+
{"fc00::1", true},
434+
{"fd00::1", true},
435+
// Metadata hostnames
436+
{"metadata.google.internal", true},
437+
{"metadata.internal", true},
438+
{"instance.metadata.internal", true},
439+
// Docker internal
440+
{"host.docker.internal", true},
441+
// mDNS / .local
442+
{"mydevice.local", true},
421443
}
422444
for _, tt := range tests {
423445
got := isPrivateHost(tt.host)

0 commit comments

Comments
 (0)