From a198f5a82005783fa1c39f73d25f4535ce77de1e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 17:27:19 +0200 Subject: [PATCH 1/4] test(httpclient): pin 64:ff9b:1::/48 and 2001::/32 as intentionally allowed Document why the local-use NAT64 and Teredo ranges are not decoded as embedded IPv4 in embeddedIPv4Prefixes, and add a test so adding either range to the list is a deliberate change. The Unmap() cases from the issue are already covered in embedded_test.go. Refs #238 Co-Authored-By: Claude Opus 5.5 --- pkg/httpclient/embedded_test.go | 20 ++++++++++++++++++++ pkg/httpclient/httpclient.go | 10 ++++++++++ 2 files changed, 30 insertions(+) diff --git a/pkg/httpclient/embedded_test.go b/pkg/httpclient/embedded_test.go index eba2d89b..3e4ddf06 100644 --- a/pkg/httpclient/embedded_test.go +++ b/pkg/httpclient/embedded_test.go @@ -66,6 +66,26 @@ func TestBlockMetadata_EmbeddedAndUnspecified(t *testing.T) { } } +// 64:ff9b:1::/48 (local-use NAT64) and 2001::/32 (Teredo) are intentionally +// not decoded as embedded IPv4 (see embeddedIPv4Prefixes). Pin that so adding +// either range to the embedding list is a deliberate, test-visible change. +func TestBlockMetadata_UnlistedEmbeddingRangesStayAllowed(t *testing.T) { + t.Parallel() + for _, addr := range []string{ + "[64:ff9b:1::a9fe:a9fe]:80", + "[64:ff9b:1:0:0:0:a9fe:a9fe]:80", + "[2001:0:4136:e378:8000:63bf:5601:5601]:80", // Teredo-shaped + "[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..5d3a6533 100644 --- a/pkg/httpclient/httpclient.go +++ b/pkg/httpclient/httpclient.go @@ -64,6 +64,16 @@ var embeddedIPv4Prefixes = []struct { {netip.MustParsePrefix("::/96"), 12}, } +// Two IPv6 ranges that look similar are deliberately NOT in the list above +// (#238; pinned by TestBlockMetadata_UnlistedEmbeddingRangesStayAllowed): +// - 64:ff9b:1::/48 (RFC 8215 local-use NAT64) has an operator-chosen +// translation layout, so the IPv4 offset cannot be assumed; no default +// gateway forwards it to a fixed embedded address. +// - 2001::/32 (Teredo, deprecated) carries the relay's IPv4 address and the +// client's obfuscated address, never the destination IPv4. +// +// If a deployment is shown to translate either range to an embedded +// destination, add it here with its offset and move the test case to blocked. // embeddedIPv4 returns the IPv4 address ip carries, if any. func embeddedIPv4(ip netip.Addr) (netip.Addr, bool) { b := ip.As16() From c6d4eb9742499a977a86ad8bfa56f1ac9f8cf472 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 18:14:20 +0200 Subject: [PATCH 2/4] fix(httpclient): block local-use NAT64 addresses that embed metadata IPv4 RFC 8215 64:ff9b:1::/48 is network-specific, so unlike the well-known prefix it may embed 169.254.169.254, and the operator picks the prefix length. Decode the embedded IPv4 under every RFC 6052 layout (/48, /56, /64, /96) and refuse the dial if any decoding is a metadata address. Teredo stays allowed; correct the comment to say why. Refs #238 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + pkg/httpclient/embedded_test.go | 68 +++++++++++++++++++++++++++++---- pkg/httpclient/httpclient.go | 54 +++++++++++++++++++++----- 3 files changed, 106 insertions(+), 17 deletions(-) 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 3e4ddf06..a5922885 100644 --- a/pkg/httpclient/embedded_test.go +++ b/pkg/httpclient/embedded_test.go @@ -66,15 +66,69 @@ func TestBlockMetadata_EmbeddedAndUnspecified(t *testing.T) { } } -// 64:ff9b:1::/48 (local-use NAT64) and 2001::/32 (Teredo) are intentionally -// not decoded as embedded IPv4 (see embeddedIPv4Prefixes). Pin that so adding -// either range to the embedding list is a deliberate, test-visible change. -func TestBlockMetadata_UnlistedEmbeddingRangesStayAllowed(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{ - "[64:ff9b:1::a9fe:a9fe]:80", - "[64:ff9b:1:0:0:0:a9fe:a9fe]:80", - "[2001:0:4136:e378:8000:63bf:5601:5601]:80", // Teredo-shaped + "[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) { diff --git a/pkg/httpclient/httpclient.go b/pkg/httpclient/httpclient.go index 5d3a6533..b2f5a353 100644 --- a/pkg/httpclient/httpclient.go +++ b/pkg/httpclient/httpclient.go @@ -64,16 +64,45 @@ var embeddedIPv4Prefixes = []struct { {netip.MustParsePrefix("::/96"), 12}, } -// Two IPv6 ranges that look similar are deliberately NOT in the list above -// (#238; pinned by TestBlockMetadata_UnlistedEmbeddingRangesStayAllowed): -// - 64:ff9b:1::/48 (RFC 8215 local-use NAT64) has an operator-chosen -// translation layout, so the IPv4 offset cannot be assumed; no default -// gateway forwards it to a fixed embedded address. -// - 2001::/32 (Teredo, deprecated) carries the relay's IPv4 address and the -// client's obfuscated address, never the destination IPv4. -// -// If a deployment is shown to translate either range to an embedded -// destination, add it here with its offset and move the test case to blocked. +// 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), but a Teredo relay forwards +// the packet as IPv6 inside UDP to the client; the stack never opens a TCP +// connection to either embedded IPv4 as the destination. An obfuscated +// field that decodes to a metadata address is therefore not a path to it. + // embeddedIPv4 returns the IPv4 address ip carries, if any. func embeddedIPv4(ip netip.Addr) (netip.Addr, bool) { b := ip.As16() @@ -115,6 +144,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 } From 5b9cd46cc194551c804db63612d5ff03309b60f6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 18:27:21 +0200 Subject: [PATCH 3/4] docs(httpclient): reword the Teredo comment The obfuscated client IPv4 is where a Teredo relay sends tunnelled traffic, so say it is not reachable as a metadata endpoint instead of claiming it is never a destination. Refs #238 Co-Authored-By: Claude Opus 5.5 --- pkg/httpclient/httpclient.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/httpclient/httpclient.go b/pkg/httpclient/httpclient.go index b2f5a353..4ec95485 100644 --- a/pkg/httpclient/httpclient.go +++ b/pkg/httpclient/httpclient.go @@ -98,10 +98,11 @@ func localNAT64Embedded(ip netip.Addr) []netip.Addr { // 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), but a Teredo relay forwards -// the packet as IPv6 inside UDP to the client; the stack never opens a TCP -// connection to either embedded IPv4 as the destination. An obfuscated -// field that decodes to a metadata address is therefore not a path to it. +// and the client's obfuscated IPv4 (XOR 0xffff). The obfuscated client IPv4 is +// where a relay sends the tunnelled 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) { From 91a9f35776e07564a6436d6e11fab71e40e6ebb6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 18:39:17 +0200 Subject: [PATCH 4/4] docs(httpclient): fix misspelling in the Teredo comment Refs #238 Co-Authored-By: Claude Opus 5.5 --- pkg/httpclient/httpclient.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/httpclient/httpclient.go b/pkg/httpclient/httpclient.go index 4ec95485..2c0a4d98 100644 --- a/pkg/httpclient/httpclient.go +++ b/pkg/httpclient/httpclient.go @@ -99,7 +99,7 @@ func localNAT64Embedded(ip netip.Addr) []netip.Addr { // 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 tunnelled packets, as IPv6 inside UDP; it is not +// 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.