feat(odoh): reject relay+target sharing an eTLD+1
Plain host-string equality caught the copy-paste-same-URL footgun but let `r.cloudflare.com` + `odoh.cloudflare.com` through — two subdomains of the same operator collapse ODoH to ordinary DoH. Add a second layer: compare registrable domains via the PSL (`psl` crate) after the exact- host check. Fails open on IP literals and unparseable hosts; the exact- host check still runs in those cases.
This commit is contained in:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -1562,6 +1562,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"log",
|
"log",
|
||||||
"odoh-rs",
|
"odoh-rs",
|
||||||
|
"psl",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
@@ -1802,6 +1803,21 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "qrcode"
|
name = "qrcode"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ tokio-rustls = "0.26"
|
|||||||
arc-swap = "1"
|
arc-swap = "1"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
odoh-rs = "1"
|
odoh-rs = "1"
|
||||||
|
psl = "2"
|
||||||
# rand_core 0.9 matches the version odoh-rs (via hpke 0.13) depends on, so we
|
# 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.
|
# share one RngCore trait and OsRng impl across the dep tree.
|
||||||
rand_core = { version = "0.9", features = ["os_rng"] }
|
rand_core = { version = "0.9", features = ["os_rng"] }
|
||||||
|
|||||||
@@ -263,25 +263,29 @@ impl UpstreamConfig {
|
|||||||
if relay_url.scheme() != "https" || target_url.scheme() != "https" {
|
if relay_url.scheme() != "https" || target_url.scheme() != "https" {
|
||||||
return Err("upstream.relay and upstream.target must both use https://".into());
|
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
|
let relay_host = relay_url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or("upstream.relay has no host")?
|
.ok_or("upstream.relay must include a host")?
|
||||||
.to_string();
|
.to_string();
|
||||||
let target_host = target_url
|
let target_host = target_url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or("upstream.target has no host")?
|
.ok_or("upstream.target must include a host")?
|
||||||
.to_string();
|
.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() {
|
let target_path = if target_url.path().is_empty() {
|
||||||
"/".to_string()
|
"/".to_string()
|
||||||
} else {
|
} 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<String> {
|
||||||
|
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<Vec<String>, D::Error>
|
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
@@ -830,6 +848,59 @@ target = "https://odoh.example.com/dns-query"
|
|||||||
assert!(err.contains("same host"), "got: {err}");
|
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]
|
#[test]
|
||||||
fn odoh_rejects_non_https() {
|
fn odoh_rejects_non_https() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
|
|||||||
Reference in New Issue
Block a user