From 57d8e89e26f0eeba73722b35c22773b6c667ca9b Mon Sep 17 00:00:00 2001 From: Lovepreet Singh Date: Sat, 18 Jul 2026 07:42:43 +0530 Subject: [PATCH] fix(traits): match RFC 9637 documentation /20 in is_public_ipv6 The 3fff::/20 documentation guard masked the first hextet with 0xfff0, which matches every first hextet in 0x3ff0..=0x3fff regardless of the second hextet. That covers 3ff0::/16 through 3fff::/16 rather than the intended 3fff::/20, so global-unicast space (3ff0:: through 3ffe::, and 3fff:: past the /20 boundary) is classified as non-routable. The avatar-URL and encrypted-media SSRF validators (reject_non_routable_ipv6) then wrongly reject those legitimately-routable hosts. Match the actual /20 instead: first hextet 0x3fff with the top nibble of the second hextet zero. The guard still fails closed for the real documentation block and never newly accepts a non-routable address. Signed-off-by: Lovepreet Singh --- crates/traits/src/app_components/host_safety.rs | 5 +++-- crates/traits/src/app_components/tests.rs | 2 +- crates/traits/tests/host_safety_public.rs | 12 +++++++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/traits/src/app_components/host_safety.rs b/crates/traits/src/app_components/host_safety.rs index b4cc701b..36736bbe 100644 --- a/crates/traits/src/app_components/host_safety.rs +++ b/crates/traits/src/app_components/host_safety.rs @@ -109,9 +109,10 @@ pub fn is_public_ipv6(addr: Ipv6Addr) -> bool { if first == 0x2001 && second == 0x0db8 { return false; } - // Documentation 3fff::/20 (RFC 9637). It falls inside global-unicast + // Documentation 3fff::/20 (RFC 9637): first hextet 0x3fff with the top + // nibble of the second hextet zero. It falls inside global-unicast // 2000::/3, so the terminal rule below would otherwise accept it. - if (first & 0xfff0) == 0x3ff0 { + if first == 0x3fff && (second & 0xf000) == 0 { return false; } // Only global unicast 2000::/3 is routable today; reject anything else not diff --git a/crates/traits/src/app_components/tests.rs b/crates/traits/src/app_components/tests.rs index ae46e1e0..92d12746 100644 --- a/crates/traits/src/app_components/tests.rs +++ b/crates/traits/src/app_components/tests.rs @@ -350,7 +350,7 @@ fn group_avatar_url_rejects_documentation_ipv6_ranges() { "https://[2001:db8::1]/a.png", "https://[2001:db8:abcd:12::1]/a.png", "https://[3fff::1]/a.png", - "https://[3fff:ffff::1]/a.png", + "https://[3fff:0fff::1]/a.png", ] { assert!( validate_and_normalize_group_avatar_url(raw).is_err(), diff --git a/crates/traits/tests/host_safety_public.rs b/crates/traits/tests/host_safety_public.rs index a04b2e38..7e13a920 100644 --- a/crates/traits/tests/host_safety_public.rs +++ b/crates/traits/tests/host_safety_public.rs @@ -80,6 +80,15 @@ fn shared_host_safety_classifies_canonical_ipv6_ranges() { assert!(is_public_ipv6("::ffff:93.184.216.34".parse().unwrap())); assert!(is_public_ip(IpAddr::V6("2606:4700::1".parse().unwrap()))); + // Only the actual RFC 9637 block 3fff:0000::/20 is documentation space. + // Neighbouring global-unicast hextets (3ff0:: to 3ffe::) and 3fff:: past the + // /20 boundary (second hextet >= 0x1000) are ordinary routable space. + for raw in ["3ff0::1", "3ffe::1", "3fff:1000::1", "3fff:ffff::1"] { + let addr: Ipv6Addr = raw.parse().expect("test IPv6 literal parses"); + assert!(is_public_ipv6(addr), "{raw} should be accepted"); + assert!(is_public_ip(IpAddr::V6(addr)), "{raw} should be accepted"); + } + for raw in [ "::1", // loopback "::", // unspecified @@ -91,7 +100,8 @@ fn shared_host_safety_classifies_canonical_ipv6_ranges() { "2002::1", // 6to4 transition prefix "2001::1", // Teredo 2001:0000::/32 "2001:db8::1", // documentation - "3fff::1", // documentation 3fff::/20 + "3fff::1", // documentation 3fff::/20 (lower edge) + "3fff:0fff::1", // documentation 3fff::/20 (upper edge) "4000::1", // outside global-unicast 2000::/3 ] { let addr: Ipv6Addr = raw.parse().expect("test IPv6 literal parses");