From 8467084388ab364a5cfbc6f9df95b91b605d58cb Mon Sep 17 00:00:00 2001 From: Jesse Szwedko Date: Tue, 11 Aug 2026 14:35:57 -0700 Subject: [PATCH 1/5] fix(saluki-io): bound the TLS handshake performed when tunneling HTTPS through a proxy The TLS handshake timeout configured on HttpClient only covered direct connections; when a proxy is configured, hyper-http-proxy::ProxyConnector performs its own separate TLS handshake over the CONNECT tunnel that wasn't bounded by any timeout, so a stalled proxy could hang a request indefinitely. Switches to a fork of hyper-http-proxy that adds a set_tls_handshake_timeout API and wires it up with the same timeout used for direct connections. --- Cargo.lock | 4 +- Cargo.toml | 8 ++-- lib/saluki-io/src/net/client/http/client.rs | 50 +++++++++++++++++++++ lib/saluki-io/src/net/client/http/conn.rs | 7 +++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 14ea257eadc..0c1cf77f6c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2137,8 +2137,8 @@ dependencies = [ [[package]] name = "hyper-http-proxy" -version = "1.1.0" -source = "git+https://github.com/tobz/hyper-http-proxy.git?branch=main#c7c5aad8b0cba98dd3ca404b45031473ef2d03b6" +version = "1.1.1" +source = "git+https://github.com/jszwedko/hyper-http-proxy.git?branch=jszwedko%2Ftls-handshake-timeout#f4ed3a8ea3424e7c47d7d9e09f244eda83c25693" dependencies = [ "bytes", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index cec41161088..6f761f07039 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -259,9 +259,11 @@ antithesis_sdk = { git = "https://github.com/antithesishq/antithesis-sdk-rust", mime = "0.3" [patch.crates-io] -# Forked version of `hyper-http-proxy` that removes an unused dependency on `rustls-native-certs`, which transitively depends -# on a version of `rustls-pemfile` that is no longer maintained and triggers a hit when running `cargo deny`. -hyper-http-proxy = { git = "https://github.com/tobz/hyper-http-proxy.git", branch = "main" } +# Forked version of `hyper-http-proxy` that adds a timeout for the TLS handshake performed when +# tunneling to an HTTPS destination through a proxy. Tracked upstream at +# https://github.com/metalbear-co/hyper-http-proxy/pull/9; switch back to the upstream crate once +# that merges. +hyper-http-proxy = { git = "https://github.com/jszwedko/hyper-http-proxy.git", branch = "jszwedko/tls-handshake-timeout" } [profile.devel] inherits = "dev" diff --git a/lib/saluki-io/src/net/client/http/client.rs b/lib/saluki-io/src/net/client/http/client.rs index 46d511a072e..d545da85653 100644 --- a/lib/saluki-io/src/net/client/http/client.rs +++ b/lib/saluki-io/src/net/client/http/client.rs @@ -376,10 +376,12 @@ impl HttpClientBuilder { None => self.tls_builder.build()?, }; let connector = self.connector_builder.build(tls_config)?; + let tls_handshake_timeout = connector.tls_handshake_timeout(); // TODO(fips): Look into updating `hyper-http-proxy` to use the provided connector for establishing the // connection to the proxy itself, even when the proxy is at an HTTPS URL, to ensure our desired TLS stack is // being used. let mut proxy_connector = hyper_http_proxy::ProxyConnector::new(connector)?; + proxy_connector.set_tls_handshake_timeout(Some(tls_handshake_timeout)); if let Some(proxies) = &self.proxies { for proxy in proxies { proxy_connector.add_proxy(proxy.to_owned()); @@ -515,6 +517,54 @@ mod tests { server_task.abort(); } + #[tokio::test] + async fn tls_handshake_timeout_fires_against_a_stalled_proxy_tunnel() { + initialize_crypto_provider(); + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind listener"); + let proxy_addr = listener.local_addr().expect("should have local address"); + let proxy_task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("proxy should accept a connection"); + let mut request = [0; 4096]; + let bytes_read = stream + .read(&mut request) + .await + .expect("proxy should read the CONNECT request"); + assert!(bytes_read > 0, "proxy should receive a CONNECT request"); + stream + .write_all(b"HTTP/1.1 200 OK\r\n\r\n") + .await + .expect("proxy should write the CONNECT response"); + // Complete the CONNECT tunnel but never speak TLS, so the client's handshake deadline is + // the only thing that can end the connection attempt. + tokio::time::sleep(Duration::from_secs(30)).await; + drop(stream); + }); + + let proxy_uri: Uri = format!("http://{proxy_addr}").parse().expect("proxy URI should parse"); + let mut client = HttpClient::builder() + .with_proxies(vec![Proxy::new(hyper_http_proxy::Intercept::All, proxy_uri)]) + .with_tls_config(|builder| builder.with_root_cert_store(RootCertStore::empty())) + .with_tls_handshake_timeout(Duration::from_millis(200)) + .with_http_protocol(HttpProtocol::Http1) + .build() + .expect("client should build"); + let request = Request::get("https://example.invalid/") + .body(Empty::::new()) + .expect("request should build"); + + let error = timeout(Duration::from_secs(5), client.send(request)) + .await + .expect("request should not hit the outer test timeout") + .expect_err("handshake should time out before completing"); + assert!( + format!("{error:#}").contains("TLS handshake timed out"), + "expected a TLS handshake timeout, got: {error:#}" + ); + + proxy_task.abort(); + } + fn mutual_tls_configs() -> (ServerConfig, ClientConfig) { let server_cert = SelfSignedCert::localhost(); let client_cert = SelfSignedCert::new(["saluki-client"]); diff --git a/lib/saluki-io/src/net/client/http/conn.rs b/lib/saluki-io/src/net/client/http/conn.rs index 8db861cba4c..181ff668344 100644 --- a/lib/saluki-io/src/net/client/http/conn.rs +++ b/lib/saluki-io/src/net/client/http/conn.rs @@ -375,6 +375,13 @@ pub struct HttpsCapableConnector { conn_age_limit: Option, } +impl HttpsCapableConnector { + /// Returns the timeout applied to the TLS handshake for HTTPS connections. + pub(crate) fn tls_handshake_timeout(&self) -> Duration { + self.tls_handshake_timeout + } +} + impl Service for HttpsCapableConnector { type Response = HttpsCapableConnection; type Error = BoxError; From 71cf1b571b9e7efe63eb741c46de695167d222f6 Mon Sep 17 00:00:00 2001 From: Jesse Szwedko Date: Tue, 11 Aug 2026 15:42:52 -0700 Subject: [PATCH 2/5] fix(saluki-io): preserve disabled TLS handshake timeout for proxy tunnels A zero tls_handshake_timeout means "disabled" for direct connections, but it was passed through to the proxy connector as Some(Duration::ZERO), which hyper-http-proxy treats as an active zero-length deadline, failing every tunneled HTTPS connection through a proxy immediately. Map zero to None before handing it to the proxy connector, and add a test. --- lib/saluki-io/src/net/client/http/client.rs | 52 ++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/saluki-io/src/net/client/http/client.rs b/lib/saluki-io/src/net/client/http/client.rs index d545da85653..b842c9fd4e9 100644 --- a/lib/saluki-io/src/net/client/http/client.rs +++ b/lib/saluki-io/src/net/client/http/client.rs @@ -381,7 +381,10 @@ impl HttpClientBuilder { // connection to the proxy itself, even when the proxy is at an HTTPS URL, to ensure our desired TLS stack is // being used. let mut proxy_connector = hyper_http_proxy::ProxyConnector::new(connector)?; - proxy_connector.set_tls_handshake_timeout(Some(tls_handshake_timeout)); + // A zero timeout means the handshake deadline is disabled, matching `HttpsCapableConnector`'s own + // handling of `Duration::ZERO` for direct connections. + let proxy_tls_handshake_timeout = (!tls_handshake_timeout.is_zero()).then_some(tls_handshake_timeout); + proxy_connector.set_tls_handshake_timeout(proxy_tls_handshake_timeout); if let Some(proxies) = &self.proxies { for proxy in proxies { proxy_connector.add_proxy(proxy.to_owned()); @@ -565,6 +568,53 @@ mod tests { proxy_task.abort(); } + #[tokio::test] + async fn zero_tls_handshake_timeout_disables_the_proxy_tunnel_deadline() { + initialize_crypto_provider(); + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind listener"); + let proxy_addr = listener.local_addr().expect("should have local address"); + let proxy_task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("proxy should accept a connection"); + let mut request = [0; 4096]; + let bytes_read = stream + .read(&mut request) + .await + .expect("proxy should read the CONNECT request"); + assert!(bytes_read > 0, "proxy should receive a CONNECT request"); + stream + .write_all(b"HTTP/1.1 200 OK\r\n\r\n") + .await + .expect("proxy should write the CONNECT response"); + // Complete the CONNECT tunnel but never speak TLS, so the request only fails if some + // deadline (mistakenly) bounds the handshake. + tokio::time::sleep(Duration::from_secs(30)).await; + drop(stream); + }); + + let proxy_uri: Uri = format!("http://{proxy_addr}").parse().expect("proxy URI should parse"); + let mut client = HttpClient::builder() + .with_proxies(vec![Proxy::new(hyper_http_proxy::Intercept::All, proxy_uri)]) + .with_tls_config(|builder| builder.with_root_cert_store(RootCertStore::empty())) + .with_tls_handshake_timeout(Duration::ZERO) + .with_http_protocol(HttpProtocol::Http1) + .build() + .expect("client should build"); + let request = Request::get("https://example.invalid/") + .body(Empty::::new()) + .expect("request should build"); + + // A zero handshake timeout means "disabled", so the request should still be pending well past + // the point where a mistakenly active zero-length deadline would have already failed it. + let result = timeout(Duration::from_millis(300), client.send(request)).await; + assert!( + result.is_err(), + "a disabled timeout should not fail the proxy tunnel's TLS handshake, got: {result:?}" + ); + + proxy_task.abort(); + } + fn mutual_tls_configs() -> (ServerConfig, ClientConfig) { let server_cert = SelfSignedCert::localhost(); let client_cert = SelfSignedCert::new(["saluki-client"]); From f7fd65cd258d395e4a0c6e51891cfca517c1f8e8 Mon Sep 17 00:00:00 2001 From: Jesse Szwedko Date: Wed, 12 Aug 2026 08:51:29 -0700 Subject: [PATCH 3/5] chore(saluki-io): pin hyper-http-proxy fork to latest review fixes Points Cargo.lock at the fork commit that addresses upstream review feedback on the TLS handshake timeout PR (metalbear-co/hyper-http-proxy#9). --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 0c1cf77f6c7..7010d66c525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "hyper-http-proxy" version = "1.1.1" -source = "git+https://github.com/jszwedko/hyper-http-proxy.git?branch=jszwedko%2Ftls-handshake-timeout#f4ed3a8ea3424e7c47d7d9e09f244eda83c25693" +source = "git+https://github.com/jszwedko/hyper-http-proxy.git?branch=jszwedko%2Ftls-handshake-timeout#f273b440d64841836c771a36ce2b2efd8a3408e0" dependencies = [ "bytes", "futures-util", From 74747667cb393f272ba390089df82849fe2dd7fc Mon Sep 17 00:00:00 2001 From: Jesse Szwedko Date: Wed, 12 Aug 2026 11:33:33 -0700 Subject: [PATCH 4/5] chore(saluki-io): switch hyper-http-proxy back to upstream crate metalbear-co/hyper-http-proxy#9 merged and released as 1.2.0, so the fork patch is no longer needed. --- Cargo.lock | 5 +++-- Cargo.toml | 9 +-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7010d66c525..0b33444f2b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2137,8 +2137,9 @@ dependencies = [ [[package]] name = "hyper-http-proxy" -version = "1.1.1" -source = "git+https://github.com/jszwedko/hyper-http-proxy.git?branch=jszwedko%2Ftls-handshake-timeout#f273b440d64841836c771a36ce2b2efd8a3408e0" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" dependencies = [ "bytes", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 6f761f07039..e02c8fd7ab3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,7 +119,7 @@ http = { version = "1", default-features = false } http-body = { version = "1", default-features = false } http-body-util = { version = "0.1", default-features = false } hyper = { version = "1", default-features = false } -hyper-http-proxy = { version = "1.1", default-features = false, features = [ +hyper-http-proxy = { version = "1.2", default-features = false, features = [ "rustls-tls-native-roots", ] } hyper-rustls = { version = "0.27", default-features = false, features = [ @@ -258,13 +258,6 @@ antithesis-instrumentation = { version = "0.1" } antithesis_sdk = { git = "https://github.com/antithesishq/antithesis-sdk-rust", rev = "78c9db56771f0f6b01cb5404765c5a96852c159c", default-features = false } # 0.2.8 is pinned to rand 0.8, rev version allows us to select workspace rand mime = "0.3" -[patch.crates-io] -# Forked version of `hyper-http-proxy` that adds a timeout for the TLS handshake performed when -# tunneling to an HTTPS destination through a proxy. Tracked upstream at -# https://github.com/metalbear-co/hyper-http-proxy/pull/9; switch back to the upstream crate once -# that merges. -hyper-http-proxy = { git = "https://github.com/jszwedko/hyper-http-proxy.git", branch = "jszwedko/tls-handshake-timeout" } - [profile.devel] inherits = "dev" From 731106d5d66563737362d91372998b56cce94a14 Mon Sep 17 00:00:00 2001 From: Jesse Szwedko Date: Wed, 12 Aug 2026 11:44:09 -0700 Subject: [PATCH 5/5] chore(deps): remove unused tobz/hyper-http-proxy deny.toml allowance The git patch this allowance covered was already replaced on this branch, first by a different fork and now by the released upstream crate, so no dependency resolves to that source anymore. --- deny.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/deny.toml b/deny.toml index a3c722b18b9..01908c0f3d1 100644 --- a/deny.toml +++ b/deny.toml @@ -36,10 +36,6 @@ allow-git = [ # while the crate is consumed from GitHub directly. "https://github.com/DataDog/rustls-cng-crypto", - # Forked version of `hyper-http-proxy` that removes an unused dependency on `rustls-native-certs`, which transitively depends - # on a version of `rustls-pemfile` that is no longer maintained and triggers a hit when running `cargo deny`. - "https://github.com/tobz/hyper-http-proxy.git", - # Yet-to-be-published version of incremental Protocol Buffers encoding library. "https://github.com/tobz/piecemeal.git", ]