diff --git a/README.md b/README.md index f152eb6..fbc17df 100644 --- a/README.md +++ b/README.md @@ -904,3 +904,36 @@ await fetch('https://api.internal.test/health', { }, }); ``` + +Use `dns.doh` to resolve request hostnames through DNS-over-HTTPS: + +```ts +await fetch('https://example.com', { + dns: { + doh: 'https://cloudflare-dns.com/dns-query', + }, +}); +``` + +Use `dns.dot` to resolve request hostnames through DNS-over-TLS: + +```ts +await fetch('https://example.com', { + dns: { + dot: 'tls://one.one.one.one', + }, +}); +``` + +`dns.doh` and `dns.dot` are mutually exclusive. When either encrypted DNS mode is set, +`dns.servers` are used only to resolve the encrypted DNS endpoint hostname. If `dns.servers` +are omitted, the endpoint is resolved with the system DNS settings. + +```ts +await fetch('https://example.com', { + dns: { + doh: 'https://cloudflare-dns.com/dns-query', + servers: ['9.9.9.9'], + }, +}); +``` diff --git a/rust/Cargo.lock b/rust/Cargo.lock index df0382e..6df1744 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -570,6 +570,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.13.2" @@ -604,22 +623,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", + "bytes", "cfg-if", "data-encoding", "enum-as-inner", "futures-channel", "futures-io", "futures-util", + "h2", + "http", "idna", "ipnet", "once_cell", "rand", "ring", + "rustls", "thiserror 2.0.17", "tinyvec", "tokio", + "tokio-rustls", "tracing", "url", + "webpki-roots 0.26.11", ] [[package]] @@ -637,10 +662,13 @@ dependencies = [ "parking_lot", "rand", "resolv-conf", + "rustls", "smallvec", "thiserror 2.0.17", "tokio", + "tokio-rustls", "tracing", + "webpki-roots 0.26.11", ] [[package]] @@ -1046,6 +1074,7 @@ dependencies = [ "strum", "thiserror 1.0.69", "tokio", + "url", "webpki-root-certs", "wreq", "wreq-util", @@ -1298,11 +1327,40 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] [[package]] name = "rustversion" @@ -1466,6 +1524,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.106" @@ -1658,6 +1722,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-socks" version = "0.5.2" @@ -2005,6 +2079,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "widestring" version = "1.2.1" @@ -2374,6 +2466,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 51cf7a9..8e36f5a 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -27,7 +27,8 @@ thiserror = "1.0" # Async runtime (if needed) tokio = { version = "1.0", features = ["full"] } webpki-root-certs = "1.0.3" -hickory-resolver = { version = "0.25.2", features = ["system-config"] } +hickory-resolver = { version = "0.25.2", features = ["system-config", "https-ring", "webpki-roots"] } +url = "2.5" [profile.release] opt-level = 3 diff --git a/rust/src/napi/convert.rs b/rust/src/napi/convert.rs index 7c345f5..6e6cf7b 100644 --- a/rust/src/napi/convert.rs +++ b/rust/src/napi/convert.rs @@ -641,6 +641,16 @@ fn js_object_to_dns_options( .map(|value| js_value_to_string_array(cx, value)) .transpose()? .unwrap_or_default(); + let doh = dns_obj + .get_opt(cx, "doh")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + .map(|value| value.value(cx)); + let dot = dns_obj + .get_opt(cx, "dot")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + .map(|value| value.value(cx)); let hosts = dns_obj .get_opt(cx, "hosts")? @@ -671,11 +681,16 @@ fn js_object_to_dns_options( .transpose()? .unwrap_or_default(); - if servers.is_empty() && hosts.is_empty() { + if doh.is_none() && dot.is_none() && servers.is_empty() && hosts.is_empty() { return Ok(None); } - Ok(Some(DnsOptions { servers, hosts })) + Ok(Some(DnsOptions { + doh, + dot, + servers, + hosts, + })) } pub(crate) fn response_to_js_object<'a, C: Context<'a>>( diff --git a/rust/src/transport/dns.rs b/rust/src/transport/dns.rs index 145df8a..912ebb1 100644 --- a/rust/src/transport/dns.rs +++ b/rust/src/transport/dns.rs @@ -1,5 +1,5 @@ use crate::transport::types::DnsOptions; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use hickory_resolver::{ config::{LookupIpStrategy, NameServerConfig, NameServerConfigGroup, ResolverConfig}, lookup_ip::LookupIpIntoIter, @@ -7,7 +7,9 @@ use hickory_resolver::{ proto::xfer::Protocol, TokioResolver, }; +use std::collections::HashSet; use std::net::{IpAddr, SocketAddr}; +use url::Url; use wreq::dns::{Addrs, Name, Resolve, Resolving}; fn parse_ip_or_socket_addr(value: &str, default_port: u16) -> Result { @@ -46,23 +48,187 @@ fn build_name_server_group(servers: &[String]) -> Result Ok(group) } +fn build_resolver(group: NameServerConfigGroup) -> TokioResolver { + let mut builder = TokioResolver::builder_with_config( + ResolverConfig::from_parts(None, Vec::new(), group), + TokioConnectionProvider::default(), + ); + + builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; + + builder.build() +} + +fn host_override_ips( + hostname: &str, + hosts: &[(String, Vec)], +) -> Result>> { + let Some((_, addresses)) = hosts + .iter() + .find(|(host, _)| host.eq_ignore_ascii_case(hostname)) + else { + return Ok(None); + }; + + let ips = parse_override_addresses(addresses)? + .into_iter() + .map(|address| address.ip()) + .collect(); + + Ok(Some(ips)) +} + +fn doh_endpoint_path(url: &Url) -> String { + let mut endpoint = match url.path() { + "" | "/" => "/dns-query".to_string(), + path => path.to_string(), + }; + + if let Some(query) = url.query() { + endpoint.push('?'); + endpoint.push_str(query); + } + + endpoint +} + +async fn resolve_encrypted_dns_host( + hostname: &str, + port: u16, + servers: &[String], + hosts: &[(String, Vec)], + protocol_name: &str, +) -> Result> { + if let Ok(ip) = hostname.parse::() { + return Ok(vec![ip]); + } + + if let Some(ips) = host_override_ips(hostname, hosts)? { + return Ok(ips); + } + + let ips = if servers.is_empty() { + tokio::net::lookup_host((hostname, port)) + .await + .with_context(|| { + format!("Failed to resolve {protocol_name} endpoint with system DNS: {hostname}") + })? + .map(|address| address.ip()) + .collect::>() + } else { + build_resolver(build_name_server_group(servers)?) + .lookup_ip(hostname) + .await + .with_context(|| { + format!("Failed to resolve {protocol_name} endpoint with dns.servers: {hostname}") + })? + .into_iter() + .collect::>() + }; + + let mut seen = HashSet::new(); + let mut deduped = ips + .into_iter() + .filter(|ip| seen.insert(*ip)) + .collect::>(); + + if deduped.is_empty() { + bail!("{protocol_name} endpoint did not resolve to any IP addresses: {hostname}"); + } + + deduped.sort_by_key(|ip| ip.is_ipv6()); + + Ok(deduped) +} + +async fn build_doh_name_server_group( + doh: &str, + servers: &[String], + hosts: &[(String, Vec)], +) -> Result { + let url = Url::parse(doh).with_context(|| format!("Invalid dns.doh URL: {doh}"))?; + + if url.scheme() != "https" { + bail!("dns.doh must be an HTTPS URL: {doh}"); + } + + if !url.username().is_empty() || url.password().is_some() { + bail!("dns.doh must not include credentials: {doh}"); + } + + if url.fragment().is_some() { + bail!("dns.doh must not include a fragment: {doh}"); + } + + let hostname = url + .host_str() + .with_context(|| format!("dns.doh URL must include a hostname: {doh}"))?; + let port = url.port_or_known_default().unwrap_or(443); + let endpoint = doh_endpoint_path(&url); + let ips = resolve_encrypted_dns_host(hostname, port, servers, hosts, "DoH").await?; + let mut group = NameServerConfigGroup::new(); + + for ip in ips { + let mut config = NameServerConfig::new(SocketAddr::new(ip, port), Protocol::Https); + config.tls_dns_name = Some(hostname.to_string()); + config.http_endpoint = Some(endpoint.clone()); + config.trust_negative_responses = true; + group.push(config); + } + + Ok(group) +} + +async fn build_dot_name_server_group( + dot: &str, + servers: &[String], + hosts: &[(String, Vec)], +) -> Result { + let url = Url::parse(dot).with_context(|| format!("Invalid dns.dot URL: {dot}"))?; + + if url.scheme() != "tls" { + bail!("dns.dot must be a tls:// URL: {dot}"); + } + + if !url.username().is_empty() || url.password().is_some() { + bail!("dns.dot must not include credentials: {dot}"); + } + + if url.fragment().is_some() { + bail!("dns.dot must not include a fragment: {dot}"); + } + + if url.query().is_some() || !matches!(url.path(), "" | "/") { + bail!("dns.dot must not include a path or query: {dot}"); + } + + let hostname = url + .host_str() + .with_context(|| format!("dns.dot URL must include a hostname: {dot}"))?; + let port = url.port().unwrap_or(853); + let ips = resolve_encrypted_dns_host(hostname, port, servers, hosts, "DoT").await?; + let mut group = NameServerConfigGroup::new(); + + for ip in ips { + let mut config = NameServerConfig::new(SocketAddr::new(ip, port), Protocol::Tls); + config.tls_dns_name = Some(hostname.to_string()); + config.trust_negative_responses = true; + group.push(config); + } + + Ok(group) +} + #[derive(Clone, Debug)] struct CustomDnsResolver { resolver: TokioResolver, } impl CustomDnsResolver { - fn new(servers: &[String]) -> Result { - let mut builder = TokioResolver::builder_with_config( - ResolverConfig::from_parts(None, Vec::new(), build_name_server_group(servers)?), - TokioConnectionProvider::default(), - ); - - builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; - - Ok(Self { - resolver: builder.build(), - }) + fn new(group: NameServerConfigGroup) -> Self { + Self { + resolver: build_resolver(group), + } } } @@ -93,7 +259,7 @@ impl Iterator for SocketAddrs { } } -pub fn configure_client_builder( +pub async fn configure_client_builder( mut client_builder: wreq::ClientBuilder, dns: Option, ) -> Result { @@ -103,8 +269,20 @@ pub fn configure_client_builder( return Ok(client_builder); }; - if !dns.servers.is_empty() { - client_builder = client_builder.dns_resolver(CustomDnsResolver::new(&dns.servers)?); + if dns.doh.is_some() && dns.dot.is_some() { + bail!("dns.doh and dns.dot cannot both be set"); + } + + if let Some(doh) = &dns.doh { + let group = build_doh_name_server_group(doh, &dns.servers, &dns.hosts).await?; + client_builder = client_builder.dns_resolver(CustomDnsResolver::new(group)); + } else if let Some(dot) = &dns.dot { + let group = build_dot_name_server_group(dot, &dns.servers, &dns.hosts).await?; + client_builder = client_builder.dns_resolver(CustomDnsResolver::new(group)); + } else if !dns.servers.is_empty() { + client_builder = client_builder.dns_resolver(CustomDnsResolver::new( + build_name_server_group(&dns.servers)?, + )); } for (hostname, addresses) in dns.hosts { diff --git a/rust/src/transport/request.rs b/rust/src/transport/request.rs index 955f6fd..545c91d 100644 --- a/rust/src/transport/request.rs +++ b/rust/src/transport/request.rs @@ -45,7 +45,7 @@ pub async fn make_request(options: RequestOptions) -> Result { client_builder = client_builder.proxy(proxy); } - client_builder = configure_dns(client_builder, dns)?; + client_builder = configure_dns(client_builder, dns).await?; client_builder = configure_client_builder( client_builder, tls_identity, diff --git a/rust/src/transport/types.rs b/rust/src/transport/types.rs index 15766d7..9678a3d 100644 --- a/rust/src/transport/types.rs +++ b/rust/src/transport/types.rs @@ -47,6 +47,8 @@ pub struct ResponseTlsInfo { #[derive(Debug, Clone)] pub struct DnsOptions { + pub doh: Option, + pub dot: Option, pub servers: Vec, pub hosts: Vec<(String, Vec)>, } diff --git a/rust/src/transport/websocket.rs b/rust/src/transport/websocket.rs index c4a36eb..ca14634 100644 --- a/rust/src/transport/websocket.rs +++ b/rust/src/transport/websocket.rs @@ -173,7 +173,7 @@ async fn make_websocket(options: WebSocketConnectOptions) -> Result server.trim()) @@ -44,11 +87,13 @@ export function normalizeDnsOptions(dns?: DnsOptions): NativeDnsOptions | undefi ) : undefined; - if ((!servers || servers.length === 0) && !hosts) { + if (!doh && !dot && (!servers || servers.length === 0) && !hosts) { return undefined; } return { + doh, + dot, servers, hosts, }; diff --git a/src/test/transport-features.spec.ts b/src/test/transport-features.spec.ts index deec9eb..0d57222 100644 --- a/src/test/transport-features.spec.ts +++ b/src/test/transport-features.spec.ts @@ -141,6 +141,43 @@ describe('transport features', () => { assert.strictEqual(body.headers.host, `example.test:${target.port}`); }); + test('should reject non-HTTPS DoH endpoints', async () => { + await assert.rejects( + () => + fetch(`${getBaseUrl()}/headers/raw`, { + dns: { + doh: 'http://cloudflare-dns.com/dns-query', + }, + }), + /dns\.doh must be an HTTPS URL/ + ); + }); + + test('should reject non-tls DoT endpoints', async () => { + await assert.rejects( + () => + fetch(`${getBaseUrl()}/headers/raw`, { + dns: { + dot: 'https://cloudflare-dns.com', + }, + }), + /dns\.dot must be a tls:\/\/ URL/ + ); + }); + + test('should reject conflicting encrypted DNS endpoints', async () => { + await assert.rejects( + () => + fetch(`${getBaseUrl()}/headers/raw`, { + dns: { + doh: 'https://cloudflare-dns.com/dns-query', + dot: 'tls://cloudflare-dns.com', + }, + }), + /dns\.doh and dns\.dot cannot both be set/ + ); + }); + test('should honor env/system proxy by default and allow opting out with proxy=false', async () => { const previous = { HTTP_PROXY: process.env.HTTP_PROXY, diff --git a/src/types/native.ts b/src/types/native.ts index c046e7a..0b46ccb 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -2,6 +2,10 @@ import type { BrowserProfile, HeaderTuple, HttpMethod, RequestTimings } from './ /** Normalized DNS overrides passed to the native layer. */ export interface NativeDnsOptions { + /** DNS-over-HTTPS endpoint used for hostname resolution. */ + doh?: string; + /** DNS-over-TLS endpoint used for hostname resolution. */ + dot?: string; /** Upstream DNS servers used for hostname resolution. */ servers?: string[]; /** Static host-to-address mappings that bypass normal DNS lookups. */ diff --git a/src/types/shared.ts b/src/types/shared.ts index 1a2cd9a..ac316d9 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -33,6 +33,10 @@ export type BodyInit = string | URLSearchParams | FormData | Buffer | ArrayBuffe /** DNS overrides applied by the native transport. */ export interface DnsOptions { + /** DNS-over-HTTPS endpoint used for hostname resolution. */ + doh?: string; + /** DNS-over-TLS endpoint used for hostname resolution. */ + dot?: string; /** Upstream DNS servers used for hostname resolution. */ servers?: string | string[]; /** Static host-to-address mappings that bypass normal DNS lookups. */