diff --git a/crates/tinytools-std/src/url_guard/README.md b/crates/tinytools-std/src/url_guard/README.md index 29f68bb..81c41b8 100644 --- a/crates/tinytools-std/src/url_guard/README.md +++ b/crates/tinytools-std/src/url_guard/README.md @@ -23,7 +23,10 @@ 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 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 ae996f8..c36decc 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,10 +472,19 @@ 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 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 - || 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)] diff --git a/crates/tinytools-std/src/url_guard/mod_tests.rs b/crates/tinytools-std/src/url_guard/mod_tests.rs index 68ecea5..ec2c820 100644 --- a/crates/tinytools-std/src/url_guard/mod_tests.rs +++ b/crates/tinytools-std/src/url_guard/mod_tests.rs @@ -355,6 +355,58 @@ 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_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")); + // 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")); +} + +#[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"));