Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
74 changes: 74 additions & 0 deletions pkg/httpclient/embedded_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions pkg/httpclient/httpclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down
Loading