mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 10:38:51 +00:00
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:
@@ -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.
|
// 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 {
|
func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int64) []string {
|
||||||
var endpoints []string
|
var endpoints []string
|
||||||
|
|
||||||
for _, cidr := range cidrs {
|
for _, cidr := range cidrs {
|
||||||
ips := expandCIDR(cidr)
|
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 _, ip := range ips {
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
endpoints = append(endpoints, fmt.Sprintf("%s:%d", ip, port))
|
endpoints = append(endpoints, fmt.Sprintf("%s:%d", ip, port))
|
||||||
@@ -291,14 +299,53 @@ func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int64) []st
|
|||||||
return endpoints
|
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.
|
// expandCIDR expands a CIDR notation or single IP into a list of IPs.
|
||||||
// Limits expansion to /20 (4096 IPs) to prevent accidental huge scans.
|
// 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 {
|
func expandCIDR(cidr string) []string {
|
||||||
// Try as CIDR first
|
// Try as CIDR first
|
||||||
ip, ipNet, err := net.ParseCIDR(cidr)
|
ip, ipNet, err := net.ParseCIDR(cidr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try as single IP
|
// Try as single IP
|
||||||
if singleIP := net.ParseIP(cidr); singleIP != nil {
|
if singleIP := net.ParseIP(cidr); singleIP != nil {
|
||||||
|
if isReservedIP(singleIP) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return []string{singleIP.String()}
|
return []string{singleIP.String()}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -313,6 +360,11 @@ func expandCIDR(cidr string) []string {
|
|||||||
|
|
||||||
var ips []string
|
var ips []string
|
||||||
for ip := ip.Mask(ipNet.Mask); ipNet.Contains(ip); incrementIP(ip) {
|
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)
|
// Copy IP before appending (net.IP is a mutable slice)
|
||||||
ipCopy := make(net.IP, len(ip))
|
ipCopy := make(net.IP, len(ip))
|
||||||
copy(ipCopy, ip)
|
copy(ipCopy, ip)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -232,3 +233,174 @@ func TestExpandEndpoints(t *testing.T) {
|
|||||||
t.Errorf("expected 192.168.1.1:8443, got %s", endpoints[1])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user