From d361a215433e30ee6aadc5594ed41875d7043b25 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 16 Feb 2026 13:43:22 +0000 Subject: [PATCH] fix(rustproxy-http): preserve original Host header when proxying and add X-Forwarded-* headers; add TLS WebSocket echo backend helper and integration test for terminate-and-reencrypt websocket --- changelog.md | 10 ++ .../rustproxy-http/src/proxy_service.rs | 100 ++++++++++--- rust/crates/rustproxy/tests/common/mod.rs | 80 +++++++++++ .../rustproxy/tests/integration_http_proxy.rs | 132 ++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- 5 files changed, 306 insertions(+), 18 deletions(-) diff --git a/changelog.md b/changelog.md index 642dd75..e302037 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-02-16 - 25.7.2 - fix(rustproxy-http) +preserve original Host header when proxying and add X-Forwarded-* headers; add TLS WebSocket echo backend helper and integration test for terminate-and-reencrypt websocket + +- Preserve the client's original Host header instead of replacing it with backend host:port when proxying requests. +- Add standard reverse-proxy headers: X-Forwarded-For (appends client IP), X-Forwarded-Host, and X-Forwarded-Proto for upstream requests. +- Ensure raw TCP/HTTP upstream requests copy original headers and skip X-Forwarded-* (which are added explicitly). +- Add start_tls_ws_echo_backend test helper to start a TLS WebSocket echo backend for tests. +- Add integration test test_terminate_and_reencrypt_websocket to verify WS upgrade through terminate-and-reencrypt TLS path. +- Rename unused parameter upstream to _upstream in proxy_service functions to avoid warnings. + ## 2026-02-16 - 25.7.1 - fix(proxy) use TLS to backends for terminate-and-reencrypt routes diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index af0e956..5ffa5aa 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -404,6 +404,51 @@ impl HttpProxyService { } } + // Add standard reverse-proxy headers (X-Forwarded-*) + { + let original_host = parts.headers.get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + let forwarded_proto = if route_match.route.action.tls.as_ref() + .map(|t| matches!(t.mode, + rustproxy_config::TlsMode::Terminate + | rustproxy_config::TlsMode::TerminateAndReencrypt)) + .unwrap_or(false) + { + "https" + } else { + "http" + }; + + // X-Forwarded-For: append client IP to existing chain + let client_ip = peer_addr.ip().to_string(); + let xff_value = if let Some(existing) = upstream_headers.get("x-forwarded-for") { + format!("{}, {}", existing.to_str().unwrap_or(""), client_ip) + } else { + client_ip + }; + if let Ok(val) = hyper::header::HeaderValue::from_str(&xff_value) { + upstream_headers.insert( + hyper::header::HeaderName::from_static("x-forwarded-for"), + val, + ); + } + // X-Forwarded-Host: original Host header + if let Ok(val) = hyper::header::HeaderValue::from_str(original_host) { + upstream_headers.insert( + hyper::header::HeaderName::from_static("x-forwarded-host"), + val, + ); + } + // X-Forwarded-Proto: original client protocol + if let Ok(val) = hyper::header::HeaderValue::from_str(forwarded_proto) { + upstream_headers.insert( + hyper::header::HeaderName::from_static("x-forwarded-proto"), + val, + ); + } + } + // Connect to upstream with timeout (TLS if upstream.use_tls is set) let backend = if upstream.use_tls { match tokio::time::timeout( @@ -469,7 +514,7 @@ impl HttpProxyService { body: Incoming, upstream_headers: hyper::HeaderMap, upstream_path: &str, - upstream: &crate::upstream_selector::UpstreamSelection, + _upstream: &crate::upstream_selector::UpstreamSelection, route: &rustproxy_config::RouteConfig, route_id: Option<&str>, source_ip: &str, @@ -496,11 +541,6 @@ impl HttpProxyService { if let Some(headers) = upstream_req.headers_mut() { *headers = upstream_headers; - if let Ok(host_val) = hyper::header::HeaderValue::from_str( - &format!("{}:{}", upstream.host, upstream.port) - ) { - headers.insert(hyper::header::HOST, host_val); - } } // Wrap the request body in CountingBody to track bytes_in @@ -535,7 +575,7 @@ impl HttpProxyService { body: Incoming, upstream_headers: hyper::HeaderMap, upstream_path: &str, - upstream: &crate::upstream_selector::UpstreamSelection, + _upstream: &crate::upstream_selector::UpstreamSelection, route: &rustproxy_config::RouteConfig, route_id: Option<&str>, source_ip: &str, @@ -562,11 +602,6 @@ impl HttpProxyService { if let Some(headers) = upstream_req.headers_mut() { *headers = upstream_headers; - if let Ok(host_val) = hyper::header::HeaderValue::from_str( - &format!("{}:{}", upstream.host, upstream.port) - ) { - headers.insert(hyper::header::HOST, host_val); - } } // Wrap the request body in CountingBody to track bytes_in @@ -739,13 +774,44 @@ impl HttpProxyService { parts.method, upstream_path ); - let upstream_host = format!("{}:{}", upstream.host, upstream.port); + // Copy all original headers (preserving the client's Host header). + // Skip X-Forwarded-* since we set them ourselves below. for (name, value) in parts.headers.iter() { - if name == hyper::header::HOST { - raw_request.push_str(&format!("host: {}\r\n", upstream_host)); - } else { - raw_request.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or(""))); + let name_str = name.as_str(); + if name_str == "x-forwarded-for" + || name_str == "x-forwarded-host" + || name_str == "x-forwarded-proto" + { + continue; } + raw_request.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or(""))); + } + + // Add standard reverse-proxy headers (X-Forwarded-*) + { + let original_host = parts.headers.get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + let forwarded_proto = if route.action.tls.as_ref() + .map(|t| matches!(t.mode, + rustproxy_config::TlsMode::Terminate + | rustproxy_config::TlsMode::TerminateAndReencrypt)) + .unwrap_or(false) + { + "https" + } else { + "http" + }; + + let client_ip = peer_addr.ip().to_string(); + let xff_value = if let Some(existing) = parts.headers.get("x-forwarded-for") { + format!("{}, {}", existing.to_str().unwrap_or(""), client_ip) + } else { + client_ip + }; + raw_request.push_str(&format!("x-forwarded-for: {}\r\n", xff_value)); + raw_request.push_str(&format!("x-forwarded-host: {}\r\n", original_host)); + raw_request.push_str(&format!("x-forwarded-proto: {}\r\n", forwarded_proto)); } if let Some(ref route_headers) = route.headers { diff --git a/rust/crates/rustproxy/tests/common/mod.rs b/rust/crates/rustproxy/tests/common/mod.rs index 168cb00..4395f1b 100644 --- a/rust/crates/rustproxy/tests/common/mod.rs +++ b/rust/crates/rustproxy/tests/common/mod.rs @@ -452,6 +452,86 @@ pub fn make_tls_terminate_route( route } +/// Start a TLS WebSocket echo backend: accepts TLS, performs WS handshake, then echoes data. +/// Combines TLS acceptance (like `start_tls_http_backend`) with WebSocket echo (like `start_ws_echo_backend`). +pub async fn start_tls_ws_echo_backend( + port: u16, + cert_pem: &str, + key_pem: &str, +) -> JoinHandle<()> { + use std::sync::Arc; + + let acceptor = rustproxy_passthrough::build_tls_acceptor(cert_pem, key_pem) + .expect("Failed to build TLS acceptor"); + let acceptor = Arc::new(acceptor); + + let listener = TcpListener::bind(format!("127.0.0.1:{}", port)) + .await + .unwrap_or_else(|_| panic!("Failed to bind TLS WS echo backend on port {}", port)); + + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(conn) => conn, + Err(_) => break, + }; + let acc = acceptor.clone(); + tokio::spawn(async move { + let mut tls_stream = match acc.accept(stream).await { + Ok(s) => s, + Err(_) => return, + }; + + // Read the HTTP upgrade request + let mut buf = vec![0u8; 4096]; + let n = match tls_stream.read(&mut buf).await { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + + let req_str = String::from_utf8_lossy(&buf[..n]); + + // Extract Sec-WebSocket-Key for handshake + let ws_key = req_str + .lines() + .find(|l| l.to_lowercase().starts_with("sec-websocket-key:")) + .map(|l| l.split(':').nth(1).unwrap_or("").trim().to_string()) + .unwrap_or_default(); + + // Send 101 Switching Protocols + let accept_response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ + Upgrade: websocket\r\n\ + Connection: Upgrade\r\n\ + Sec-WebSocket-Accept: {}\r\n\ + \r\n", + ws_key + ); + + if tls_stream + .write_all(accept_response.as_bytes()) + .await + .is_err() + { + return; + } + + // Echo all data back (raw TCP after upgrade) + let mut echo_buf = vec![0u8; 65536]; + loop { + let n = match tls_stream.read(&mut echo_buf).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + if tls_stream.write_all(&echo_buf[..n]).await.is_err() { + break; + } + } + }); + } + }) +} + /// Helper to create a TLS passthrough route for testing. pub fn make_tls_passthrough_route( port: u16, diff --git a/rust/crates/rustproxy/tests/integration_http_proxy.rs b/rust/crates/rustproxy/tests/integration_http_proxy.rs index 968dcd4..897a834 100644 --- a/rust/crates/rustproxy/tests/integration_http_proxy.rs +++ b/rust/crates/rustproxy/tests/integration_http_proxy.rs @@ -490,6 +490,12 @@ async fn test_terminate_and_reencrypt_http_routing() { "Expected /api/data path, got: {}", alpha_body ); + // Verify original Host header is preserved (not replaced with backend IP:port) + assert!( + alpha_body.contains(r#""host":"alpha.example.com"#), + "Expected original Host header alpha.example.com, got: {}", + alpha_body + ); // Test beta domain - different host goes to different backend let beta_result = with_timeout(async { @@ -527,10 +533,136 @@ async fn test_terminate_and_reencrypt_http_routing() { "Expected /other path, got: {}", beta_body ); + // Verify original Host header is preserved for beta too + assert!( + beta_body.contains(r#""host":"beta.example.com"#), + "Expected original Host header beta.example.com, got: {}", + beta_body + ); proxy.stop().await.unwrap(); } +/// Test that WebSocket upgrade works through terminate-and-reencrypt mode. +/// +/// Verifies the full chain: client→TLS→proxy terminates→re-encrypts→TLS→backend WebSocket. +/// The proxy's `handle_websocket_upgrade` checks `upstream.use_tls` and calls +/// `connect_tls_backend()` when true. This test covers that path. +#[tokio::test] +async fn test_terminate_and_reencrypt_websocket() { + let backend_port = next_port(); + let proxy_port = next_port(); + let domain = "ws.example.com"; + + // Frontend cert (client→proxy TLS) + let (frontend_cert, frontend_key) = generate_self_signed_cert(domain); + // Backend cert (proxy→backend TLS) + let (backend_cert, backend_key) = generate_self_signed_cert("localhost"); + + // Start TLS WebSocket echo backend + let _backend = start_tls_ws_echo_backend(backend_port, &backend_cert, &backend_key).await; + + // Create terminate-and-reencrypt route + let mut route = make_tls_terminate_route( + proxy_port, + domain, + "127.0.0.1", + backend_port, + &frontend_cert, + &frontend_key, + ); + route.action.tls.as_mut().unwrap().mode = rustproxy_config::TlsMode::TerminateAndReencrypt; + + let options = RustProxyOptions { + routes: vec![route], + ..Default::default() + }; + + let mut proxy = RustProxy::new(options).unwrap(); + proxy.start().await.unwrap(); + assert!(wait_for_port(proxy_port, 2000).await); + + let result = with_timeout( + async { + let _ = rustls::crypto::ring::default_provider().install_default(); + let tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier)) + .with_no_client_auth(); + let connector = + tokio_rustls::TlsConnector::from(std::sync::Arc::new(tls_config)); + + let stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", proxy_port)) + .await + .unwrap(); + let server_name = + rustls::pki_types::ServerName::try_from(domain.to_string()).unwrap(); + let mut tls_stream = connector.connect(server_name, stream).await.unwrap(); + + // Send WebSocket upgrade request through TLS + let request = format!( + "GET /ws HTTP/1.1\r\n\ + Host: {}\r\n\ + Upgrade: websocket\r\n\ + Connection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ + Sec-WebSocket-Version: 13\r\n\ + \r\n", + domain + ); + tls_stream.write_all(request.as_bytes()).await.unwrap(); + + // Read the 101 response (byte-by-byte until \r\n\r\n) + let mut response_buf = Vec::with_capacity(4096); + let mut temp = [0u8; 1]; + loop { + let n = tls_stream.read(&mut temp).await.unwrap(); + if n == 0 { + break; + } + response_buf.push(temp[0]); + if response_buf.len() >= 4 { + let len = response_buf.len(); + if response_buf[len - 4..] == *b"\r\n\r\n" { + break; + } + } + } + + let response_str = String::from_utf8_lossy(&response_buf).to_string(); + assert!( + response_str.contains("101"), + "Expected 101 Switching Protocols, got: {}", + response_str + ); + assert!( + response_str.to_lowercase().contains("upgrade: websocket"), + "Expected Upgrade header, got: {}", + response_str + ); + + // After upgrade, send data and verify echo + let test_data = b"Hello TLS WebSocket!"; + tls_stream.write_all(test_data).await.unwrap(); + + // Read echoed data + let mut echo_buf = vec![0u8; 256]; + let n = tls_stream.read(&mut echo_buf).await.unwrap(); + let echoed = &echo_buf[..n]; + + assert_eq!(echoed, test_data, "Expected echo of sent data"); + + "ok".to_string() + }, + 10, + ) + .await + .unwrap(); + + assert_eq!(result, "ok"); + proxy.stop().await.unwrap(); +} + /// Test that the protocol field on route config is accepted and processed. #[tokio::test] async fn test_protocol_field_in_route_config() { diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f96f589..0e8c242 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '25.7.1', + version: '25.7.2', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' }