fix(security): TICKET-013 filter reserved IP ranges in network scanner

- Added isReservedIP() function to detect loopback, link-local, multicast, broadcast ranges
- Blocks 127.0.0.0/8 (loopback), 169.254.0.0/16 (link-local/cloud metadata), 224.0.0.0/4 (multicast), 255.255.255.255
- Preserves RFC1918 private ranges (10.x, 172.16.x, 192.168.x) for self-hosted scenarios
- Updated expandCIDR() to filter reserved IPs during CIDR expansion
- Updated expandEndpoints() to log warnings when reserved ranges are filtered
- Added 16 comprehensive tests covering loopback, link-local, multicast filtering
- Tests verify private ranges and public IPs are not blocked
- Tests verify single IP filtering and bulk CIDR expansion filtering
This commit is contained in:
shankar0123
2026-03-27 21:36:10 -04:00
parent 200bdf990f
commit fd6f236a5c
2 changed files with 224 additions and 0 deletions
+52
View File
@@ -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)