Skip to content

Commit f76d1fb

Browse files
authored
fix(io): bound the TLS handshake performed when tunneling HTTPS through a proxy (#2309)
## Human Summary Use the upstream `hyper-http-proxy` crate, which now [supports setting a timeout on TLS handshakes](metalbear-co/hyper-http-proxy#9). This started out as a fork while the change was upstream, but that PR has since merged and been released as 1.2.0, so we no longer need it. ## AI Summary When a proxy is configured, `HttpClient`'s TLS handshake timeout only bounds direct connections — `hyper_http_proxy::ProxyConnector` performs a separate TLS handshake over the CONNECT tunnel to reach HTTPS destinations, and that handshake was previously unbounded, so a stalled or unresponsive proxy could hang a request indefinitely. This adds a `set_tls_handshake_timeout` API to `hyper-http-proxy` (contributed upstream via [metalbear-co/hyper-http-proxy#9](metalbear-co/hyper-http-proxy#9), merged and released as 1.2.0) and wires it up using the same handshake timeout already configured for direct connections. ## Test plan - [x] Added `tls_handshake_timeout_fires_against_a_stalled_proxy_tunnel`, which emulates a proxy that completes the CONNECT tunnel but never speaks TLS, and asserts the client's configured handshake timeout ends the request. Co-authored-by: jesse.szwedko <jesse.szwedko@datadoghq.com>
1 parent 0dee0a9 commit f76d1fb

5 files changed

Lines changed: 111 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ http = { version = "1", default-features = false }
119119
http-body = { version = "1", default-features = false }
120120
http-body-util = { version = "0.1", default-features = false }
121121
hyper = { version = "1", default-features = false }
122-
hyper-http-proxy = { version = "1.1", default-features = false, features = [
122+
hyper-http-proxy = { version = "1.2", default-features = false, features = [
123123
"rustls-tls-native-roots",
124124
] }
125125
hyper-rustls = { version = "0.27", default-features = false, features = [
@@ -258,11 +258,6 @@ antithesis-instrumentation = { version = "0.1" }
258258
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
259259
mime = "0.3"
260260

261-
[patch.crates-io]
262-
# Forked version of `hyper-http-proxy` that removes an unused dependency on `rustls-native-certs`, which transitively depends
263-
# on a version of `rustls-pemfile` that is no longer maintained and triggers a hit when running `cargo deny`.
264-
hyper-http-proxy = { git = "https://github.com/tobz/hyper-http-proxy.git", branch = "main" }
265-
266261
[profile.devel]
267262
inherits = "dev"
268263

deny.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,6 @@ allow-git = [
3636
# while the crate is consumed from GitHub directly.
3737
"https://github.com/DataDog/rustls-cng-crypto",
3838

39-
# Forked version of `hyper-http-proxy` that removes an unused dependency on `rustls-native-certs`, which transitively depends
40-
# on a version of `rustls-pemfile` that is no longer maintained and triggers a hit when running `cargo deny`.
41-
"https://github.com/tobz/hyper-http-proxy.git",
42-
4339
# Yet-to-be-published version of incremental Protocol Buffers encoding library.
4440
"https://github.com/tobz/piecemeal.git",
4541
]

lib/saluki-io/src/net/client/http/client.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,15 @@ impl HttpClientBuilder {
376376
None => self.tls_builder.build()?,
377377
};
378378
let connector = self.connector_builder.build(tls_config)?;
379+
let tls_handshake_timeout = connector.tls_handshake_timeout();
379380
// TODO(fips): Look into updating `hyper-http-proxy` to use the provided connector for establishing the
380381
// connection to the proxy itself, even when the proxy is at an HTTPS URL, to ensure our desired TLS stack is
381382
// being used.
382383
let mut proxy_connector = hyper_http_proxy::ProxyConnector::new(connector)?;
384+
// A zero timeout means the handshake deadline is disabled, matching `HttpsCapableConnector`'s own
385+
// handling of `Duration::ZERO` for direct connections.
386+
let proxy_tls_handshake_timeout = (!tls_handshake_timeout.is_zero()).then_some(tls_handshake_timeout);
387+
proxy_connector.set_tls_handshake_timeout(proxy_tls_handshake_timeout);
383388
if let Some(proxies) = &self.proxies {
384389
for proxy in proxies {
385390
proxy_connector.add_proxy(proxy.to_owned());
@@ -515,6 +520,101 @@ mod tests {
515520
server_task.abort();
516521
}
517522

523+
#[tokio::test]
524+
async fn tls_handshake_timeout_fires_against_a_stalled_proxy_tunnel() {
525+
initialize_crypto_provider();
526+
527+
let listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind listener");
528+
let proxy_addr = listener.local_addr().expect("should have local address");
529+
let proxy_task = tokio::spawn(async move {
530+
let (mut stream, _) = listener.accept().await.expect("proxy should accept a connection");
531+
let mut request = [0; 4096];
532+
let bytes_read = stream
533+
.read(&mut request)
534+
.await
535+
.expect("proxy should read the CONNECT request");
536+
assert!(bytes_read > 0, "proxy should receive a CONNECT request");
537+
stream
538+
.write_all(b"HTTP/1.1 200 OK\r\n\r\n")
539+
.await
540+
.expect("proxy should write the CONNECT response");
541+
// Complete the CONNECT tunnel but never speak TLS, so the client's handshake deadline is
542+
// the only thing that can end the connection attempt.
543+
tokio::time::sleep(Duration::from_secs(30)).await;
544+
drop(stream);
545+
});
546+
547+
let proxy_uri: Uri = format!("http://{proxy_addr}").parse().expect("proxy URI should parse");
548+
let mut client = HttpClient::builder()
549+
.with_proxies(vec![Proxy::new(hyper_http_proxy::Intercept::All, proxy_uri)])
550+
.with_tls_config(|builder| builder.with_root_cert_store(RootCertStore::empty()))
551+
.with_tls_handshake_timeout(Duration::from_millis(200))
552+
.with_http_protocol(HttpProtocol::Http1)
553+
.build()
554+
.expect("client should build");
555+
let request = Request::get("https://example.invalid/")
556+
.body(Empty::<Bytes>::new())
557+
.expect("request should build");
558+
559+
let error = timeout(Duration::from_secs(5), client.send(request))
560+
.await
561+
.expect("request should not hit the outer test timeout")
562+
.expect_err("handshake should time out before completing");
563+
assert!(
564+
format!("{error:#}").contains("TLS handshake timed out"),
565+
"expected a TLS handshake timeout, got: {error:#}"
566+
);
567+
568+
proxy_task.abort();
569+
}
570+
571+
#[tokio::test]
572+
async fn zero_tls_handshake_timeout_disables_the_proxy_tunnel_deadline() {
573+
initialize_crypto_provider();
574+
575+
let listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind listener");
576+
let proxy_addr = listener.local_addr().expect("should have local address");
577+
let proxy_task = tokio::spawn(async move {
578+
let (mut stream, _) = listener.accept().await.expect("proxy should accept a connection");
579+
let mut request = [0; 4096];
580+
let bytes_read = stream
581+
.read(&mut request)
582+
.await
583+
.expect("proxy should read the CONNECT request");
584+
assert!(bytes_read > 0, "proxy should receive a CONNECT request");
585+
stream
586+
.write_all(b"HTTP/1.1 200 OK\r\n\r\n")
587+
.await
588+
.expect("proxy should write the CONNECT response");
589+
// Complete the CONNECT tunnel but never speak TLS, so the request only fails if some
590+
// deadline (mistakenly) bounds the handshake.
591+
tokio::time::sleep(Duration::from_secs(30)).await;
592+
drop(stream);
593+
});
594+
595+
let proxy_uri: Uri = format!("http://{proxy_addr}").parse().expect("proxy URI should parse");
596+
let mut client = HttpClient::builder()
597+
.with_proxies(vec![Proxy::new(hyper_http_proxy::Intercept::All, proxy_uri)])
598+
.with_tls_config(|builder| builder.with_root_cert_store(RootCertStore::empty()))
599+
.with_tls_handshake_timeout(Duration::ZERO)
600+
.with_http_protocol(HttpProtocol::Http1)
601+
.build()
602+
.expect("client should build");
603+
let request = Request::get("https://example.invalid/")
604+
.body(Empty::<Bytes>::new())
605+
.expect("request should build");
606+
607+
// A zero handshake timeout means "disabled", so the request should still be pending well past
608+
// the point where a mistakenly active zero-length deadline would have already failed it.
609+
let result = timeout(Duration::from_millis(300), client.send(request)).await;
610+
assert!(
611+
result.is_err(),
612+
"a disabled timeout should not fail the proxy tunnel's TLS handshake, got: {result:?}"
613+
);
614+
615+
proxy_task.abort();
616+
}
617+
518618
fn mutual_tls_configs() -> (ServerConfig, ClientConfig) {
519619
let server_cert = SelfSignedCert::localhost();
520620
let client_cert = SelfSignedCert::new(["saluki-client"]);

lib/saluki-io/src/net/client/http/conn.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,13 @@ pub struct HttpsCapableConnector {
375375
conn_age_limit: Option<Duration>,
376376
}
377377

378+
impl HttpsCapableConnector {
379+
/// Returns the timeout applied to the TLS handshake for HTTPS connections.
380+
pub(crate) fn tls_handshake_timeout(&self) -> Duration {
381+
self.tls_handshake_timeout
382+
}
383+
}
384+
378385
impl Service<Uri> for HttpsCapableConnector {
379386
type Response = HttpsCapableConnection;
380387
type Error = BoxError;

0 commit comments

Comments
 (0)