From 75f9cd1a1990eb87ddfb677b2bf252f624091262 Mon Sep 17 00:00:00 2001 From: fewensa Date: Mon, 20 Jul 2026 14:48:26 +0000 Subject: [PATCH] feat: RTTP: Add a safe policy for HTTPS-to-HTTP redirects --- crates/rttp-client/src/config.rs | 23 ++++++++ .../src/connection/async_connection.rs | 6 ++ .../src/connection/block_connection.rs | 6 ++ crates/rttp-client/src/error.rs | 8 +++ crates/rttp-client/tests/test_http_async.rs | 57 +++++++++++++++++++ crates/rttp-client/tests/test_http_basic.rs | 51 +++++++++++++++++ .../rttp-test-support/src/client_helpers.rs | 39 +++++++++++++ 7 files changed, 190 insertions(+) diff --git a/crates/rttp-client/src/config.rs b/crates/rttp-client/src/config.rs index fd971e60..12c14e46 100644 --- a/crates/rttp-client/src/config.rs +++ b/crates/rttp-client/src/config.rs @@ -42,6 +42,7 @@ pub struct Config { write_timeout: u64, auto_redirect: bool, max_redirect: u32, + allow_https_to_http_redirects: bool, verify_ssl_hostname: bool, verify_ssl_cert: bool, http2_max_frame_size: Option, @@ -55,6 +56,7 @@ impl Default for Config { .write_timeout(10000) .auto_redirect(false) .max_redirect(0) + .allow_https_to_http_redirects(false) .build() } } @@ -78,6 +80,9 @@ impl Config { pub fn max_redirect(&self) -> u32 { self.max_redirect } + pub fn allow_https_to_http_redirects(&self) -> bool { + self.allow_https_to_http_redirects + } pub fn verify_ssl_cert(&self) -> bool { self.verify_ssl_cert } @@ -123,6 +128,7 @@ impl ConfigBuilder { write_timeout: 10000, auto_redirect: false, max_redirect: 0, + allow_https_to_http_redirects: false, verify_ssl_hostname: true, verify_ssl_cert: true, http2_max_frame_size: None, @@ -154,6 +160,17 @@ impl ConfigBuilder { self.config.max_redirect = max_redirect; self } + /// Allow automatic redirects from HTTPS URLs to HTTP URLs. + /// + /// This is disabled by default because following such a redirect removes + /// transport security from the request. + pub fn allow_https_to_http_redirects( + &mut self, + allow_https_to_http_redirects: bool, + ) -> &mut Self { + self.config.allow_https_to_http_redirects = allow_https_to_http_redirects; + self + } pub fn verify_ssl_hostname(&mut self, verify_ssl_hostname: bool) -> &mut Self { self.config.verify_ssl_hostname = verify_ssl_hostname; self @@ -214,6 +231,7 @@ mod tests { .write_timeout(4321) .auto_redirect(true) .max_redirect(5) + .allow_https_to_http_redirects(true) .verify_ssl_hostname(false) .verify_ssl_cert(false) .http2_max_frame_size(16_384) @@ -224,6 +242,7 @@ mod tests { assert_eq!(config.write_timeout(), 4321); assert!(config.auto_redirect()); assert_eq!(config.max_redirect(), 5); + assert!(config.allow_https_to_http_redirects()); assert!(!config.verify_ssl_hostname()); assert!(!config.verify_ssl_cert()); assert_eq!(Some(16_384), config.http2_max_frame_size()); @@ -245,6 +264,10 @@ mod tests { builder_config.auto_redirect() ); assert_eq!(default_config.max_redirect(), builder_config.max_redirect()); + assert_eq!( + default_config.allow_https_to_http_redirects(), + builder_config.allow_https_to_http_redirects() + ); assert_eq!( default_config.verify_ssl_hostname(), builder_config.verify_ssl_hostname() diff --git a/crates/rttp-client/src/connection/async_connection.rs b/crates/rttp-client/src/connection/async_connection.rs index 63544764..e1313135 100644 --- a/crates/rttp-client/src/connection/async_connection.rs +++ b/crates/rttp-client/src/connection/async_connection.rs @@ -275,6 +275,12 @@ impl<'a> AsyncConnection<'a> { } let redirect_url = self.conn.resolve_redirect_url(&url, location)?; + if url.scheme() == "https" + && redirect_url.url.scheme() == "http" + && !config.allow_https_to_http_redirects() + { + return Err(error::https_to_http_redirect(url)); + } let strip_sensitive_headers = !self.conn.is_same_origin_url(&url, &redirect_url.url); if !self.conn.is_same_origin_url(&url, &redirect_url.url) || redirect_url.url.scheme() != "http" diff --git a/crates/rttp-client/src/connection/block_connection.rs b/crates/rttp-client/src/connection/block_connection.rs index 6af0941a..3e97ba95 100644 --- a/crates/rttp-client/src/connection/block_connection.rs +++ b/crates/rttp-client/src/connection/block_connection.rs @@ -82,6 +82,12 @@ impl<'a> BlockConnection<'a> { } let redirect_url = self.conn.resolve_redirect_url(&url, location)?; + if url.scheme() == "https" + && redirect_url.url.scheme() == "http" + && !config.allow_https_to_http_redirects() + { + return Err(error::https_to_http_redirect(url)); + } let strip_sensitive_headers = !self.conn.is_same_origin_url(&url, &redirect_url.url); if !self.conn.is_same_origin_url(&url, &redirect_url.url) || redirect_url.url.scheme() != "http" diff --git a/crates/rttp-client/src/error.rs b/crates/rttp-client/src/error.rs index 15b1556c..73a7461f 100644 --- a/crates/rttp-client/src/error.rs +++ b/crates/rttp-client/src/error.rs @@ -204,6 +204,14 @@ pub(crate) fn too_many_redirects(url: Url) -> Error { Error::new(Kind::Redirect, Some("too many redirects")).with_url(url) } +pub(crate) fn https_to_http_redirect(url: Url) -> Error { + Error::new( + Kind::Redirect, + Some("HTTPS to HTTP redirect is not allowed"), + ) + .with_url(url) +} + #[allow(dead_code)] pub(crate) fn status_code(url: Url, status: StatusCode) -> Error { Error::new(Kind::Status(status), None::).with_url(url) diff --git a/crates/rttp-client/tests/test_http_async.rs b/crates/rttp-client/tests/test_http_async.rs index 71318a5f..34c5de93 100644 --- a/crates/rttp-client/tests/test_http_async.rs +++ b/crates/rttp-client/tests/test_http_async.rs @@ -1975,6 +1975,63 @@ fn test_async_https() { }); } +#[test] +#[cfg(all(feature = "async", feature = "tls-rustls"))] +fn test_async_https_to_http_redirect_is_rejected_before_target_connection() { + let target = TcpListener::bind("127.0.0.1:0").expect("bind redirect target"); + let target_addr = target.local_addr().expect("redirect target address"); + let (addr, _handle) = support::spawn_tls_redirect_server(format!("http://{}/final", target_addr)); + block_on(async { + let error = client() + .get() + .url(format!("https://{}/", addr)) + .config( + Config::builder() + .auto_redirect(true) + .verify_ssl_cert(false) + .verify_ssl_hostname(false), + ) + .rasync() + .await + .expect_err("HTTPS to HTTP redirect should be rejected by default"); + + assert!(error.is_redirect()); + assert!(error.to_string().contains("HTTPS to HTTP redirect")); + assert_eq!("https", error.url().expect("redirect source URL").scheme()); + }); + target + .set_nonblocking(true) + .expect("set redirect target nonblocking"); + assert!(matches!( + target.accept(), + Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock + )); +} + +#[test] +#[cfg(all(feature = "async", feature = "tls-rustls"))] +fn test_async_https_to_http_redirect_can_be_enabled_explicitly() { + let (target_addr, _target_handle) = support::spawn_http_server(); + let (addr, _handle) = support::spawn_tls_redirect_server(format!("http://{}/final", target_addr)); + block_on(async { + let response = client() + .get() + .url(format!("https://{}/", addr)) + .config( + Config::builder() + .auto_redirect(true) + .allow_https_to_http_redirects(true) + .verify_ssl_cert(false) + .verify_ssl_hostname(false), + ) + .rasync() + .await + .expect("explicit HTTPS to HTTP redirect opt-in should succeed"); + + assert!(response.ok()); + }); +} + #[test] #[cfg(feature = "async")] fn test_async_http_proxy_uses_absolute_form_for_http_requests() { diff --git a/crates/rttp-client/tests/test_http_basic.rs b/crates/rttp-client/tests/test_http_basic.rs index 9b94c0ae..d296f653 100644 --- a/crates/rttp-client/tests/test_http_basic.rs +++ b/crates/rttp-client/tests/test_http_basic.rs @@ -1193,6 +1193,57 @@ fn test_https() { assert!(response.is_ok()); } +#[test] +#[cfg(feature = "tls-rustls")] +fn test_https_to_http_redirect_is_rejected_before_target_connection() { + let target = TcpListener::bind("127.0.0.1:0").expect("bind redirect target"); + let target_addr = target.local_addr().expect("redirect target address"); + let (addr, _handle) = support::spawn_tls_redirect_server(format!("http://{}/final", target_addr)); + let error = client() + .get() + .url(format!("https://{}/", addr)) + .config( + Config::builder() + .auto_redirect(true) + .verify_ssl_cert(false) + .verify_ssl_hostname(false), + ) + .emit() + .expect_err("HTTPS to HTTP redirect should be rejected by default"); + + assert!(error.is_redirect()); + assert!(error.to_string().contains("HTTPS to HTTP redirect")); + assert_eq!("https", error.url().expect("redirect source URL").scheme()); + target + .set_nonblocking(true) + .expect("set redirect target nonblocking"); + assert!(matches!( + target.accept(), + Err(ref error) if error.kind() == io::ErrorKind::WouldBlock + )); +} + +#[test] +#[cfg(feature = "tls-rustls")] +fn test_https_to_http_redirect_can_be_enabled_explicitly() { + let (target_addr, _target_handle) = support::spawn_http_server(); + let (addr, _handle) = support::spawn_tls_redirect_server(format!("http://{}/final", target_addr)); + let response = client() + .get() + .url(format!("https://{}/", addr)) + .config( + Config::builder() + .auto_redirect(true) + .allow_https_to_http_redirects(true) + .verify_ssl_cert(false) + .verify_ssl_hostname(false), + ) + .emit() + .expect("explicit HTTPS to HTTP redirect opt-in should succeed"); + + assert!(response.ok()); +} + #[test] fn test_http_with_url() { let (addr, _handle) = support::spawn_http_server(); diff --git a/crates/rttp-test-support/src/client_helpers.rs b/crates/rttp-test-support/src/client_helpers.rs index 1fc79b3d..d7b348ad 100644 --- a/crates/rttp-test-support/src/client_helpers.rs +++ b/crates/rttp-test-support/src/client_helpers.rs @@ -1087,3 +1087,42 @@ pub fn spawn_tls_server() -> (SocketAddr, JoinHandle<()>) { }); (addr, handle) } + +#[cfg(feature = "tls-rustls")] +pub fn spawn_tls_redirect_server(location: String) -> (SocketAddr, JoinHandle<()>) { + use rcgen::generate_simple_self_signed; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + use rustls::{ServerConfig, ServerConnection, StreamOwned}; + use std::sync::Arc; + + let cert = generate_simple_self_signed(vec!["localhost".to_string()]).expect("generate cert"); + let cert_der = cert.serialize_der().expect("cert der"); + let key_der = cert.serialize_private_key_der(); + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(cert_der)], + PrivateKeyDer::from(PrivatePkcs8KeyDer::from(key_der)), + ) + .expect("set cert"); + let config = Arc::new(config); + + let (listener, addr) = bind_local_http_listener("tls redirect server"); + let handle = thread::spawn(move || { + if let Ok((stream, _)) = listener.accept() { + let session = ServerConnection::new(config).expect("server connection"); + let mut tls = StreamOwned::new(session, stream); + let _ = read_http_request(&mut tls); + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + location + ); + let _ = tls.write_all(response.as_bytes()); + let _ = tls.flush(); + tls.conn.send_close_notify(); + let _ = tls.flush(); + } + }); + + (addr, handle) +}