From 32725b0d280df43c6470a4706d52f6c73a236103 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Fri, 7 Aug 2026 09:17:49 -0400 Subject: [PATCH] fix: enforce ECH requirements for HTTP/3 --- docs/cli-reference.md | 3 ++- docs/ech.md | 4 +++- src/http/metadata.rs | 26 +++++++++++++++++++++++++- src/http/mod.rs | 2 +- src/http/transport/client.rs | 14 +++++++++----- src/http/transport/tests.rs | 12 +++++++++++- tests/http.rs | 20 ++++++++++++++++++++ 7 files changed, 71 insertions(+), 10 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a91b10e4..b5e3275f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -546,7 +546,8 @@ Encrypted Client Hello mode. Values: `auto`, `on`, `off`. Default: `off`. Falls back to GREASE ECH when no real config is found. If the server rejects the offer, the connection proceeds gracefully. - **`on`** — Require ECH. `fetch` reports an error if the server does not - advertise ECH in DNS or rejects the offer. + advertise ECH in DNS or rejects the offer. This mode cannot be combined with + explicit HTTP/3. Automatic protocol selection uses TCP. - **`off`** — Never use ECH. ECH requires TLS 1.3 and is incompatible with `--min-tls 1.2`. diff --git a/docs/ech.md b/docs/ech.md index 0908e6f1..5cdf8c26 100644 --- a/docs/ech.md +++ b/docs/ech.md @@ -25,7 +25,9 @@ fetch --ech off https://example.com recommended mode for general use. - **`on`** — Require ECH. Errors if the server does not advertise ECH in DNS, - and fails if the server rejects the offer. + and fails if the server rejects the offer. This mode cannot be used with + explicit HTTP/3 because fetch cannot verify ECH acceptance on QUIC + connections. Automatic protocol selection uses TCP when this mode is active. - **`off`** — Never use ECH (the default). diff --git a/src/http/metadata.rs b/src/http/metadata.rs index 547f05ff..c63c08af 100644 --- a/src/http/metadata.rs +++ b/src/http/metadata.rs @@ -227,10 +227,19 @@ pub(super) fn validate_http_version_options( } } -pub(crate) fn validate_ech_for_url(cli: &Cli, url: &Url) -> Result<(), FetchError> { +pub(crate) fn validate_ech_for_url( + cli: &Cli, + url: &Url, + version: Option, +) -> Result<(), FetchError> { if url.scheme() != "https" && matches!(cli.ech.as_deref(), Some("auto" | "on")) { return Err("--ech requires an https:// URL".into()); } + if matches!(cli.ech.as_deref(), Some("on")) && matches!(version, Some(HttpVersion::Http3)) { + return Err( + "--ech on cannot be used with HTTP/3 because ECH acceptance cannot be verified".into(), + ); + } Ok(()) } @@ -801,6 +810,21 @@ mod tests { assert_eq!(err.to_string(), "http3: unsupported protocol scheme: http"); } + #[test] + fn ech_on_rejects_explicit_http3() { + let cli = + Cli::try_parse_from(["fetch", "--ech", "on", "--http", "3", "https://example.com"]) + .unwrap(); + let url = Url::parse("https://example.com/").unwrap(); + + let err = validate_ech_for_url(&cli, &url, Some(HttpVersion::Http3)).unwrap_err(); + + assert_eq!( + err.to_string(), + "--ech on cannot be used with HTTP/3 because ECH acceptance cannot be verified" + ); + } + #[test] fn http3_rejects_unix_socket_like_go_app() { let url = Url::parse("https://example.com/").unwrap(); diff --git a/src/http/mod.rs b/src/http/mod.rs index 458370eb..1276f65a 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -106,7 +106,7 @@ async fn execute_inner(cli: &Cli) -> Result { apply_query(&mut url, &cli.query); client::validate_proxy_for_http_version(cli.proxy.as_deref(), http_version)?; validate_http_version_options(http_version, &url, cli.grpc, cli.unix.as_deref())?; - validate_ech_for_url(cli, &url)?; + validate_ech_for_url(cli, &url, http_version)?; let grpc_schema = if cli.grpc { proto::load_local_schema(cli)? } else { diff --git a/src/http/transport/client.rs b/src/http/transport/client.rs index a46e4bd8..184301ae 100644 --- a/src/http/transport/client.rs +++ b/src/http/transport/client.rs @@ -193,11 +193,7 @@ impl Client { // one on the heap so nested clients such as DoH do not exhaust Windows' // default thread stack while polling a request. let response = match version { - None if (self.config.auto_http3.is_some() - || self.config.auto_http3_discovery - || self.config.http3_cache.is_some()) - && url.scheme() == "https" => - { + None if should_send_auto_http3(&self.config, &url) => { Box::pin(self.send_auto_http3(method, url.clone(), headers, body, body_deadline)) .await } @@ -407,6 +403,14 @@ impl Client { } } +pub(super) fn should_send_auto_http3(config: &ClientConfig, url: &Url) -> bool { + !config.ech_hard_fail + && (config.auto_http3.is_some() + || config.auto_http3_discovery + || config.http3_cache.is_some()) + && url.scheme() == "https" +} + impl ClientBuilder { pub(crate) fn build(self) -> Result { let config = Arc::new(self.config); diff --git a/src/http/transport/tests.rs b/src/http/transport/tests.rs index 95210b23..5cee124c 100644 --- a/src/http/transport/tests.rs +++ b/src/http/transport/tests.rs @@ -1,5 +1,5 @@ use super::body::H3UploadTask; -use super::client::replace_headers; +use super::client::{replace_headers, should_send_auto_http3}; use super::h3::{ auto_http3_hint_addrs, http3_endpoint_local_addr, race_primary_fallback, spawn_auto_http3_origin_addrs, take_finished_auto_http3_origin_addrs, @@ -76,6 +76,16 @@ fn http3_client_endpoint_defaults_to_dual_stack_bind() { assert_eq!(explicit.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST)); } +#[test] +fn ech_hard_fail_forces_tcp_despite_auto_http3_discovery() { + let url = Url::parse("https://example.com/").unwrap(); + let mut builder = Client::builder().auto_http3_discovery(); + assert!(should_send_auto_http3(&builder.config, &url)); + + builder = builder.ech_hard_fail(true); + assert!(!should_send_auto_http3(&builder.config, &url)); +} + fn https_record(priority: u16, target: &str, alpn: &[&str], port: Option) -> SvcbRecord { SvcbRecord { priority, diff --git a/tests/http.rs b/tests/http.rs index 45635c8b..11185479 100644 --- a/tests/http.rs +++ b/tests/http.rs @@ -2257,6 +2257,26 @@ fn ech_rejected_for_plain_http() { ); } +#[test] +fn ech_on_rejects_explicit_http3() { + let res = run_fetch(&[ + "--ech", + "on", + "--http", + "3", + "https://ech-http3-reject.invalid", + ]); + + assert_exit(&res, 1); + assert!( + res.stderr.contains( + "--ech on cannot be used with HTTP/3 because ECH acceptance cannot be verified" + ), + "expected ECH HTTP/3 error, got:\n{}", + res.stderr + ); +} + #[test] fn ech_allowed_for_https() { // --ech auto on https:// with a self-signed server should proceed past