diff --git a/Cargo.lock b/Cargo.lock index b630e73..dc95f58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1562,6 +1562,7 @@ dependencies = [ "hyper-util", "log", "odoh-rs", + "psl", "qrcode", "rand_core 0.9.5", "rcgen", @@ -1802,6 +1803,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl" +version = "2.1.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c0777260d32b76a8c3c197646707085d37e79d63b5872a29192c8d4f60f50b" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "qrcode" version = "0.14.1" diff --git a/Cargo.toml b/Cargo.toml index c22352b..ec3bb43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ tokio-rustls = "0.26" arc-swap = "1" ring = "0.17" odoh-rs = "1" +psl = "2" # rand_core 0.9 matches the version odoh-rs (via hpke 0.13) depends on, so we # share one RngCore trait and OsRng impl across the dep tree. rand_core = { version = "0.9", features = ["os_rng"] } diff --git a/src/config.rs b/src/config.rs index 1205e37..3a41d24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -263,25 +263,29 @@ impl UpstreamConfig { if relay_url.scheme() != "https" || target_url.scheme() != "https" { return Err("upstream.relay and upstream.target must both use https://".into()); } - if relay_url.host_str().is_none() || target_url.host_str().is_none() { - return Err("upstream.relay and upstream.target must include a host".into()); - } - if relay_url.host_str() == target_url.host_str() { - return Err(format!( - "upstream.relay and upstream.target resolve to the same host ({}); the privacy property requires distinct operators", - relay_url.host_str().unwrap_or("?") - ) - .into()); - } - let relay_host = relay_url .host_str() - .ok_or("upstream.relay has no host")? + .ok_or("upstream.relay must include a host")? .to_string(); let target_host = target_url .host_str() - .ok_or("upstream.target has no host")? + .ok_or("upstream.target must include a host")? .to_string(); + + if relay_host == target_host { + return Err(format!( + "upstream.relay and upstream.target resolve to the same host ({}); the privacy property requires distinct operators", + relay_host + ) + .into()); + } + if let Some(shared) = shared_registrable_domain(&relay_host, &target_host) { + return Err(format!( + "upstream.relay ({}) and upstream.target ({}) share the registrable domain ({}); the privacy property requires distinct operators", + relay_host, target_host, shared + ) + .into()); + } let target_path = if target_url.path().is_empty() { "/".to_string() } else { @@ -303,6 +307,20 @@ impl UpstreamConfig { } } +/// Returns the registrable domain (eTLD+1) shared by both hosts, if any. +/// Fails open on hosts the PSL can't parse (IP literals, bare TLDs). +fn shared_registrable_domain(relay_host: &str, target_host: &str) -> Option { + let relay = psl::domain(relay_host.as_bytes())?; + let target = psl::domain(target_host.as_bytes())?; + if relay.as_bytes() == target.as_bytes() { + std::str::from_utf8(relay.as_bytes()) + .ok() + .map(str::to_owned) + } else { + None + } +} + fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result, D::Error> where D: serde::Deserializer<'de>, @@ -830,6 +848,59 @@ target = "https://odoh.example.com/dns-query" assert!(err.contains("same host"), "got: {err}"); } + #[test] + fn odoh_rejects_shared_registrable_domain() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://r.cloudflare.com/relay" +target = "https://odoh.cloudflare.com/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let err = config.upstream.odoh_upstream().unwrap_err().to_string(); + assert!(err.contains("registrable domain"), "got: {err}"); + assert!(err.contains("cloudflare.com"), "got: {err}"); + } + + #[test] + fn odoh_rejects_shared_registrable_under_multi_label_suffix() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://a.foo.co.uk/relay" +target = "https://b.foo.co.uk/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let err = config.upstream.odoh_upstream().unwrap_err().to_string(); + assert!(err.contains("foo.co.uk"), "got: {err}"); + } + + #[test] + fn odoh_accepts_distinct_registrable_under_multi_label_suffix() { + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://relay.foo.co.uk/relay" +target = "https://target.bar.co.uk/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.upstream.odoh_upstream().is_ok()); + } + + #[test] + fn odoh_accepts_distinct_private_psl_suffix_subdomains() { + // *.github.io is a public suffix, so foo.github.io and bar.github.io + // are independent registrable domains — accept. + let toml = r#" +[upstream] +mode = "odoh" +relay = "https://foo.github.io/relay" +target = "https://bar.github.io/dns-query" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.upstream.odoh_upstream().is_ok()); + } + #[test] fn odoh_rejects_non_https() { let toml = r#"