mod authority_tests { use super::*; fn validation_policy() -> crate::config::ValidationPolicy { crate::config::ValidationPolicy { tls_addr: None, cert_dir: None, data_dir: None, proxy_addr: "127.0.0.1:8080".parse().unwrap(), admin_addr: "127.0.0.1:9090".parse().unwrap(), } } #[test] fn retry_is_limited_to_safe_methods() { for method in [Method::GET, Method::HEAD, Method::OPTIONS] { assert!(retry_safe_method(&method)); } for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] { assert!(!retry_safe_method(&method)); } } #[test] fn explicit_http2_backend_uses_http2_request_version() { let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::Http2; let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); let route = snapshot.select("localhost", "/").unwrap(); assert_eq!( upstream_request_version(route.backend.as_ref().unwrap(), false), Version::HTTP_2 ); } #[test] fn preserve_host_sets_http2_authority_without_changing_network_origin() { use crate::config::UpstreamProtocol; for (protocol, scheme, expected_authority, remove_host) in [ (UpstreamProtocol::Http1, "https", "backend.internal:443", false), (UpstreamProtocol::Auto, "https", "unitraklub.pl", false), (UpstreamProtocol::Http2, "https", "unitraklub.pl", false), (UpstreamProtocol::H2, "https", "unitraklub.pl", true), (UpstreamProtocol::Auto, "http", "10.87.20.2:443", false), (UpstreamProtocol::Http2, "http", "unitraklub.pl", true), (UpstreamProtocol::H2, "http", "unitraklub.pl", true), ] { let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); let member = &mut config.vhosts[0].backends[0]; member.url = format!("{scheme}://10.87.20.2:443"); member.allow_private = true; member.protocol = protocol; member.preserve_host = true; if scheme == "https" { member.tls_server_name = Some("backend.internal".into()); } let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); let backend = &snapshot.pools[0].members[0].backend; let uri = upstream_uri(backend, "/block_refresh/block/10").unwrap(); let mut headers = HeaderMap::new(); headers.insert(HOST, HeaderValue::from_static("unitraklub.pl")); let target = upstream_request_target(&uri, &mut headers, backend, false, false).unwrap(); assert_eq!(target.authority().unwrap().as_str(), expected_authority, "{protocol:?}"); assert_eq!(target.path(), "/block_refresh/block/10"); assert_eq!(headers.contains_key(HOST), !remove_host); assert_eq!(backend.origin, format!("{scheme}://10.87.20.2:443")); } } #[test] fn invalid_outgoing_host_is_rejected_instead_of_used_for_dialing() { let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::H2; config.vhosts[0].backends[0].preserve_host = true; let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); let backend = &snapshot.pools[0].members[0].backend; let uri = upstream_uri(backend, "/").unwrap(); let mut headers = HeaderMap::new(); headers.insert(HOST, HeaderValue::from_static("evil@example.invalid")); assert!(upstream_request_target(&uri, &mut headers, backend, false, false).is_none()); } #[test] fn retry_prepares_authority_for_the_selected_backend_not_the_previous_one() { let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::H2; config.vhosts[0].backends[0].preserve_host = true; let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); let backend = &snapshot.pools[0].members[0].backend; let mut headers = HeaderMap::new(); headers.insert(HOST, HeaderValue::from_static("public.example")); let req = retry_request(&Method::GET, Version::HTTP_2, &headers, backend, &HeaderValue::from_static("public.example"), "/retry", true).unwrap(); assert_eq!(req.uri().authority().unwrap().as_str(), "public.example"); assert!(req.headers().get(HOST).is_none()); } #[test] fn legacy_https_http2_version_is_h1_safe_and_strict_h2_is_required() { let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); config.vhosts[0].backends[0].url = "https://127.0.0.1:9000".to_owned(); config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::Http2; let snapshot = crate::config::compile(config.clone(), 1, &validation_policy()).unwrap(); let route = snapshot.select("localhost", "/").unwrap(); let origin = route.backend.as_ref().unwrap(); assert_eq!(upstream_request_version(origin, false), Version::HTTP_11); // Extended CONNECT still uses H2 only in its existing WebSocket path. assert_eq!(upstream_request_version(origin, true), Version::HTTP_2); config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::H2; let snapshot = crate::config::compile(config, 2, &validation_policy()).unwrap(); let route = snapshot.select("localhost", "/").unwrap(); assert_eq!( upstream_request_version(route.backend.as_ref().unwrap(), false), Version::HTTP_2 ); } #[tokio::test] async fn h3_body_capable_methods_without_length_become_known_empty_for_h1() { use bytes::Bytes; for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] { let stream = futures_util::stream::empty::>(); let mut request = Request::builder() .method(method.clone()) .version(Version::HTTP_3) .uri("https://example.test/api2/json/item") .body(Body::from_stream(stream)) .unwrap(); assert_eq!(request.body().size_hint().exact(), None, "{method}"); normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) .await .unwrap(); assert!(request.body().is_end_stream(), "{method}"); assert_eq!(request.body().size_hint().exact(), Some(0), "{method}"); } } #[tokio::test] async fn h3_delete_unknown_nonempty_body_is_replayed_not_buffered_away() { use bytes::Bytes; let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"payload"))]); let mut request = Request::builder() .method(Method::DELETE) .version(Version::HTTP_3) .uri("https://example.test/api2/json/item") .body(Body::from_stream(stream)) .unwrap(); normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) .await .unwrap(); let body = request.into_body().collect().await.unwrap().to_bytes(); assert_eq!(body, Bytes::from_static(b"payload")); } #[tokio::test] async fn h3_unknown_body_is_untouched_for_http2_upstream() { use bytes::Bytes; let stream = futures_util::stream::pending::>(); let mut request = Request::builder() .method(Method::DELETE) .version(Version::HTTP_3) .uri("https://example.test/api2/json/item") .body(Body::from_stream(stream)) .unwrap(); normalize_h3_h1_unknown_body(&mut request, Version::HTTP_2) .await .unwrap(); assert_eq!(request.body().size_hint().exact(), None); } #[tokio::test] async fn non_h3_unknown_body_is_untouched_for_http1_upstream() { use bytes::Bytes; for version in [Version::HTTP_11, Version::HTTP_2] { let stream = futures_util::stream::pending::>(); let mut request = Request::builder() .method(Method::DELETE) .version(version) .uri("https://example.test/api2/json/item") .body(Body::from_stream(stream)) .unwrap(); normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) .await .unwrap(); assert_eq!(request.body().size_hint().exact(), None); } } #[tokio::test] async fn h3_declared_delete_body_is_not_preconsumed() { use bytes::Bytes; let stream = futures_util::stream::pending::>(); let mut request = Request::builder() .method(Method::DELETE) .version(Version::HTTP_3) .uri("https://example.test/api2/json/item") .header(http::header::CONTENT_LENGTH, "7") .body(Body::from_stream(stream)) .unwrap(); normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) .await .unwrap(); assert_eq!(request.headers()[http::header::CONTENT_LENGTH], "7"); assert_eq!(request.body().size_hint().exact(), None); } #[test] fn generated_policy_and_gateway_errors_use_html_pages() { for status in [ StatusCode::BAD_GATEWAY, StatusCode::FORBIDDEN, StatusCode::TOO_MANY_REQUESTS, StatusCode::SERVICE_UNAVAILABLE, StatusCode::GATEWAY_TIMEOUT, ] { let response = error(status, "internal detail\n"); assert_eq!(response.status(), status); assert_eq!( response.headers().get("content-type").unwrap(), "text/html; charset=utf-8" ); assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); } } #[test] fn locally_generated_pages_remove_cache_metadata() { let mut response = error(StatusCode::BAD_GATEWAY, "internal detail\n"); for (name, value) in [ ("etag", "\"old\""), ("x-cache", "HIT"), ("x-cache-reason", "origin"), ("cache-status", "Origin; hit"), ] { response.headers_mut().insert(name, value.parse().unwrap()); } enforce_local_page_no_cache(&mut response); assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); for name in ["etag", "x-cache", "x-cache-reason", "cache-status"] { assert!(!response.headers().contains_key(name), "{name}"); } assert!(response .extensions() .get::() .is_some()); } #[test] fn http2_uses_authority_and_rejects_conflicting_host() { let mut request = Request::builder() .version(Version::HTTP_2) .uri("https://app.localhost/test") .body(Body::empty()) .unwrap(); assert_eq!(effective_host(&request, true).unwrap(), "app.localhost"); request .headers_mut() .insert(HOST, "different.localhost".parse().unwrap()); assert!(effective_host(&request, true).is_err()); } #[test] fn legacy_websocket_origin_matches_upstream_scheme_and_host() { let origin = legacy_websocket_origin("https://ilo.internal:443", &"10.0.0.5:443".parse().unwrap()) .unwrap(); assert_eq!(origin, "https://10.0.0.5:443"); assert!(legacy_websocket_origin("not-an-origin", &"10.0.0.5".parse().unwrap()).is_none()); } #[test] fn force_ssl_location_preserves_path_query_and_https_port() { let uri: Uri = "/api/items?x=1&next=%2Fhome".parse().unwrap(); assert_eq!( force_ssl_location("app.example.test", &uri, 443).unwrap(), "https://app.example.test/api/items?x=1&next=%2Fhome" ); assert_eq!( force_ssl_location("app.example.test", &uri, 8443).unwrap(), "https://app.example.test:8443/api/items?x=1&next=%2Fhome" ); } #[test] fn http1_absolute_form_and_h2_wrong_scheme_are_rejected() { let request = Request::builder() .uri("http://app.localhost/") .header(HOST, "app.localhost") .body(Body::empty()) .unwrap(); assert!(effective_host(&request, false).is_err()); let request = Request::builder() .version(Version::HTTP_2) .uri("http://app.localhost/") .body(Body::empty()) .unwrap(); assert!(effective_host(&request, true).is_err()); } #[derive(Debug)] struct NestedDiagnosticError { source: std::io::Error, } impl std::fmt::Display for NestedDiagnosticError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("connector wrapper") } } impl std::error::Error for NestedDiagnosticError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { Some(&self.source) } } #[test] fn upstream_error_chain_keeps_nested_connector_cause() { let error = NestedDiagnosticError { source: std::io::Error::new( std::io::ErrorKind::ConnectionRefused, "connection refused by origin", ), }; let detail = format_error_chain(&error); assert!(detail.contains("connector wrapper")); assert!(detail.contains("connection refused by origin")); } #[test] fn upstream_error_detail_truncation_preserves_utf8_boundaries() { let mut detail = "ą".repeat(MAX_UPSTREAM_ERROR_DETAIL); truncate_utf8(&mut detail, MAX_UPSTREAM_ERROR_DETAIL); assert!(detail.len() <= MAX_UPSTREAM_ERROR_DETAIL); assert!(std::str::from_utf8(detail.as_bytes()).is_ok()); } #[test] fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() { let mut response = Response::new(Body::empty()); response.headers_mut().insert("x-rustproxy-backend", HeaderValue::from_static("canonical-spoof")); response .headers_mut() .insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed")); response.headers_mut().insert( "x-rust-proxy-backend-time", HeaderValue::from_static("legacy-spoof"), ); response.headers_mut().insert( "x-rustproxy-backend-time", HeaderValue::from_static("999ms"), ); apply_backend_diagnostic_values(&mut response, false, false); assert!(response.headers().get("x-rustproxy-backend").is_none()); assert!(response.headers().get("x-rust-proxy-backend").is_none()); assert!(response .headers() .get("x-rust-proxy-backend-time") .is_none()); assert!(response.headers().get("x-rustproxy-backend-time").is_none()); response .extensions_mut() .insert(OriginBackend("origin-a".into())); response .extensions_mut() .insert(OriginTiming(Duration::from_millis(12))); response.headers_mut().insert("x-rustproxy-backend", HeaderValue::from_static("canonical-spoof")); apply_backend_diagnostic_values(&mut response, true, true); assert!(response.headers().get("x-rustproxy-backend").is_none()); assert_eq!( response.headers().get("x-rust-proxy-backend").unwrap(), "origin-a" ); assert_eq!( response.headers().get("x-rustproxy-backend-time").unwrap(), "12.00ms" ); } #[test] fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() { let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); let backend = snapshot .select("localhost", "/") .unwrap() .backend .as_ref() .unwrap(); let suffix = "/convert?url=https%3A//easylist.to/easylist/easylist.txt&ip=0.0.0.0"; let uri = upstream_uri(backend, suffix).unwrap(); assert_eq!(uri.path_and_query().unwrap().as_str(), suffix); } #[tokio::test] async fn error_fallback_replays_empty_unknown_length_h2_h3_get_and_head() { use bytes::Bytes; for version in [Version::HTTP_2, Version::HTTP_3] { for method in [Method::GET, Method::HEAD] { let stream = futures_util::stream::empty::>(); let mut request = Request::builder() .version(version) .method(method.clone()) .uri("https://www.example.test/missing?query=1") .body(Body::from_stream(stream)) .unwrap(); assert_eq!(request.body().size_hint().exact(), None); let result = match prepare_error_fallback_body(&mut request, true).await { Ok(result) => result, Err(response) => panic!("unexpected status {}", response.status()), }; assert!( matches!(result, ErrorFallbackBody::Replayable(ref bytes) if bytes.is_empty()), "an empty {method:?} request over {version:?} should allow fallback" ); assert!(request.body().is_end_stream()); assert_eq!( request.uri().path_and_query().unwrap().as_str(), "/missing?query=1" ); } } } #[tokio::test] async fn error_fallback_preserves_unknown_length_get_payload_without_replaying_it() { use bytes::Bytes; let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static( b"unexpected-payload", ))]); let mut request = Request::builder() .method(Method::GET) .version(Version::HTTP_3) .uri("https://www.example.test/missing") .body(Body::from_stream(stream)) .unwrap(); let result = match prepare_error_fallback_body(&mut request, true).await { Ok(result) => result, Err(response) => panic!("unexpected status {}", response.status()), }; assert!(matches!(result, ErrorFallbackBody::Unavailable)); assert_eq!( request.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"unexpected-payload") ); } #[tokio::test] async fn error_fallback_does_not_poll_unknown_length_post_payload() { use bytes::Bytes; let stream = futures_util::stream::pending::>(); let mut request = Request::builder() .method(Method::POST) .version(Version::HTTP_2) .uri("https://www.example.test/form") .body(Body::from_stream(stream)) .unwrap(); let result = match prepare_error_fallback_body(&mut request, true).await { Ok(result) => result, Err(response) => panic!("unexpected status {}", response.status()), }; assert!(matches!(result, ErrorFallbackBody::Unavailable)); } #[tokio::test] async fn fallback_terminal_404_html_takes_precedence_over_builtin_error_page() { use bytes::Bytes; let mut response = Response::builder() .status(StatusCode::NOT_FOUND) .header("content-type", "text/html; charset=utf-8") .body(Body::from("ReactPHP 404")) .unwrap(); response.extensions_mut().insert(ErrorFallbackResponse); let configured_page = crate::pages::Page { kind: crate::pages::Kind::Builtin, status: Some(404), html: String::new(), }; if should_replace_error_page(&response, false) { crate::pages::replace_error(&mut response, &configured_page); } assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!( response.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"ReactPHP 404") ); // A proxy-generated response is eligible for configured global/vhost // pages even when interception of *application* errors is disabled. let response = Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::empty()) .unwrap(); assert!(should_replace_error_page(&response, false)); } #[tokio::test] async fn application_404_is_not_overwritten_by_global_pages_when_interception_is_off() { use http_body_util::BodyExt; for source in ["reverse_proxy", "web_server_php"] { let mut response = Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from(format!("{source}: origin 404 body"))) .unwrap(); // Both paths use intercept_route_origin_error to set this marker // once the origin response reaches Response. response .extensions_mut() .insert(crate::pages::OriginProvidedResponse); assert!(!should_replace_error_page(&response, false), "{source}"); let bytes = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!( bytes.as_ref(), format!("{source}: origin 404 body").as_bytes() ); } } #[tokio::test] async fn compiled_vhost_origin_interception_controls_application_404() { for enabled in [false, true] { let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); config.pages.errors.insert( 404, crate::pages::Page { kind: crate::pages::Kind::Html, status: Some(503), html: "RustProxy custom error page".into(), }, ); if enabled { config.vhosts[0].pages.intercept_statuses = vec![404]; } let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); let route = snapshot.select("localhost", "/").unwrap(); let response = Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from("application custom 404")) .unwrap(); let response = intercept_route_origin_error(response, route, &snapshot); assert_eq!(response.status(), StatusCode::NOT_FOUND); assert!(response .extensions() .get::() .is_some()); assert!(!should_replace_error_page(&response, false)); assert_eq!( response .extensions() .get::() .is_some(), enabled ); if enabled { assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); } let bytes = response.into_body().collect().await.unwrap().to_bytes(); if enabled { assert_eq!(bytes, "RustProxy custom error page"); } else { assert_eq!(bytes, "application custom 404"); } } } } #[cfg(test)] mod graceful_h2_tests { use super::*; use std::fmt; #[derive(Debug)] struct H2DebugError(&'static str); impl fmt::Display for H2DebugError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "client error (SendRequest): {}", self.0) } } impl std::error::Error for H2DebugError {} #[test] fn only_graceful_remote_h2_goaway_is_neutral() { let valid = H2DebugError("GoAway(b\"\", NO_ERROR, Remote)"); let protocol = H2DebugError("GoAway(b\"\", PROTOCOL_ERROR, Remote)"); let local = H2DebugError("GoAway(b\"\", NO_ERROR, Local)"); assert!(is_graceful_h2_goaway(&valid)); assert!(!is_graceful_h2_goaway(&protocol)); assert!(!is_graceful_h2_goaway(&local)); } }