fix(security): complete SSRF remediation with defense-in-depth (CWE-918)

Implement three-layer SSRF protection:
- Layer 1: URL pre-validation (existing)
- Layer 2: network.NewSafeHTTPClient() with connection-time IP validation
- Layer 3: Redirect target validation

New package: internal/network/safeclient.go
- IsPrivateIP(): Blocks RFC 1918, loopback, link-local (169.254.x.x),
  reserved ranges, IPv6 private
- safeDialer(): DNS resolve → validate all IPs → dial validated IP
  (prevents DNS rebinding/TOCTOU)
- NewSafeHTTPClient(): Functional options (WithTimeout, WithAllowLocalhost,
  WithAllowedDomains, WithMaxRedirects)

Updated services:
- notification_service.go
- security_notification_service.go
- update_service.go
- crowdsec/registration.go (WithAllowLocalhost for LAPI)
- crowdsec/hub_sync.go (WithAllowedDomains for CrowdSec domains)

Consolidated duplicate isPrivateIP implementations to use network package.

Test coverage: 90.9% for network package
CodeQL: 0 SSRF findings (CWE-918 mitigated)

Closes #450
This commit is contained in:
GitHub Actions
2025-12-24 17:33:55 +00:00
parent 718969b1de
commit 745b9e3e97
17 changed files with 1802 additions and 722 deletions
+8 -54
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/Wikid82/charon/backend/internal/network"
"github.com/Wikid82/charon/backend/internal/security"
)
@@ -16,7 +17,7 @@ import (
// This prevents DNS rebinding attacks by validating the IP just before connecting.
// Returns a DialContext function suitable for use in http.Transport.
func ssrfSafeDialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, netw, addr string) (net.Conn, error) {
// Parse host and port from address
host, port, err := net.SplitHostPort(addr)
if err != nil {
@@ -34,8 +35,9 @@ func ssrfSafeDialer() func(ctx context.Context, network, addr string) (net.Conn,
}
// Validate ALL resolved IPs - if any are private, reject immediately
// Using centralized network.IsPrivateIP for consistent SSRF protection
for _, ip := range ips {
if isPrivateIP(ip.IP) {
if network.IsPrivateIP(ip.IP) {
return nil, fmt.Errorf("access to private IP addresses is blocked (resolved to %s)", ip.IP)
}
}
@@ -44,7 +46,7 @@ func ssrfSafeDialer() func(ctx context.Context, network, addr string) (net.Conn,
dialer := &net.Dialer{
Timeout: 5 * time.Second,
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
return dialer.DialContext(ctx, netw, net.JoinHostPort(ips[0].IP.String(), port))
}
}
@@ -188,56 +190,8 @@ func TestURLConnectivity(rawURL string, transport ...http.RoundTripper) (bool, f
}
// isPrivateIP checks if an IP address is private, loopback, link-local, or otherwise restricted.
// This function implements SSRF protection by blocking:
// - Private IPv4 ranges (RFC 1918)
// - Loopback addresses (127.0.0.0/8, ::1/128)
// - Link-local addresses (169.254.0.0/16, fe80::/10)
// - Private IPv6 ranges (fc00::/7)
// - Reserved ranges (0.0.0.0/8, 240.0.0.0/4, 255.255.255.255/32)
// This function wraps network.IsPrivateIP for backward compatibility within the utils package.
// See network.IsPrivateIP for the full list of blocked IP ranges.
func isPrivateIP(ip net.IP) bool {
// Check built-in Go functions for common cases
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
// Define private and reserved IP blocks
privateBlocks := []string{
// IPv4 Private Networks (RFC 1918)
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
// IPv4 Link-Local (RFC 3927) - includes AWS/GCP metadata service
"169.254.0.0/16",
// IPv4 Loopback
"127.0.0.0/8",
// IPv4 Reserved ranges
"0.0.0.0/8", // "This network"
"240.0.0.0/4", // Reserved for future use
"255.255.255.255/32", // Broadcast
// IPv6 Loopback
"::1/128",
// IPv6 Unique Local Addresses (RFC 4193)
"fc00::/7",
// IPv6 Link-Local
"fe80::/10",
}
// Check if IP is in any of the blocked ranges
for _, block := range privateBlocks {
_, subnet, err := net.ParseCIDR(block)
if err != nil {
continue
}
if subnet.Contains(ip) {
return true
}
}
return false
return network.IsPrivateIP(ip)
}