From 3665deb56bd8e85f1f7fb569ba8ed0944838978c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 17:56:39 +0300 Subject: [PATCH 1/4] fix: accept loopback addresses for DoH and add IP SANs to TLS cert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DoH endpoint rejected requests with Host: 127.0.0.1/::1/localhost, and the generated TLS cert had no IP SANs — so browsers couldn't use https://127.0.0.1/dns-query even with the CA trusted. - is_doh_host now accepts 127.0.0.1, ::1, localhost (with optional port) - TLS cert includes 127.0.0.1 and ::1 IP SANs, plus bare TLD DNS SAN Closes #87 --- src/doh.rs | 33 +++++++++++++++++++++++---------- src/tls.rs | 14 ++++++++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/doh.rs b/src/doh.rs index 7325688..917e039 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -49,16 +49,25 @@ pub async fn doh_post(State(state): State, req: Request) } fn is_doh_host(host: Option<&str>, tld: &str) -> bool { - match host { - Some(h) if h == tld => true, - Some(h) => { - h.len() == 2 * tld.len() + 1 - && h.starts_with(tld) - && h.as_bytes().get(tld.len()) == Some(&b'.') - && h.ends_with(tld) - } - None => false, - } + let h = match host { + Some(h) => h, + None => return false, + }; + is_doh_name(h, tld) + || h.rsplit_once(':').is_some_and(|(base, port)| { + port.bytes().all(|b| b.is_ascii_digit()) && is_doh_name(base, tld) + }) +} + +fn is_doh_name(h: &str, tld: &str) -> bool { + h == tld + || (h.len() == 2 * tld.len() + 1 + && h.starts_with(tld) + && h.as_bytes().get(tld.len()) == Some(&b'.') + && h.ends_with(tld)) + || h == "127.0.0.1" + || h == "::1" + || h == "localhost" } async fn resolve_doh( @@ -148,6 +157,10 @@ mod tests { fn is_doh_host_matches_tld() { assert!(is_doh_host(Some("numa"), "numa")); assert!(is_doh_host(Some("numa.numa"), "numa")); + assert!(is_doh_host(Some("127.0.0.1"), "numa")); + assert!(is_doh_host(Some("127.0.0.1:443"), "numa")); + assert!(is_doh_host(Some("::1"), "numa")); + assert!(is_doh_host(Some("localhost"), "numa")); assert!(!is_doh_host(Some("foo.numa"), "numa")); assert!(!is_doh_host(None, "numa")); } diff --git a/src/tls.rs b/src/tls.rs index e9e2f59..2443f4f 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -186,6 +186,20 @@ fn generate_service_cert( } } + // Loopback IP SANs so browsers can reach DoH at https://127.0.0.1/dns-query + sans.push(SanType::IpAddress(std::net::IpAddr::V4( + std::net::Ipv4Addr::LOCALHOST, + ))); + sans.push(SanType::IpAddress(std::net::IpAddr::V6( + std::net::Ipv6Addr::LOCALHOST, + ))); + + // Bare TLD (e.g. "numa") for DoH via https://numa/dns-query + match tld.to_string().try_into() { + Ok(ia5) => sans.push(SanType::DnsName(ia5)), + Err(e) => warn!("invalid SAN {}: {}", tld, e), + } + if sans.is_empty() { return Err("no valid service names for TLS cert".into()); } From 115a55b199ff02ca09acda4b4549ddc12742f847 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 22:26:44 +0300 Subject: [PATCH 2/4] fix: bracketed IPv6, localhost SAN, split host-check helpers - is_doh_host split into strip_port + is_loopback_host + is_tld_match - strip_port handles bracketed IPv6 ([::1]:443) and rejects bare IPv6 - Add [::1] to accepted loopback hosts, add localhost DNS SAN to cert - Remove dead sans.is_empty() guard (loopback IPs always present) --- src/doh.rs | 37 +++++++++++++++++++++++++++++-------- src/tls.rs | 13 +++++-------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/doh.rs b/src/doh.rs index 917e039..672402b 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -53,21 +53,39 @@ fn is_doh_host(host: Option<&str>, tld: &str) -> bool { Some(h) => h, None => return false, }; - is_doh_name(h, tld) - || h.rsplit_once(':').is_some_and(|(base, port)| { - port.bytes().all(|b| b.is_ascii_digit()) && is_doh_name(base, tld) - }) + let base = strip_port(h).unwrap_or(h); + is_loopback_host(base) || is_tld_match(base, tld) } -fn is_doh_name(h: &str, tld: &str) -> bool { +fn strip_port(h: &str) -> Option<&str> { + if h.starts_with('[') { + // [::1]:443 → [::1] + let (base, port) = h.rsplit_once("]:")?; + port.bytes() + .all(|b| b.is_ascii_digit()) + .then(|| &h[..base.len() + 1]) + } else { + let (base, port) = h.rsplit_once(':')?; + // Bare IPv6 like "::1" has multiple colons — not a port suffix + if base.contains(':') { + return None; + } + port.bytes() + .all(|b| b.is_ascii_digit()) + .then_some(base) + } +} + +fn is_loopback_host(h: &str) -> bool { + matches!(h, "127.0.0.1" | "::1" | "[::1]" | "localhost") +} + +fn is_tld_match(h: &str, tld: &str) -> bool { h == tld || (h.len() == 2 * tld.len() + 1 && h.starts_with(tld) && h.as_bytes().get(tld.len()) == Some(&b'.') && h.ends_with(tld)) - || h == "127.0.0.1" - || h == "::1" - || h == "localhost" } async fn resolve_doh( @@ -160,7 +178,10 @@ mod tests { assert!(is_doh_host(Some("127.0.0.1"), "numa")); assert!(is_doh_host(Some("127.0.0.1:443"), "numa")); assert!(is_doh_host(Some("::1"), "numa")); + assert!(is_doh_host(Some("[::1]"), "numa")); + assert!(is_doh_host(Some("[::1]:443"), "numa")); assert!(is_doh_host(Some("localhost"), "numa")); + assert!(is_doh_host(Some("localhost:443"), "numa")); assert!(!is_doh_host(Some("foo.numa"), "numa")); assert!(!is_doh_host(None, "numa")); } diff --git a/src/tls.rs b/src/tls.rs index 2443f4f..9167904 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -194,14 +194,11 @@ fn generate_service_cert( std::net::Ipv6Addr::LOCALHOST, ))); - // Bare TLD (e.g. "numa") for DoH via https://numa/dns-query - match tld.to_string().try_into() { - Ok(ia5) => sans.push(SanType::DnsName(ia5)), - Err(e) => warn!("invalid SAN {}: {}", tld, e), - } - - if sans.is_empty() { - return Err("no valid service names for TLS cert".into()); + for name in ["localhost", tld] { + match name.to_string().try_into() { + Ok(ia5) => sans.push(SanType::DnsName(ia5)), + Err(e) => warn!("invalid SAN {}: {}", name, e), + } } params.subject_alt_names = sans; From bd505813b6f3d852e7955bc07c0b986dcb54d387 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 23:42:45 +0300 Subject: [PATCH 3/4] test: verify TLS cert SANs (wildcard, services, loopback, localhost, bare TLD) Parse the generated DER cert with x509-parser to assert the exact SAN set, catching silent try_into() failures that a params-level test would miss. --- Cargo.toml | 1 + src/tls.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index d7f6f9f..6ab0972 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ tower = { version = "0.5", features = ["util"] } http = "1" hickory-resolver = { version = "0.25", features = ["https-ring", "webpki-roots"] } hickory-proto = "0.25" +x509-parser = "0.18" [[bench]] name = "hot_path" diff --git a/src/tls.rs b/src/tls.rs index 9167904..22a00a4 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -251,4 +251,72 @@ mod tests { let err: crate::Error = "rcgen failure".into(); assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none()); } + + #[test] + fn service_cert_contains_expected_sans() { + use x509_parser::prelude::GeneralName; + + let dir = std::env::temp_dir().join(format!("numa-test-san-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (ca_der, issuer) = ensure_ca(&dir).unwrap(); + + let names = vec!["grafana".into(), "router".into()]; + let (chain, _) = generate_service_cert(&ca_der, &issuer, "numa", &names).unwrap(); + assert_eq!(chain.len(), 2, "chain should be [leaf, CA]"); + + let (_, cert) = x509_parser::parse_x509_certificate(chain[0].as_ref()).unwrap(); + let san = cert + .tbs_certificate + .subject_alternative_name() + .unwrap() + .unwrap(); + + let dns: Vec<&str> = san + .value + .general_names + .iter() + .filter_map(|gn| match gn { + GeneralName::DNSName(s) => Some(*s), + _ => None, + }) + .collect(); + + let ips: Vec = san + .value + .general_names + .iter() + .filter_map(|gn| match gn { + GeneralName::IPAddress(b) => match b.len() { + 4 => Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new( + b[0], b[1], b[2], b[3], + ))), + 16 => { + let a: [u8; 16] = (*b).try_into().unwrap(); + Some(std::net::IpAddr::V6(std::net::Ipv6Addr::from(a))) + } + _ => None, + }, + _ => None, + }) + .collect(); + + // DNS SANs + assert!(dns.contains(&"*.numa"), "missing wildcard SAN"); + assert!(dns.contains(&"grafana.numa"), "missing service SAN"); + assert!(dns.contains(&"router.numa"), "missing service SAN"); + assert!(dns.contains(&"localhost"), "missing localhost SAN"); + assert!(dns.contains(&"numa"), "missing bare TLD SAN"); + + // IP SANs + assert!( + ips.contains(&std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), + "missing 127.0.0.1 SAN" + ); + assert!( + ips.contains(&std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)), + "missing ::1 SAN" + ); + + let _ = std::fs::remove_dir_all(&dir); + } } From 305935ed9867db6e7877c81b38114a3190b9a004 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 23:59:51 +0300 Subject: [PATCH 4/4] style: rustfmt strip_port --- src/doh.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/doh.rs b/src/doh.rs index 672402b..f90b919 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -70,9 +70,7 @@ fn strip_port(h: &str) -> Option<&str> { if base.contains(':') { return None; } - port.bytes() - .all(|b| b.is_ascii_digit()) - .then_some(base) + port.bytes().all(|b| b.is_ascii_digit()).then_some(base) } }