fix(security): close IPv6 SSRF gap in validateAgentURL (C6)

PR #94 blocked 169.254.0.0/16 but left IPv6 equivalents fully open.
Go's (*IPNet).Contains() does not match pure IPv6 addresses against IPv4
CIDRs, so ::1, fe80::*, and fc00::/7 all bypassed the check.

Add three explicit IPv6 entries to blockedRanges:
  - fe80::/10  (IPv6 link-local — cloud metadata analogue)
  - ::1/128    (IPv6 loopback)
  - fc00::/7   (IPv6 ULA — RFC-4193 private)

IPv4-mapped IPv6 (::ffff:169.254.x.x) is already safe: Go normalises
these to IPv4 via To4() before Contains() runs.

Tests: four new cases in TestValidateAgentURL covering all three blocked
IPv6 ranges plus the IPv4-mapped IPv6 auto-normalisation path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Security Auditor 2026-04-15 07:32:54 +00:00 committed by DevOps Engineer
parent 460cd9acf8
commit 7b57f411fc
2 changed files with 27 additions and 6 deletions

View File

@ -37,6 +37,13 @@ func NewRegistryHandler(b *events.Broadcaster) *RegistryHandler {
// - 10.0.0.0/8 RFC-1918 — lateral movement within private networks
// - 172.16.0.0/12 RFC-1918 — includes Docker bridge/overlay ranges
// - 192.168.0.0/16 RFC-1918 — home/office LAN ranges
// - fe80::/10 IPv6 link-local — same threat class as 169.254.x.x
// - ::1/128 IPv6 loopback
// - fc00::/7 IPv6 ULA (RFC-4193 private ranges)
//
// IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) is normalised to IPv4 by
// Go's net.ParseIP.To4() before Contains() runs, so the IPv4 rules above
// catch those without a separate entry.
//
// Returns a non-nil error suitable for including in a 400 Bad Request response.
func validateAgentURL(rawURL string) error {
@ -59,16 +66,19 @@ func validateAgentURL(rawURL string) error {
cidr string
label string
}{
{"169.254.0.0/16", "link-local (cloud metadata endpoint)"},
{"127.0.0.0/8", "loopback"},
{"10.0.0.0/8", "RFC-1918 private"},
{"172.16.0.0/12", "RFC-1918 private"},
{"192.168.0.0/16", "RFC-1918 private"},
{"169.254.0.0/16", "link-local address (cloud metadata endpoint)"},
{"127.0.0.0/8", "loopback address"},
{"10.0.0.0/8", "RFC-1918 private address"},
{"172.16.0.0/12", "RFC-1918 private address"},
{"192.168.0.0/16", "RFC-1918 private address"},
{"fe80::/10", "IPv6 link-local address (cloud metadata analogue)"},
{"::1/128", "IPv6 loopback address"},
{"fc00::/7", "IPv6 ULA address (RFC-4193 private)"},
}
for _, r := range blockedRanges {
_, network, _ := net.ParseCIDR(r.cidr)
if network.Contains(ip) {
return errors.New("private/reserved IP ranges are not permitted")
return fmt.Errorf("url targets a blocked address: %s", r.label)
}
}
}

View File

@ -480,6 +480,17 @@ func TestValidateAgentURL(t *testing.T) {
{"blocked RFC1918 192.168.0.1", "http://192.168.0.1:8080", true},
{"blocked RFC1918 192.168.1.100", "http://192.168.1.100:8080", true},
{"blocked RFC1918 192.168.255.254", "http://192.168.255.254:8080", true},
// ── Must be rejected: IPv6 SSRF vectors (C6 gap) ─────────────────────
// Go's IPv4 CIDRs do not match pure IPv6 addresses via Contains(), so
// each IPv6 range needs an explicit blocklist entry.
{"blocked IPv6 loopback [::1]", "http://[::1]:8080", true},
{"blocked IPv6 link-local [fe80::1]", "http://[fe80::1]:8080", true},
{"blocked IPv6 ULA [fd00::1]", "http://[fd00::1]:8080", true},
// IPv4-mapped IPv6 for a blocked range must also be rejected.
// Go normalises ::ffff:169.254.x.x to IPv4 via To4(), so the existing
// 169.254.0.0/16 entry catches it without a dedicated rule.
{"blocked IPv4-mapped IPv6 link-local", "http://[::ffff:169.254.169.254]:80", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {