Repository navigation
fix(io): bound the TLS handshake performed when tunneling HTTPS through a proxy #2309
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8467084
71cf1b5
f7fd65c
7474766
731106d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -376,10 +376,15 @@ 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)?; | ||
| // 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()); | ||
|
|
@@ -515,6 +520,101 @@ mod tests { | |
| server_task.abort(); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn tls_handshake_timeout_fires_against_a_stalled_proxy_tunnel() { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could go either way on this test. It duplicates a test in the upstream crate, but also it checks that we wired the timeout through correctly. I'm inclined to leave it.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a bad idea to verify the upstream crate behavior doesn't change. |
||
| 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::<Bytes>::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(); | ||
| } | ||
|
|
||
| #[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::<Bytes>::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"]); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice