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
5 changes: 4 additions & 1 deletion crates/tinytools-std/src/url_guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
senamakel marked this conversation as resolved.
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
Expand Down
29 changes: 19 additions & 10 deletions crates/tinytools-std/src/url_guard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//!
Expand Down Expand Up @@ -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()
Expand All @@ -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
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
&& 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]))
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
// 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)
Comment thread
senamakel marked this conversation as resolved.
}

#[cfg(test)]
Expand Down
52 changes: 52 additions & 0 deletions crates/tinytools-std/src/url_guard/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Loading