diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index ea912da16..80b57aaf8 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -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. diff --git a/pkg/audit/webhook.go b/pkg/audit/webhook.go index b472d7ab1..4b471bef0 100644 --- a/pkg/audit/webhook.go +++ b/pkg/audit/webhook.go @@ -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 } diff --git a/pkg/audit/webhook_validation_test.go b/pkg/audit/webhook_validation_test.go index 20d2cb724..8c4637c9c 100644 --- a/pkg/audit/webhook_validation_test.go +++ b/pkg/audit/webhook_validation_test.go @@ -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 { diff --git a/pkg/securityutil/embedded_ipv4.go b/pkg/securityutil/embedded_ipv4.go new file mode 100644 index 000000000..8232f8d05 --- /dev/null +++ b/pkg/securityutil/embedded_ipv4.go @@ -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 +} diff --git a/pkg/securityutil/embedded_ipv4_test.go b/pkg/securityutil/embedded_ipv4_test.go new file mode 100644 index 000000000..e1dc2bb22 --- /dev/null +++ b/pkg/securityutil/embedded_ipv4_test.go @@ -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") + } +} diff --git a/pkg/securityutil/outbound_http.go b/pkg/securityutil/outbound_http.go index 02a0741a8..7f88980c3 100644 --- a/pkg/securityutil/outbound_http.go +++ b/pkg/securityutil/outbound_http.go @@ -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 }