From d884caeb89d2b3fbacc68514d59a7719d7be277a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 20:50:47 +0300 Subject: [PATCH 1/5] Block private IPv4 in IPv6 transition addresses Co-authored-by: Medulla --- crates/tinytools-std/src/url_guard/README.md | 4 ++- crates/tinytools-std/src/url_guard/mod.rs | 31 ++++++++++++++------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/tinytools-std/src/url_guard/README.md b/crates/tinytools-std/src/url_guard/README.md index 29f68bb..9e96dd3 100644 --- a/crates/tinytools-std/src/url_guard/README.md +++ b/crates/tinytools-std/src/url_guard/README.md @@ -23,7 +23,9 @@ hostname for TLS SNI and the Host header. Validate every redirect destination before following it. The guard rejects loopback, private, link-local, multicast, documentation, -shared-address, local names, IPv4-mapped IPv6, and NAT64 translation prefixes. +shared-address, local names, IPv4-mapped and IPv4-compatible IPv6, private +IPv4 destinations embedded in NAT64 and 6to4 addresses, private Teredo server +and client addresses, and NAT64 translation prefixes. Its lexical checks reject userinfo, backslashes, percent-encoded hosts, and IPv6 URL literals because downstream URL parsers can interpret those forms differently. This crate supplies no HTTP transport, so connection pinning and diff --git a/crates/tinytools-std/src/url_guard/mod.rs b/crates/tinytools-std/src/url_guard/mod.rs index ae996f8..b06b036 100644 --- a/crates/tinytools-std/src/url_guard/mod.rs +++ b/crates/tinytools-std/src/url_guard/mod.rs @@ -6,7 +6,7 @@ //! - **Open allowlist** (`allowed_domains` is empty): any public non-private //! host is permitted. All SSRF guards still apply (loopback / RFC1918 / //! link-local / multicast / documentation / shared-address / -//! IPv4-mapped IPv6, `localhost` / `*.localhost` / `*.local`). +//! IPv4-mapped and transition IPv6, `localhost` / `*.localhost` / `*.local`). //! - **Strict allowlist** (`allowed_domains` is non-empty): only the listed //! domains and their subdomains are permitted. //! @@ -452,15 +452,15 @@ pub fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { || a == 0 } -/// Whether an IPv6 address is non-global (loopback, ULA, link-local, mapped, ...). +/// Whether an IPv6 address is non-global, including private IPv4 destinations +/// embedded in transition addresses. pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { let segs = v6.segments(); - let well_known_nat64_v4 = - (segs[0] == 0x0064 && segs[1] == 0xff9b && segs[2] == 0 && segs[3] == 0).then(|| { - let [first, second] = segs[6].to_be_bytes(); - let [third, fourth] = segs[7].to_be_bytes(); - std::net::Ipv4Addr::new(first, second, third, fourth) - }); + let embedded_v4 = |hi: u16, lo: u16| { + let [first, second] = hi.to_be_bytes(); + let [third, fourth] = lo.to_be_bytes(); + is_non_global_v4(std::net::Ipv4Addr::new(first, second, third, fourth)) + }; v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() @@ -472,7 +472,20 @@ pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { // Local-use translation (RFC 8215) and the well-known NAT64 prefix // can embed addresses that translate to private IPv4 destinations. || (segs[0] == 0x0064 && segs[1] == 0xff9b && segs[2] == 1) - || well_known_nat64_v4.is_some_and(is_non_global_v4) + || (segs[0] == 0x0064 + && segs[1] == 0xff9b + && segs[2..6] == [0; 4] + && embedded_v4(segs[6], segs[7])) + // 6to4 carries the destination IPv4 address immediately after 2002::/16. + || (segs[0] == 0x2002 && embedded_v4(segs[1], segs[2])) + // Teredo carries its server IPv4 address and the client's XOR-obfuscated + // IPv4 address. Either may be an internal destination. + || (segs[0] == 0x2001 + && segs[1] == 0 + && (embedded_v4(segs[2], segs[3]) + || embedded_v4(!segs[6], !segs[7]))) + // Deprecated IPv4-compatible addresses put IPv4 in the final 32 bits. + || (segs[..6] == [0; 6] && embedded_v4(segs[6], segs[7])) || (segs[0] & 0xfff0) == 0x3ff0 || segs[0] == 0x5f00 || v6.to_ipv4_mapped().is_some_and(is_non_global_v4) From 84a9121919e860eb963fcb4065597fcfb51eabc5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 05:57:34 +0300 Subject: [PATCH 2/5] fix(url_guard): use standard IPv4 transition conversion Co-authored-by: Medulla --- crates/tinytools-std/src/url_guard/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tinytools-std/src/url_guard/mod.rs b/crates/tinytools-std/src/url_guard/mod.rs index b06b036..486435e 100644 --- a/crates/tinytools-std/src/url_guard/mod.rs +++ b/crates/tinytools-std/src/url_guard/mod.rs @@ -484,11 +484,10 @@ pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { && segs[1] == 0 && (embedded_v4(segs[2], segs[3]) || embedded_v4(!segs[6], !segs[7]))) - // Deprecated IPv4-compatible addresses put IPv4 in the final 32 bits. - || (segs[..6] == [0; 6] && embedded_v4(segs[6], segs[7])) || (segs[0] & 0xfff0) == 0x3ff0 || segs[0] == 0x5f00 - || v6.to_ipv4_mapped().is_some_and(is_non_global_v4) + // `to_ipv4` covers both mapped and deprecated compatible addresses. + || v6.to_ipv4().is_some_and(is_non_global_v4) } #[cfg(test)] From 4af88acedf78a91f4c3e37bce4c70867e14955be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 06:00:27 +0300 Subject: [PATCH 3/5] refactor(url_guard): express Teredo prefix and candidates directly Co-authored-by: Medulla --- crates/tinytools-std/src/url_guard/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/tinytools-std/src/url_guard/mod.rs b/crates/tinytools-std/src/url_guard/mod.rs index 486435e..83f8ea9 100644 --- a/crates/tinytools-std/src/url_guard/mod.rs +++ b/crates/tinytools-std/src/url_guard/mod.rs @@ -480,10 +480,8 @@ pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { || (segs[0] == 0x2002 && embedded_v4(segs[1], segs[2])) // Teredo carries its server IPv4 address and the client's XOR-obfuscated // IPv4 address. Either may be an internal destination. - || (segs[0] == 0x2001 - && segs[1] == 0 - && (embedded_v4(segs[2], segs[3]) - || embedded_v4(!segs[6], !segs[7]))) + || (segs[..2] == [0x2001, 0] + && (embedded_v4(segs[2], segs[3]) || embedded_v4(!segs[6], !segs[7]))) || (segs[0] & 0xfff0) == 0x3ff0 || segs[0] == 0x5f00 // `to_ipv4` covers both mapped and deprecated compatible addresses. From dcfbc07a834e74079f5024b05f895141f841d7d6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 06:59:54 +0300 Subject: [PATCH 4/5] test(url_guard): cover IPv6 transition addresses Co-authored-by: Medulla --- .../tinytools-std/src/url_guard/mod_tests.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/tinytools-std/src/url_guard/mod_tests.rs b/crates/tinytools-std/src/url_guard/mod_tests.rs index 68ecea5..02126fe 100644 --- a/crates/tinytools-std/src/url_guard/mod_tests.rs +++ b/crates/tinytools-std/src/url_guard/mod_tests.rs @@ -355,6 +355,57 @@ fn blocks_nat64_translation_prefixes() { assert!(!is_private_or_local_host("2001:4860:4860::8888")); } +#[test] +fn classifies_well_known_nat64_embedded_addresses() { + assert!(is_private_or_local_host("64:ff9b::a00:1")); + assert!(is_private_or_local_host("64:ff9b::7f00:1")); + assert!(is_private_or_local_host("64:ff9b::c633:6401")); + assert!(!is_private_or_local_host("64:ff9b::808:808")); + // This is outside the exact /96 translation prefix. + assert!(!is_private_or_local_host("64:ff9b:0:0:0:1:a00:1")); +} + +#[test] +fn classifies_6to4_embedded_destinations() { + assert!(is_private_or_local_host("2002:a00:1::")); + assert!(is_private_or_local_host("2002:7f00:1::")); + assert!(!is_private_or_local_host("2002:808:808::")); +} + +#[test] +fn classifies_teredo_server_and_client_addresses() { + // The last two segments are the client's IPv4 address with every bit inverted. + assert!(is_private_or_local_host("2001:0:808:808:0:0:f5ff:fffe")); + assert!(is_private_or_local_host("2001:0:a00:1:0:0:f7f7:f7f7")); + assert!(!is_private_or_local_host("2001:0:808:808:0:0:fefe:fefe")); +} + +#[test] +fn classifies_mapped_and_compatible_ipv4_addresses() { + assert!(is_private_or_local_host("::ffff:10.0.0.1")); + assert!(!is_private_or_local_host("::ffff:8.8.8.8")); + assert!(is_private_or_local_host("::10.0.0.1")); + assert!(!is_private_or_local_host("::8.8.8.8")); +} + +#[tokio::test] +async fn dns_check_rejects_private_ipv4_inside_transition_address() -> anyhow::Result<()> { + let err = validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { + Ok(vec!["2002:a00:1::".parse()?]) + }) + .await + .rejection()?; + assert!(err.contains("DNS rebinding blocked")); + + let allowed = + validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { + Ok(vec!["2002:808:808::".parse()?]) + }) + .await?; + assert_eq!(allowed.addrs[0].ip().to_string(), "2002:808:808::"); + Ok(()) +} + #[test] fn allows_public_ipv6() { assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); From 0e4011d0df2ac87da4679540a38cec350926ff29 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 07:01:28 +0300 Subject: [PATCH 5/5] fix(url_guard): classify Teredo client as destination Co-authored-by: Medulla --- crates/tinytools-std/src/url_guard/README.md | 5 +++-- crates/tinytools-std/src/url_guard/mod.rs | 7 +++---- crates/tinytools-std/src/url_guard/mod_tests.rs | 5 +++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/tinytools-std/src/url_guard/README.md b/crates/tinytools-std/src/url_guard/README.md index 9e96dd3..81c41b8 100644 --- a/crates/tinytools-std/src/url_guard/README.md +++ b/crates/tinytools-std/src/url_guard/README.md @@ -24,8 +24,9 @@ before following it. The guard rejects loopback, private, link-local, multicast, documentation, shared-address, local names, IPv4-mapped and IPv4-compatible IPv6, private -IPv4 destinations embedded in NAT64 and 6to4 addresses, private Teredo server -and client addresses, and NAT64 translation prefixes. +IPv4 destinations embedded in NAT64 and 6to4 addresses, private Teredo client +addresses, and NAT64 translation prefixes. A Teredo server address is not the +IPv4 destination represented by the endpoint. Its lexical checks reject userinfo, backslashes, percent-encoded hosts, and IPv6 URL literals because downstream URL parsers can interpret those forms differently. This crate supplies no HTTP transport, so connection pinning and diff --git a/crates/tinytools-std/src/url_guard/mod.rs b/crates/tinytools-std/src/url_guard/mod.rs index 83f8ea9..c36decc 100644 --- a/crates/tinytools-std/src/url_guard/mod.rs +++ b/crates/tinytools-std/src/url_guard/mod.rs @@ -478,10 +478,9 @@ pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { && embedded_v4(segs[6], segs[7])) // 6to4 carries the destination IPv4 address immediately after 2002::/16. || (segs[0] == 0x2002 && embedded_v4(segs[1], segs[2])) - // Teredo carries its server IPv4 address and the client's XOR-obfuscated - // IPv4 address. Either may be an internal destination. - || (segs[..2] == [0x2001, 0] - && (embedded_v4(segs[2], segs[3]) || embedded_v4(!segs[6], !segs[7]))) + // Teredo carries a server IPv4 address, but the destination is the + // client's XOR-obfuscated IPv4 address in the last two segments. + || (segs[..2] == [0x2001, 0] && embedded_v4(!segs[6], !segs[7])) || (segs[0] & 0xfff0) == 0x3ff0 || segs[0] == 0x5f00 // `to_ipv4` covers both mapped and deprecated compatible addresses. diff --git a/crates/tinytools-std/src/url_guard/mod_tests.rs b/crates/tinytools-std/src/url_guard/mod_tests.rs index 02126fe..ec2c820 100644 --- a/crates/tinytools-std/src/url_guard/mod_tests.rs +++ b/crates/tinytools-std/src/url_guard/mod_tests.rs @@ -373,10 +373,11 @@ fn classifies_6to4_embedded_destinations() { } #[test] -fn classifies_teredo_server_and_client_addresses() { +fn classifies_teredo_client_address_without_rejecting_server_address() { // The last two segments are the client's IPv4 address with every bit inverted. assert!(is_private_or_local_host("2001:0:808:808:0:0:f5ff:fffe")); - assert!(is_private_or_local_host("2001:0:a00:1:0:0:f7f7:f7f7")); + // The server is not the IPv4 destination represented by this endpoint. + assert!(!is_private_or_local_host("2001:0:a00:1:0:0:f7f7:f7f7")); assert!(!is_private_or_local_host("2001:0:808:808:0:0:fefe:fefe")); }