diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a50c03..872f348a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - **`scripts/setup-git-secrets.sh`:** the database DSN detector now fires on `user:password@host` (the old pattern ended at `@`, which `git-secrets`' word-match scan rejected before a hostname), and also covers `postgresql`, `mariadb`, driver-suffixed schemes and empty usernames (#125). - **`provider`:** `ProviderConfig` is renamed to `Config` (#39). `ProviderConfig` stays as a deprecated type alias, so existing consumers keep compiling; migrate to `provider.Config`. The provider modules move over once their pinned `pkg` version includes the rename. - **`provider`:** `ProviderConfig` is renamed to `Config` (#39). `ProviderConfig` stays as a type alias, so existing consumers keep compiling; migrate to `provider.Config`. The provider modules move over once their pinned `pkg` version includes the rename. +- **`httpclient`:** dials to the RFC 8215 local-use NAT64 prefix `64:ff9b:1::/48` are refused when any RFC 6052 layout (/48, /56, /64, /96) embeds a metadata IPv4 address; Teredo `2001::/32` stays allowed (#238). - **`common.HoursPerMonth`:** exported 730-hour average month (`HoursPerYear / 12`) next to `HoursPerYear`; the remaining bare 730 and 8760 literals in the aws and gcp providers use the shared constants (#109). - **`gcp`:** region-lookup permission errors are now classified by type only (googleapi 403 or gRPC `PermissionDenied`) through the shared `IsPermissionError`. gRPC `PermissionDenied` is now skipped with a warning like a 403, and an untyped error whose text merely contains "403" and "permission" now propagates instead of being skipped (#6). - **BREAKING `gcp`: stored contexts removed (#6).** `computeengine.NewClient` diff --git a/pkg/httpclient/embedded_test.go b/pkg/httpclient/embedded_test.go index eba2d89b..a5922885 100644 --- a/pkg/httpclient/embedded_test.go +++ b/pkg/httpclient/embedded_test.go @@ -66,6 +66,80 @@ func TestBlockMetadata_EmbeddedAndUnspecified(t *testing.T) { } } +// nat64Local returns an address in 64:ff9b:1::/48 whose RFC 6052 layout +// (index into rfc6052Layouts) carries v4. +func nat64Local(t *testing.T, layout int, v4 string) string { + t.Helper() + b := netip.MustParseAddr("64:ff9b:1::").As16() + o := netip.MustParseAddr(v4).As4() + for i, off := range rfc6052Layouts[layout] { + b[off] = o[i] + } + return "[" + netip.AddrFrom16(b).String() + "]:80" +} + +// A local-use NAT64 prefix (RFC 8215) may embed any IPv4 under any RFC 6052 +// layout (/48, /56, /64, /96), so each layout carrying a metadata address must +// be refused, and a global one must not. +func TestBlockMetadata_LocalNAT64AllLayouts(t *testing.T) { + t.Parallel() + for layout := range rfc6052Layouts { + for _, v4 := range []string{ + "169.254.169.254", // IMDS + "169.254.170.2", // ECS credentials + "168.63.129.16", // Azure WireServer + "100.100.100.200", // Alibaba IMDS + } { + addr := nat64Local(t, layout, v4) + t.Run(addr, func(t *testing.T) { + t.Parallel() + if err := blockMetadata("tcp", addr, nil); err == nil { + t.Fatalf("%s (layout %d, %s) must be blocked", addr, layout, v4) + } + }) + } + addr := nat64Local(t, layout, "8.8.8.8") + t.Run(addr, func(t *testing.T) { + t.Parallel() + if err := blockMetadata("tcp", addr, nil); err != nil { + t.Fatalf("%s must stay allowed: %v", addr, err) + } + }) + } + for _, addr := range []string{ + "[64:ff9b:1::a9fe:a9fe]:80", // /96 literal from the issue + "[64:ff9b:1::169.254.169.254]:80", + "[64:ff9b:1::a9fe:a9fe%eth0]:80", + } { + t.Run(addr, func(t *testing.T) { + t.Parallel() + if err := blockMetadata("tcp", addr, nil); err == nil { + t.Fatalf("%s must be blocked", addr) + } + }) + } + if err := blockMetadata("tcp", "[64:ff9b:2::a9fe:a9fe]:80", nil); err != nil { + t.Fatalf("address outside the /48 must stay allowed: %v", err) + } +} + +// Teredo is intentionally not decoded (see the Teredo comment in +// httpclient.go). +func TestBlockMetadata_TeredoStaysAllowed(t *testing.T) { + t.Parallel() + for _, addr := range []string{ + "[2001:0:4136:e378:8000:63bf:5601:5601]:80", // client field de-obfuscates to 169.254.169.254 + "[2001::a9fe:a9fe]:80", + } { + t.Run(addr, func(t *testing.T) { + t.Parallel() + if err := blockMetadata("tcp", addr, nil); err != nil { + t.Fatalf("%s must stay allowed: %v", addr, err) + } + }) + } +} + // The guard runs for every dial, so a redirect to an embedded form or an // unspecified address is refused as well. func TestNew_BlocksEmbeddedFormsViaRedirect(t *testing.T) { diff --git a/pkg/httpclient/httpclient.go b/pkg/httpclient/httpclient.go index ba54b02c..2c0a4d98 100644 --- a/pkg/httpclient/httpclient.go +++ b/pkg/httpclient/httpclient.go @@ -64,6 +64,46 @@ var embeddedIPv4Prefixes = []struct { {netip.MustParsePrefix("::/96"), 12}, } +// localNAT64 is the RFC 8215 local-use NAT64 prefix. Unlike the well-known +// 64:ff9b::/96 (which RFC 6052 section 3.1 forbids from carrying non-global +// IPv4), a network-specific prefix may embed any IPv4 address, including +// metadata ones, and the operator chooses the prefix length (/48 to /96). The +// IPv4 address is therefore decoded under every RFC 6052 layout and the dial is +// refused if any decoding is blocked (#238). +var localNAT64 = netip.MustParsePrefix("64:ff9b:1::/48") + +// rfc6052Layouts lists, per NAT64 prefix length (/48, /56, /64, /96), the byte +// offsets of the four IPv4 octets in the 16-byte form. Byte 8 is the reserved +// "u" octet and is skipped by RFC 6052. +var rfc6052Layouts = [][4]int{ + {6, 7, 9, 10}, + {7, 9, 10, 11}, + {9, 10, 11, 12}, + {12, 13, 14, 15}, +} + +// localNAT64Embedded returns the IPv4 address ip would translate to under each +// RFC 6052 layout, or nil when ip is outside localNAT64. +func localNAT64Embedded(ip netip.Addr) []netip.Addr { + if !localNAT64.Contains(ip) { + return nil + } + b := ip.As16() + out := make([]netip.Addr, 0, len(rfc6052Layouts)) + for _, l := range rfc6052Layouts { + out = append(out, netip.AddrFrom4([4]byte{b[l[0]], b[l[1]], b[l[2]], b[l[3]]})) + } + return out +} + +// Teredo (2001::/32, deprecated) is deliberately NOT decoded (#238; pinned by +// TestBlockMetadata_TeredoStaysAllowed). An address carries the relay's IPv4 +// and the client's obfuscated IPv4 (XOR 0xffff). The obfuscated client IPv4 is +// where a relay sends the tunneled packets, as IPv6 inside UDP; it is not +// reachable as a metadata endpoint, because nothing here opens a TCP +// connection to it. A field that decodes to a metadata address is therefore +// not a path to that endpoint. + // embeddedIPv4 returns the IPv4 address ip carries, if any. func embeddedIPv4(ip netip.Addr) (netip.Addr, bool) { b := ip.As16() @@ -105,6 +145,11 @@ func blockMetadata(_, address string, _ syscall.RawConn) error { if v4, ok := embeddedIPv4(ip); ok && isMetadata(v4) { return fmt.Errorf("connection to metadata endpoint %s (embedded in %s) is blocked", v4, ip) } + for _, v4 := range localNAT64Embedded(ip) { + if isMetadata(v4) { + return fmt.Errorf("connection to metadata endpoint %s (embedded in %s) is blocked", v4, ip) + } + } return nil }