Block IPv6 transition addresses in SSRF guards (GHSA-8f8r-p75h-jwq5)

The net.IP predicates every SSRF guard is built from read only the literal
address bytes, so an IPv6 transition address smuggles an internal IPv4
destination past all of them. 64:ff9b::a9fe:a9fe reaches 169.254.169.254
while IsLoopback, IsPrivate, IsLinkLocalUnicast and To4 all report an
ordinary public address, defeating both the webhook URL validator and the
restricted outbound transport with the same input.

Add securityutil.EmbeddedIPv4Candidates, which unwraps NAT64 (RFC 6052
well-known and RFC 8215 local-use prefixes), 6to4, Teredo, ISATAP,
IPv4-compatible and IPv4-translated encodings, and run every candidate
through the caller's own policy in both layers. The embedded destination
inherits the outer policy rather than a stricter one, so a NAT64 address
wrapping a permitted public target stays permitted and AllowPrivateIPs /
AllowLoopback relax the embedded check the same way they relax the outer.

Reported by tonghuaroot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard Courtman
2026-07-31 17:54:37 +01:00
committed by courtmanr@gmail.com
parent 372a969a76
commit d4ce19219c
6 changed files with 333 additions and 0 deletions
@@ -710,6 +710,25 @@ tenant-monitor creation), because an allowlist that only the default org
observes silently denies legitimate per-client private webhook targets and
invites per-org security drift.
Webhook SSRF classification is an address-reachability decision, not a
byte-pattern decision. The `net.IP` predicates (`IsLoopback`, `IsPrivate`,
`IsLinkLocalUnicast`, `IsMulticast`, `IsUnspecified`) read only the literal
address handed to them, so an IPv6 transition address carries an internal IPv4
destination past every one of them: `64:ff9b::a9fe:a9fe` reaches
169.254.169.254 while reporting itself as ordinary global unicast. Both SSRF
layers -- the webhook URL validator (`pkg/audit/webhook.go`) and the restricted
outbound transport (`pkg/securityutil/outbound_http.go`) -- must therefore
unwrap NAT64 (RFC 6052 well-known and RFC 8215 local-use prefixes), 6to4,
Teredo, ISATAP, IPv4-compatible, and IPv4-translated encodings through the
single shared `securityutil.EmbeddedIPv4Candidates` helper and apply the same
policy to every embedded destination they find. Unwrapping is a shared helper
rather than a per-layer predicate because a bypass that defeats one layer
defeats the other identically, so the two layers must never drift on which
encodings they recognize. The embedded destination inherits the caller's
policy, not a stricter one: a NAT64 address wrapping a permitted public target
stays permitted, and `AllowPrivateIPs`/`AllowLoopback` relax the embedded check
exactly as they relax the outer one.
This subsystem now gives `L14` an explicit governed home for privacy guidance
and telemetry disclosures instead of leaving those trust surfaces as lane-level
evidence with no subsystem ownership.
+8
View File
@@ -334,6 +334,14 @@ func isPrivateOrReservedIP(ip net.IP) bool {
}
}
// NAT64, 6to4, Teredo and ISATAP addresses tunnel to an embedded IPv4
// destination that none of the checks above can see.
for _, embedded := range securityutil.EmbeddedIPv4Candidates(ip) {
if isPrivateOrReservedIP(embedded) {
return true
}
}
return false
}
+59
View File
@@ -94,6 +94,65 @@ func TestIsPrivateOrReservedIP(t *testing.T) {
}
}
// TestIsPrivateOrReservedIPRejectsIPv6TransitionAddresses covers the SSRF
// bypass where a NAT64, 6to4, Teredo, ISATAP or IPv4-compatible address routes
// to an internal IPv4 destination while every net.IP predicate reports it as an
// ordinary public address.
func TestIsPrivateOrReservedIPRejectsIPv6TransitionAddresses(t *testing.T) {
cases := map[string]bool{
"64:ff9b::a9fe:a9fe": true, // NAT64 -> 169.254.169.254
"64:ff9b::7f00:1": true, // NAT64 -> 127.0.0.1
"64:ff9b::a00:1": true, // NAT64 -> 10.0.0.1
"64:ff9b:1::c0a8:1": true, // NAT64 local-use -> 192.168.0.1
"64:ff9b:1:a9fe:a9:fe00::": true, // NAT64 local-use /48 -> 169.254.169.254
"2002:ac10:1::": true, // 6to4 -> 172.16.0.1
"2002:a9fe:a9fe::": true, // 6to4 -> 169.254.169.254
"2001:0:a9fe:a9fe::": true, // Teredo server -> 169.254.169.254
"2001:db8::5efe:c0a8:101": true, // ISATAP -> 192.168.1.1
"::192.168.1.1": true, // IPv4-compatible -> 192.168.1.1
"::ffff:0:10.0.0.1": true, // IPv4-translated -> 10.0.0.1
"64:ff9b::808:808": false, // NAT64 -> 8.8.8.8
"2002:808:808::": false, // 6to4 -> 8.8.8.8
"2001:db8::5efe:808:808": false, // ISATAP -> 8.8.8.8
"2606:4700:4700::1111": false, // ordinary global unicast
}
for ipStr, expected := range cases {
ip := net.ParseIP(ipStr)
if ip == nil {
t.Fatalf("failed to parse %q", ipStr)
}
if got := isPrivateOrReservedIP(ip); got != expected {
t.Fatalf("ip %s expected %v, got %v", ipStr, expected, got)
}
}
}
func TestValidateWebhookURLRejectsIPv6TransitionLiterals(t *testing.T) {
blocked := []string{
"https://[64:ff9b::a9fe:a9fe]/latest/meta-data/",
"https://[2002:ac10:1::]/hook",
"https://[2001:db8::5efe:c0a8:101]/hook",
}
for _, raw := range blocked {
if err := validateWebhookURL(context.Background(), raw); err == nil {
t.Fatalf("expected %s to be rejected", raw)
}
}
}
func TestValidateWebhookURLRejectsResolvedIPv6TransitionAddresses(t *testing.T) {
origResolver := resolveWebhookIPs
defer func() { resolveWebhookIPs = origResolver }()
resolveWebhookIPs = func(ctx context.Context, host string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("64:ff9b::a9fe:a9fe")}}, nil
}
if err := validateWebhookURL(context.Background(), "https://webhook.example.com/hook"); err == nil {
t.Fatalf("expected hostname resolving to a NAT64 metadata address to be rejected")
}
}
func TestWebhookDelivery_QueueAndURLs(t *testing.T) {
delivery := NewWebhookDelivery([]string{"http://example.com"})
if delivery.QueueLength() != 0 {
+90
View File
@@ -0,0 +1,90 @@
package securityutil
import "net"
// EmbeddedIPv4Candidates returns every IPv4 destination that an IPv6 transition
// address can encode.
//
// SSRF guards are built out of the net.IP predicates - IsLoopback, IsPrivate,
// IsLinkLocalUnicast and friends - and every one of them inspects the literal
// 16 bytes it is handed. An IPv6 transition address carries an IPv4 destination
// somewhere inside those bytes, so a NAT64 address such as 64:ff9b::a9fe:a9fe
// reaches 169.254.169.254 while reporting itself as an ordinary global unicast
// address. Callers must run each returned candidate through the same policy
// they applied to the outer address.
//
// Plain IPv4 and IPv4-mapped addresses (::ffff:a.b.c.d) return no candidates:
// net.IP.To4 already normalises those, so the stdlib predicates see the real
// destination without help.
//
// Candidates in 0.0.0.0/8 are omitted. They are not routable destinations, and
// including them would make every ::/128 and ::1 look like a transition
// address when the unspecified and loopback checks already cover those.
func EmbeddedIPv4Candidates(ip net.IP) []net.IP {
v6 := ip.To16()
if v6 == nil || ip.To4() != nil {
return nil
}
var candidates []net.IP
add := func(a, b, c, d byte) {
if a == 0 {
return
}
embedded := net.IPv4(a, b, c, d)
for _, existing := range candidates {
if existing.Equal(embedded) {
return
}
}
candidates = append(candidates, embedded)
}
switch {
case isZeroIPBytes(v6[0:12]):
// RFC 4291 IPv4-compatible address (::a.b.c.d). Deprecated, still parsed.
add(v6[12], v6[13], v6[14], v6[15])
case isZeroIPBytes(v6[0:8]) && v6[8] == 0xff && v6[9] == 0xff && isZeroIPBytes(v6[10:12]):
// RFC 2765 IPv4-translated address (::ffff:0:a.b.c.d).
add(v6[12], v6[13], v6[14], v6[15])
case v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b:
if isZeroIPBytes(v6[4:12]) {
// RFC 6052 Well-Known Prefix 64:ff9b::/96.
add(v6[12], v6[13], v6[14], v6[15])
}
if v6[4] == 0x00 && v6[5] == 0x01 {
// RFC 8215 Local-Use Prefix 64:ff9b:1::/48. Operators pick the
// embedding length, so every RFC 6052 layout that fits under a /48
// is a candidate. Byte 8 is the reserved u octet and is skipped.
add(v6[6], v6[7], v6[9], v6[10]) // /48
add(v6[7], v6[9], v6[10], v6[11]) // /56
add(v6[9], v6[10], v6[11], v6[12]) // /64
add(v6[12], v6[13], v6[14], v6[15])
}
case v6[0] == 0x20 && v6[1] == 0x02:
// RFC 3056 6to4 (2002::/16) embeds the gateway IPv4 address.
add(v6[2], v6[3], v6[4], v6[5])
case v6[0] == 0x20 && v6[1] == 0x01 && v6[2] == 0x00 && v6[3] == 0x00:
// RFC 4380 Teredo (2001::/32) carries the server IPv4 in bytes 4-7 and
// the client IPv4 in bytes 12-15, obfuscated as its ones' complement.
add(v6[4], v6[5], v6[6], v6[7])
add(^v6[12], ^v6[13], ^v6[14], ^v6[15])
}
// RFC 5214 ISATAP interface identifiers sit under an arbitrary /64, so this
// is checked independently of the prefix cases above.
if (v6[8] == 0x00 || v6[8] == 0x02) && v6[9] == 0x00 && v6[10] == 0x5e && v6[11] == 0xfe {
add(v6[12], v6[13], v6[14], v6[15])
}
return candidates
}
func isZeroIPBytes(b []byte) bool {
for _, octet := range b {
if octet != 0 {
return false
}
}
return true
}
+149
View File
@@ -0,0 +1,149 @@
package securityutil
import (
"context"
"net"
"testing"
)
func TestEmbeddedIPv4Candidates(t *testing.T) {
tests := []struct {
name string
addr string
want []string
empty bool
}{
{name: "nat64 well-known metadata", addr: "64:ff9b::a9fe:a9fe", want: []string{"169.254.169.254"}},
{name: "nat64 well-known loopback", addr: "64:ff9b::7f00:1", want: []string{"127.0.0.1"}},
{name: "nat64 well-known private", addr: "64:ff9b::a00:1", want: []string{"10.0.0.1"}},
{name: "nat64 well-known public", addr: "64:ff9b::808:808", want: []string{"8.8.8.8"}},
{name: "nat64 local-use 96", addr: "64:ff9b:1::a9fe:a9fe", want: []string{"169.254.169.254"}},
{name: "nat64 local-use 48", addr: "64:ff9b:1:a9fe:a9:fe00::", want: []string{"169.254.169.254"}},
{name: "6to4 private", addr: "2002:ac10:0001::", want: []string{"172.16.0.1"}},
{name: "6to4 metadata", addr: "2002:a9fe:a9fe::", want: []string{"169.254.169.254"}},
{name: "teredo server and client", addr: "2001:0:a9fe:a9fe:0:0:5601:5601", want: []string{"169.254.169.254", "169.254.169.254"}},
{name: "isatap private", addr: "2001:db8::5efe:c0a8:101", want: []string{"192.168.1.1"}},
{name: "isatap group bit set", addr: "2001:db8::200:5efe:c0a8:101", want: []string{"192.168.1.1"}},
{name: "ipv4-compatible private", addr: "::192.168.1.1", want: []string{"192.168.1.1"}},
{name: "ipv4-translated private", addr: "::ffff:0:192.168.1.1", want: []string{"192.168.1.1"}},
{name: "plain ipv4 has nothing to unwrap", addr: "169.254.169.254", empty: true},
{name: "ipv4-mapped is normalised by stdlib", addr: "::ffff:169.254.169.254", empty: true},
{name: "ordinary global unicast ipv6", addr: "2606:4700:4700::1111", empty: true},
{name: "unique local ipv6", addr: "fd00::1", empty: true},
{name: "ipv6 loopback is not a transition address", addr: "::1", empty: true},
{name: "unspecified is not a transition address", addr: "::", empty: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.addr)
if ip == nil {
t.Fatalf("failed to parse %q", tc.addr)
}
got := EmbeddedIPv4Candidates(ip)
if tc.empty {
if len(got) != 0 {
t.Fatalf("expected no embedded candidates for %s, got %v", tc.addr, got)
}
return
}
for _, want := range tc.want {
wantIP := net.ParseIP(want)
found := false
for _, candidate := range got {
if candidate.Equal(wantIP) {
found = true
break
}
}
if !found {
t.Fatalf("expected %s to embed %s, got %v", tc.addr, want, got)
}
}
})
}
}
func TestEmbeddedIPv4CandidatesSkipsUnroutableFirstOctet(t *testing.T) {
// 0.0.0.0/8 destinations are not routable, and surfacing them would make
// every all-zero prefix look like a transition address.
for _, addr := range []string{"64:ff9b::0:1", "2002:0:1::", "::0.0.0.1"} {
ip := net.ParseIP(addr)
if ip == nil {
t.Fatalf("failed to parse %q", addr)
}
if got := EmbeddedIPv4Candidates(ip); len(got) != 0 {
t.Fatalf("expected %s to yield no candidates, got %v", addr, got)
}
}
}
// TestValidateOutboundIPBlocksIPv6TransitionAddresses covers the SSRF bypass
// where a NAT64, 6to4, Teredo, ISATAP or IPv4-compatible address carries a
// blocked IPv4 destination past every net.IP predicate.
func TestValidateOutboundIPBlocksIPv6TransitionAddresses(t *testing.T) {
opts := RestrictedOutboundHTTPOptions{}
blocked := []string{
"64:ff9b::a9fe:a9fe", // NAT64 -> 169.254.169.254
"64:ff9b::7f00:1", // NAT64 -> 127.0.0.1
"64:ff9b::a00:1", // NAT64 -> 10.0.0.1
"64:ff9b:1::c0a8:1", // NAT64 local-use -> 192.168.0.1
"64:ff9b:1:a9fe:a9:fe00::", // NAT64 local-use /48 -> 169.254.169.254
"2002:ac10:1::", // 6to4 -> 172.16.0.1
"2002:a9fe:a9fe::", // 6to4 -> 169.254.169.254
"2001:0:a9fe:a9fe::", // Teredo server -> 169.254.169.254
"2001:db8::5efe:c0a8:101", // ISATAP -> 192.168.1.1
"::192.168.1.1", // IPv4-compatible -> 192.168.1.1
"::ffff:0:10.0.0.1", // IPv4-translated -> 10.0.0.1
}
for _, addr := range blocked {
ip := net.ParseIP(addr)
if ip == nil {
t.Fatalf("failed to parse %q", addr)
}
if err := validateOutboundIP(ip, opts); err == nil {
t.Fatalf("expected %s to be blocked as an embedded IPv4 destination", addr)
}
}
allowed := []string{
"64:ff9b::808:808", // NAT64 -> 8.8.8.8
"2002:808:808::", // 6to4 -> 8.8.8.8
"2606:4700:4700::1111", // ordinary global unicast
"2001:db8::5efe:808:808", // ISATAP -> 8.8.8.8
}
for _, addr := range allowed {
ip := net.ParseIP(addr)
if ip == nil {
t.Fatalf("failed to parse %q", addr)
}
if err := validateOutboundIP(ip, opts); err != nil {
t.Fatalf("expected %s to be allowed, got %v", addr, err)
}
}
}
func TestValidateOutboundIPTransitionRespectsOptions(t *testing.T) {
nat64Private := net.ParseIP("64:ff9b::a00:1")
nat64Loopback := net.ParseIP("64:ff9b::7f00:1")
if err := validateOutboundIP(nat64Private, RestrictedOutboundHTTPOptions{AllowPrivateIPs: true}); err != nil {
t.Fatalf("expected NAT64-wrapped private IP to be allowed when private IPs are permitted, got %v", err)
}
if err := validateOutboundIP(nat64Loopback, RestrictedOutboundHTTPOptions{AllowLoopback: true}); err != nil {
t.Fatalf("expected NAT64-wrapped loopback to be allowed when loopback is permitted, got %v", err)
}
if err := validateOutboundIP(nat64Loopback, RestrictedOutboundHTTPOptions{AllowPrivateIPs: true}); err == nil {
t.Fatalf("expected NAT64-wrapped loopback to stay blocked when only private IPs are permitted")
}
}
func TestResolvePermittedOutboundIPsRejectsTransitionLiteral(t *testing.T) {
if _, err := resolvePermittedOutboundIPs(context.Background(), "64:ff9b::a9fe:a9fe", RestrictedOutboundHTTPOptions{}); err == nil {
t.Fatalf("expected NAT64 metadata literal to be rejected before dialling")
}
}
+8
View File
@@ -65,6 +65,14 @@ func validateOutboundIP(ip net.IP, opts RestrictedOutboundHTTPOptions) error {
if !opts.AllowPrivateIPs && ip.IsPrivate() {
return fmt.Errorf("private addresses are not allowed")
}
// Every check above reads the literal address bytes, so an IPv6 transition
// address hides its real IPv4 destination from all of them. Hold the
// embedded destination to the same policy.
for _, embedded := range EmbeddedIPv4Candidates(ip) {
if err := validateOutboundIP(embedded, opts); err != nil {
return fmt.Errorf("IPv6 transition address embeds a blocked destination: %w", err)
}
}
return nil
}