From 8cf7ccd5ee041ecaea92b49951793d4d2393761f Mon Sep 17 00:00:00 2001 From: Amir Raminfar Date: Tue, 4 Aug 2026 12:33:51 -0700 Subject: [PATCH] fix(security): block IPv6 transition addresses in webhook SSRF guard (#4887) --- internal/notification/dispatcher/webhook.go | 64 +++++++++++++++++++ .../notification/dispatcher/webhook_test.go | 19 +++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/internal/notification/dispatcher/webhook.go b/internal/notification/dispatcher/webhook.go index 387c7179..d6093567 100644 --- a/internal/notification/dispatcher/webhook.go +++ b/internal/notification/dispatcher/webhook.go @@ -10,6 +10,7 @@ import ( "net" "net/http" "net/url" + "slices" "strings" "text/template" "time" @@ -30,6 +31,16 @@ var errBlockedAddress = errors.New("webhook target resolves to a blocked address var zeroNetV4 = &net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(8, 32)} func isBlockedIP(ip net.IP) bool { + if isBlockedBaseIP(ip) { + return true + } + // IPv6 transition mechanisms (6to4, NAT64, Teredo, IPv4-compatible) embed an + // arbitrary IPv4 address that none of the checks above look at. Unwrap and + // re-check the embedded address so 2002:7f00:1::1 is treated as 127.0.0.1. + return slices.ContainsFunc(embeddedIPv4(ip), isBlockedBaseIP) +} + +func isBlockedBaseIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || @@ -50,6 +61,59 @@ func isBlockedIP(ip net.IP) bool { return false } +// embeddedIPv4 returns the IPv4 addresses carried inside an IPv6 transition +// address, or nil when the address carries none. Teredo yields two: the relay +// server and the (obfuscated) client. +func embeddedIPv4(ip net.IP) []net.IP { + if ip.To4() != nil { + return nil + } + ip16 := ip.To16() + if ip16 == nil { + return nil + } + + switch { + // 6to4 — RFC 3056, 2002::/16, IPv4 in bytes 2-6 + case ip16[0] == 0x20 && ip16[1] == 0x02: + return []net.IP{net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5])} + + // NAT64 well-known prefix — RFC 6052, 64:ff9b::/96, IPv4 in the low 32 bits + case ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b && + isZeros(ip16[4:12]): + return []net.IP{net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])} + + // NAT64 local-use prefix — RFC 8215, 64:ff9b:1::/48. The embedded IPv4 sits + // at a position that depends on the operator's prefix length, so block the + // whole range rather than guess. + case ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b && ip16[4] == 0x00 && ip16[5] == 0x01: + return []net.IP{net.IPv4zero} + + // Teredo — RFC 4380, 2001::/32. Server IPv4 in bytes 4-8, client IPv4 in + // bytes 12-16 obfuscated by XOR with 0xff. + case ip16[0] == 0x20 && ip16[1] == 0x01 && ip16[2] == 0x00 && ip16[3] == 0x00: + return []net.IP{ + net.IPv4(ip16[4], ip16[5], ip16[6], ip16[7]), + net.IPv4(ip16[12]^0xff, ip16[13]^0xff, ip16[14]^0xff, ip16[15]^0xff), + } + + // IPv4-compatible — deprecated ::a.b.c.d, not unwrapped by net.IP.To4 + case isZeros(ip16[0:12]): + return []net.IP{net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])} + } + + return nil +} + +func isZeros(b []byte) bool { + for _, x := range b { + if x != 0 { + return false + } + } + return true +} + func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { diff --git a/internal/notification/dispatcher/webhook_test.go b/internal/notification/dispatcher/webhook_test.go index 7c1b4ee5..c0a443c2 100644 --- a/internal/notification/dispatcher/webhook_test.go +++ b/internal/notification/dispatcher/webhook_test.go @@ -226,9 +226,21 @@ func TestIsBlockedIP(t *testing.T) { "fe80::1", "224.0.0.1", "0.0.0.0", - "0.1.2.3", // 0.0.0.0/8 — routes to localhost on Linux - "0.255.255.255", // top of 0.0.0.0/8 + "0.1.2.3", // 0.0.0.0/8 — routes to localhost on Linux + "0.255.255.255", // top of 0.0.0.0/8 "255.255.255.255", // limited broadcast + + // IPv6 transition addresses embedding a blocked IPv4 + "2002:7f00:0001::1", // 6to4 -> 127.0.0.1 + "2002:a9fe:a9fe::1", // 6to4 -> 169.254.169.254 + "2002:0000:0001::1", // 6to4 -> 0.0.0.1 + "64:ff9b::7f00:1", // NAT64 WKP -> 127.0.0.1 + "64:ff9b::a9fe:a9fe", // NAT64 WKP -> 169.254.169.254 + "64:ff9b:1::7f00:1", // NAT64 local-use prefix + "2001:0000:dead:beef:0000:0000:80ff:fffe", // Teredo -> client 127.0.0.1 + "2001:0000:7f00:0001::1", // Teredo -> server 127.0.0.1 + "::7f00:1", // IPv4-compatible -> 127.0.0.1 + "::a9fe:a9fe", // IPv4-compatible -> 169.254.169.254 } for _, s := range blocked { ip := net.ParseIP(s) @@ -242,6 +254,9 @@ func TestIsBlockedIP(t *testing.T) { "172.16.5.10", "8.8.8.8", "2606:4700:4700::1111", + "2002:0808:0808::1", // 6to4 -> 8.8.8.8 + "64:ff9b::808:808", // NAT64 WKP -> 8.8.8.8 + "2001:0:808:808::1", // Teredo with public server/client } for _, s := range allowed { ip := net.ParseIP(s)