diff --git a/seaweed-volume/src/remote_storage/endpoint_guard.rs b/seaweed-volume/src/remote_storage/endpoint_guard.rs index 90e051ff6..d3dbf7fa6 100644 --- a/seaweed-volume/src/remote_storage/endpoint_guard.rs +++ b/seaweed-volume/src/remote_storage/endpoint_guard.rs @@ -17,7 +17,7 @@ //! narrow TOCTOU window (a hostname that resolves to a public IP here and then //! flips to a blocked one when the SDK dials) remains as a follow-up. -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; /// AWS/Azure/GCP IPv4 instance-metadata-service (IMDS) address. It is /// link-local and thus already covered by [`is_link_local`], but is named @@ -74,6 +74,41 @@ fn is_cgnat(ip: IpAddr) -> bool { } } +/// Returns the IPv4 address carried by an IPv6 transition address -- NAT64 +/// 64:ff9b::/96 (RFC 6052), 6to4 2002::/16 (RFC 3056), Teredo 2001:0000::/32 +/// (RFC 4380), and the deprecated IPv4-compatible ::/96 (RFC 4291) -- or None +/// when it is not one of those. IPv4-mapped ::ffff:0:0/96 is excluded; it is +/// already normalized via `to_ipv4_mapped`. +fn embedded_transition_ipv4(v6: Ipv6Addr) -> Option { + let o = v6.octets(); + if o[0] == 0x00 + && o[1] == 0x64 + && o[2] == 0xff + && o[3] == 0x9b + && o[4..12].iter().all(|&b| b == 0) + { + return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15])); + } + if o[0] == 0x20 && o[1] == 0x02 { + return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5])); + } + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && o[3] == 0x00 { + // Teredo obfuscates the client IPv4 as its ones' complement. + return Some(Ipv4Addr::new( + o[12] ^ 0xff, + o[13] ^ 0xff, + o[14] ^ 0xff, + o[15] ^ 0xff, + )); + } + if o[..12].iter().all(|&b| b == 0) { + // IPv4-compatible ::a.b.c.d; :: and ::1 are already handled by the + // unspecified / loopback checks before extraction runs. + return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15])); + } + None +} + /// Returns an error if `ip` is not safe to dial from a server that can reach /// cluster-internal hosts. Mirrors Go's `checkBlockedIP`. pub fn check_blocked_ip(endpoint: &str, ip: IpAddr) -> Result<(), String> { @@ -124,6 +159,15 @@ pub fn check_blocked_ip(endpoint: &str, ip: IpAddr) -> Result<(), String> { endpoint, ip )); } + // IPv6 transition addresses embed an IPv4 destination that routes to the + // same host wherever the matching relay exists (common in IPv6-only cloud). + // to_ipv4_mapped above only covers ::ffff: mapped addresses, so pull the + // embedded IPv4 out of the other forms and re-check it against the rules. + if let IpAddr::V6(v6) = ip { + if let Some(v4) = embedded_transition_ipv4(v6) { + return check_blocked_ip(endpoint, IpAddr::V4(v4)); + } + } Ok(()) } @@ -333,6 +377,49 @@ mod tests { assert!(check_blocked_ip("e", ip("2606:4700:4700::1111")).is_ok()); } + #[test] + fn rejects_ipv6_transition_addresses() { + // Every transition form encoding an internal IPv4 must be blocked. + assert!( + check_blocked_ip("e", ip("64:ff9b::a9fe:a9fe")) // NAT64 -> IMDS + .unwrap_err() + .contains("metadata") + ); + assert!( + check_blocked_ip("e", ip("64:ff9b::7f00:1")) // NAT64 -> loopback + .unwrap_err() + .contains("loopback") + ); + assert!( + check_blocked_ip("e", ip("2002:a00:1::")) // 6to4 -> 10.0.0.1 + .unwrap_err() + .contains("private") + ); + assert!( + check_blocked_ip("e", ip("2001:0:4136:e378:8000:63bf:80ff:fffe")) // Teredo -> loopback + .unwrap_err() + .contains("loopback") + ); + assert!( + check_blocked_ip("e", ip("::7f00:1")) // IPv4-compatible -> loopback + .unwrap_err() + .contains("loopback") + ); + // A NAT64 address outside the 64:ff9b::/96 well-known prefix is not + // decoded (its embedded IPv4 lives elsewhere), so it is left as-is. + assert!(check_blocked_ip("e", ip("64:ff9b:1::a9fe:a9fe")).is_ok()); + // Every transition form embedding a public IPv4 (8.8.8.8) still passes: + // NAT64, 6to4, Teredo, IPv4-compatible. + assert!(check_blocked_ip("e", ip("64:ff9b::808:808")).is_ok()); + assert!(check_blocked_ip("e", ip("2002:808:808::")).is_ok()); + assert!(check_blocked_ip("e", ip("2001::f7f7:f7f7")).is_ok()); + assert!(check_blocked_ip("e", ip("::808:808")).is_ok()); + // Bracketed transition literal via the full endpoint path. + assert!(precheck_endpoint("http://[64:ff9b::a9fe:a9fe]/") + .unwrap_err() + .contains("metadata")); + } + #[test] fn rejects_ipv4_mapped_ipv6() { // IPv4-mapped IPv6 must be unmapped so the IPv4 rules catch it. diff --git a/weed/server/volume_grpc_remote.go b/weed/server/volume_grpc_remote.go index 651952300..36fe5c683 100644 --- a/weed/server/volume_grpc_remote.go +++ b/weed/server/volume_grpc_remote.go @@ -107,9 +107,50 @@ func checkBlockedIP(endpoint string, ip net.IP) error { case cgnatNet.Contains(ip): return fmt.Errorf("remote endpoint %q resolves to CGNAT address %s", endpoint, ip) } + // IPv6 transition addresses embed an IPv4 destination that routes to the + // same host wherever the matching relay exists (common in IPv6-only cloud). + // net.IP only normalizes ::ffff: mapped addresses, so pull the embedded + // IPv4 out of the other forms and re-check it against the deny list. + if embedded := embeddedTransitionIPv4(ip); embedded != nil { + return checkBlockedIP(endpoint, embedded) + } return nil } +// embeddedTransitionIPv4 returns the IPv4 address carried by an IPv6 transition +// address -- NAT64 64:ff9b::/96 (RFC 6052), 6to4 2002::/16 (RFC 3056), Teredo +// 2001:0000::/32 (RFC 4380), and the deprecated IPv4-compatible ::/96 (RFC +// 4291) -- or nil when ip is not one of those. IPv4-mapped ::ffff:0:0/96 is +// excluded because net.IP already normalizes it via To4. +func embeddedTransitionIPv4(ip net.IP) net.IP { + v6 := ip.To16() + if v6 == nil || ip.To4() != nil { + return nil + } + switch { + case v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b && allZero(v6[4:12]): + return net.IPv4(v6[12], v6[13], v6[14], v6[15]) + case v6[0] == 0x20 && v6[1] == 0x02: + return net.IPv4(v6[2], v6[3], v6[4], v6[5]) + case v6[0] == 0x20 && v6[1] == 0x01 && v6[2] == 0x00 && v6[3] == 0x00: + // Teredo obfuscates the client IPv4 as its ones' complement. + return net.IPv4(v6[12]^0xff, v6[13]^0xff, v6[14]^0xff, v6[15]^0xff) + case allZero(v6[:12]): + // IPv4-compatible ::a.b.c.d; :: and ::1 are already handled above. + return net.IPv4(v6[12], v6[13], v6[14], v6[15]) + } + return nil +} + +func allZero(b []byte) bool { + for _, c := range b { + if c != 0 { + return false + } + } + return true +} + // guardedDialer returns a DialContext that resolves the host itself and // re-applies checkBlockedIP to every resolved address immediately before // dialing. This closes the DNS-rebinding window between diff --git a/weed/server/volume_grpc_remote_test.go b/weed/server/volume_grpc_remote_test.go index 9992bebc0..229c32f9c 100644 --- a/weed/server/volume_grpc_remote_test.go +++ b/weed/server/volume_grpc_remote_test.go @@ -166,6 +166,61 @@ func TestValidateRemoteEndpoint(t *testing.T) { wantErr: true, wantSub: "CGNAT", }, + { + name: "nat64 imds", + endpoint: "http://[64:ff9b::a9fe:a9fe]/", + wantErr: true, + wantSub: "metadata", + }, + { + name: "nat64 loopback", + endpoint: "http://[64:ff9b::7f00:1]/", + wantErr: true, + wantSub: "loopback", + }, + { + name: "6to4 private", + endpoint: "http://[2002:a00:1::]/", + wantErr: true, + wantSub: "private", + }, + { + name: "teredo loopback", + endpoint: "http://[2001:0:4136:e378:8000:63bf:80ff:fffe]/", + wantErr: true, + wantSub: "loopback", + }, + { + name: "ipv4-compatible loopback", + endpoint: "http://[::7f00:1]/", + wantErr: true, + wantSub: "loopback", + }, + { + name: "nat64 public passes", + endpoint: "http://[64:ff9b::808:808]/", + wantErr: false, + }, + { + name: "6to4 public passes", + endpoint: "http://[2002:808:808::]/", + wantErr: false, + }, + { + name: "teredo public passes", + endpoint: "http://[2001::f7f7:f7f7]/", + wantErr: false, + }, + { + name: "ipv4-compatible public passes", + endpoint: "http://[::808:808]/", + wantErr: false, + }, + { + name: "nat64 non-wellknown-prefix not decoded", + endpoint: "http://[64:ff9b:1::a9fe:a9fe]/", + wantErr: false, + }, { name: "public s3", endpoint: "https://s3.us-east-1.amazonaws.com/",