diff --git a/internal/service/network_scan.go b/internal/service/network_scan.go index e1710e4..4d9fd89 100644 --- a/internal/service/network_scan.go +++ b/internal/service/network_scan.go @@ -276,11 +276,19 @@ func (s *NetworkScanService) scanTarget(ctx context.Context, target *domain.Netw } // expandEndpoints converts CIDR ranges and ports into a list of "ip:port" endpoints. +// Filters out reserved IP ranges and logs warnings. func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int64) []string { var endpoints []string for _, cidr := range cidrs { ips := expandCIDR(cidr) + if ips == nil || len(ips) == 0 { + if s.logger != nil { + s.logger.Warn("CIDR range filtered (reserved or too large)", + "cidr", cidr) + } + continue + } for _, ip := range ips { for _, port := range ports { endpoints = append(endpoints, fmt.Sprintf("%s:%d", ip, port)) @@ -291,14 +299,53 @@ func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int64) []st return endpoints } +// isReservedCIDR checks if an IP address falls within reserved ranges that should not be scanned. +// Filters out loopback, link-local (including cloud metadata), and multicast ranges. +// Does NOT filter RFC 1918 ranges since certctl is self-hosted and internal networks are a primary use case. +func isReservedIP(ip net.IP) bool { + // Loopback: 127.0.0.0/8 + if ip.IsLoopback() { + return true + } + + // Link-local: 169.254.0.0/16 (includes cloud metadata 169.254.169.254) + if linkLocal := net.ParseIP("169.254.0.0"); linkLocal != nil { + if _, linkLocalNet, _ := net.ParseCIDR("169.254.0.0/16"); linkLocalNet != nil { + if linkLocalNet.Contains(ip) { + return true + } + } + } + + // Multicast: 224.0.0.0/4 + if multicast := net.ParseIP("224.0.0.0"); multicast != nil { + if _, multicastNet, _ := net.ParseCIDR("224.0.0.0/4"); multicastNet != nil { + if multicastNet.Contains(ip) { + return true + } + } + } + + // Broadcast: 255.255.255.255 + if ip.String() == "255.255.255.255" { + return true + } + + return false +} + // expandCIDR expands a CIDR notation or single IP into a list of IPs. // Limits expansion to /20 (4096 IPs) to prevent accidental huge scans. +// Filters out reserved IP ranges to prevent SSRF attacks. func expandCIDR(cidr string) []string { // Try as CIDR first ip, ipNet, err := net.ParseCIDR(cidr) if err != nil { // Try as single IP if singleIP := net.ParseIP(cidr); singleIP != nil { + if isReservedIP(singleIP) { + return nil + } return []string{singleIP.String()} } return nil @@ -313,6 +360,11 @@ func expandCIDR(cidr string) []string { var ips []string for ip := ip.Mask(ipNet.Mask); ipNet.Contains(ip); incrementIP(ip) { + // Skip reserved IPs + if isReservedIP(ip) { + continue + } + // Copy IP before appending (net.IP is a mutable slice) ipCopy := make(net.IP, len(ip)) copy(ipCopy, ip) diff --git a/internal/service/network_scan_test.go b/internal/service/network_scan_test.go index 18803eb..fffb3eb 100644 --- a/internal/service/network_scan_test.go +++ b/internal/service/network_scan_test.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "net" "testing" "time" @@ -232,3 +233,174 @@ func TestExpandEndpoints(t *testing.T) { t.Errorf("expected 192.168.1.1:8443, got %s", endpoints[1]) } } + +// SSRF Protection Tests + +func TestIsReservedIP_Loopback(t *testing.T) { + tests := []struct { + ip string + expected bool + }{ + {"127.0.0.1", true}, + {"127.255.255.255", true}, + {"127.0.0.0", true}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + result := isReservedIP(net.ParseIP(tt.ip)) + if result != tt.expected { + t.Errorf("isReservedIP(%s) = %v, expected %v", tt.ip, result, tt.expected) + } + }) + } +} + +func TestIsReservedIP_LinkLocal(t *testing.T) { + tests := []struct { + ip string + expected bool + }{ + {"169.254.0.1", true}, + {"169.254.169.254", true}, // AWS cloud metadata + {"169.254.255.255", true}, + {"169.254.0.0", true}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + result := isReservedIP(net.ParseIP(tt.ip)) + if result != tt.expected { + t.Errorf("isReservedIP(%s) = %v, expected %v", tt.ip, result, tt.expected) + } + }) + } +} + +func TestIsReservedIP_Multicast(t *testing.T) { + tests := []struct { + ip string + expected bool + }{ + {"224.0.0.1", true}, + {"239.255.255.255", true}, + {"224.0.0.0", true}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + result := isReservedIP(net.ParseIP(tt.ip)) + if result != tt.expected { + t.Errorf("isReservedIP(%s) = %v, expected %v", tt.ip, result, tt.expected) + } + }) + } +} + +func TestIsReservedIP_Broadcast(t *testing.T) { + result := isReservedIP(net.ParseIP("255.255.255.255")) + if !result { + t.Errorf("isReservedIP(255.255.255.255) = %v, expected true", result) + } +} + +func TestIsReservedIP_AllowsPrivateRanges(t *testing.T) { + tests := []struct { + ip string + expected bool + desc string + }{ + {"10.0.0.1", false, "RFC1918 10/8"}, + {"10.255.255.255", false, "RFC1918 10/8 end"}, + {"172.16.0.1", false, "RFC1918 172.16/12"}, + {"172.31.255.255", false, "RFC1918 172.16/12 end"}, + {"192.168.1.1", false, "RFC1918 192.168/16"}, + {"192.168.255.255", false, "RFC1918 192.168/16 end"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + result := isReservedIP(net.ParseIP(tt.ip)) + if result != tt.expected { + t.Errorf("isReservedIP(%s) = %v, expected %v", tt.ip, result, tt.expected) + } + }) + } +} + +func TestIsReservedIP_AllowsPublic(t *testing.T) { + tests := []struct { + ip string + expected bool + }{ + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"208.67.222.222", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + result := isReservedIP(net.ParseIP(tt.ip)) + if result != tt.expected { + t.Errorf("isReservedIP(%s) = %v, expected %v", tt.ip, result, tt.expected) + } + }) + } +} + +func TestExpandCIDR_FiltersLoopback(t *testing.T) { + ips := expandCIDR("127.0.0.0/8") + if len(ips) != 0 { + t.Errorf("expected empty for loopback CIDR, got %d IPs", len(ips)) + } +} + +func TestExpandCIDR_FiltersLinkLocal(t *testing.T) { + ips := expandCIDR("169.254.0.0/16") + if len(ips) != 0 { + t.Errorf("expected empty for link-local CIDR, got %d IPs", len(ips)) + } +} + +func TestExpandCIDR_FiltersMulticast(t *testing.T) { + ips := expandCIDR("224.0.0.0/4") + if len(ips) != 0 { + t.Errorf("expected empty for multicast CIDR, got %d IPs", len(ips)) + } +} + +func TestExpandCIDR_AllowsPrivateRanges(t *testing.T) { + // Should NOT filter RFC1918 ranges + tests := []struct { + name string + cidr string + min int + }{ + {"10/8 sample", "10.0.0.0/30", 2}, // 2 usable (after removing network/broadcast) + {"172.16/12 sample", "172.16.0.0/30", 2}, // 2 usable + {"192.168/16 sample", "192.168.1.1/32", 1}, // Single IP + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ips := expandCIDR(tt.cidr) + if len(ips) < tt.min { + t.Errorf("expected at least %d IPs for %s, got %d", tt.min, tt.cidr, len(ips)) + } + }) + } +} + +func TestExpandCIDR_SingleLoopbackIP(t *testing.T) { + ips := expandCIDR("127.0.0.1") + if len(ips) != 0 { + t.Errorf("expected empty for loopback IP, got %v", ips) + } +} + +func TestExpandCIDR_SingleLinkLocalIP(t *testing.T) { + ips := expandCIDR("169.254.169.254") + if len(ips) != 0 { + t.Errorf("expected empty for cloud metadata IP, got %v", ips) + } +}