From dd2502dd114484b00bee6b185c694b7c409668db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 18 Oct 2026 09:24:03 +0200 Subject: [PATCH] v0.34.0_10 --- SHA256SUMS | 22 +++-- src/forwarding.rs | 153 ++++++++++++++++++++--------- src/forwarding/peer_formatting.rs | 29 ++++++ src/forwarding/tests.rs | 99 +++++++++++++++++++ src/proxy/cache_helpers.rs | 40 +++++--- src/proxy/forward_entry.rs | 40 ++++---- src/proxy/identity.rs | 43 ++++++++ src/proxy/mod.rs | 1 + src/proxy/tests/authority_tests.rs | 26 +++++ src/proxy/types.rs | 8 +- src/proxy/upstream_helpers.rs | 13 ++- src/server/http1_connection.rs | 26 +++-- src/server/listeners.rs | 2 +- 13 files changed, 389 insertions(+), 113 deletions(-) create mode 100644 src/forwarding/peer_formatting.rs create mode 100644 src/proxy/identity.rs diff --git a/SHA256SUMS b/SHA256SUMS index 1e07b47..b30722e 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -326,8 +326,9 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs 4c2c988191a97f81568a8df64af772463d010844f1abf327871901cdb32d4336 src/docker/model.rs 5f4610c59b2d4ff6a205a2d9ae11bc84be689b2a68302459f546b3f43e53593c src/docker/reconcile.rs a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs -ae46f4a971119d300d79fa506ed116054313334484546ba2f411e893e4e7586c src/forwarding.rs -4622bf34a316fca5dd524d229652a1fef8b1828f34e2c3e9bc0e35fe03b6a393 src/forwarding/tests.rs +526ae9c7f777b0442ced235237ded659e6e9457aaccc68a1fd5af80b58d3c567 src/forwarding.rs +4be1a43a911e61ffccad01f0ca71913e5e1ee682d83591d02d478baeb900b54e src/forwarding/peer_formatting.rs +f3c1de0f428b008ed830ca410bfa5b4ca489a0861275b8541dbfa6217d16cb95 src/forwarding/tests.rs af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs 80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs 06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs @@ -375,7 +376,7 @@ e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs 2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs 66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.rs -818dcfefa1c40d6a722dacf53c7f45c53141240f1c1913cfa651e19dd2718526 src/proxy/cache_helpers.rs +7586fc88e10381eb97f3be6a66f355d16088cc9481f50c193eee8fe169c4fc8e src/proxy/cache_helpers.rs aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs 676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs 3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs @@ -387,20 +388,21 @@ cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forw 0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs 3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs -2c681e48d6be7db784b65cc186bced40f1b6c777d97a4d222c653008b8406a31 src/proxy/forward_entry.rs +3b9f27445c637dbf093b07a5b4e77cbff4df2bab2ad622bb87b6772dab245ec6 src/proxy/forward_entry.rs 9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/headers.rs 0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs -3f25429418941eb72c3847125340db74684ad316eb278ecf658953026fd41f4f src/proxy/mod.rs +30a01e70328e8e5d544d86e3df540c0078a17de33376d389b5ad06e351477634 src/proxy/identity.rs +050c66311f71cac75c1d4c8547351a1d6f1111ed0db128691366fdd73647e90a src/proxy/mod.rs 55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs 2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs -3db4188e2cc6d69fdff1b817cc15d0e6b18bda18d4e4729d37c05736c7d8cad6 src/proxy/tests/authority_tests.rs +94a50c0bf147565098e06672df3625e44d5af34ef1f12fd355d82cc7a72cf033 src/proxy/tests/authority_tests.rs 1aa9bbbfb2659facdaeaf185e11221b1c8cd5fe6e23c7c1dacd3aa55e3db4fcb src/proxy/tests/cache_fast_path_tests.rs 0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs -a74815a0b46d1c94ffbf8cc3a8bbbc967daa4aec0981139d2d92624c10553026 src/proxy/types.rs -1db11efc7c35b8654f88f3176d9bca341c3e625d055e6c7e888f953c04b5f573 src/proxy/upstream_helpers.rs +fecc1c7ffff740caa7168370e59234a17b9836a6dce6d08b770ee9648c22709b src/proxy/types.rs +0f9655d4ca1de35dcd7d5807d146cb3127a78b993beaeec7deec30e2f4040bb8 src/proxy/upstream_helpers.rs 2d9142e3ae95895b5271b4009ab48236a3a2074ce51179c021193b35777560e4 src/proxy/web_cache_revalidation.rs d7b4b809b12ee89293df45e9a64b1c9981fb3f89deaab3762f4a3ad3992dd7b1 src/proxy/web_cache_revalidation/tests.rs 69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs @@ -425,8 +427,8 @@ ee2050b9f8efb45a209c91d76043a1ba29c4289978896aaf7e7d344d48fdc369 src/runtime/te 0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs 98c52aa3b50d2e211ab752ccaf353c9a8c1500a87f9c1684b5dd0ba9d10e4619 src/server.rs f2f6196632b2fdb2444189624f961edaf5bdfff57f700b0207b57e2491c1daa5 src/server/http.rs -050c48ea1127aa87dba92c074ab84e8346e65dbe253621ea53f8ca2f68d791ab src/server/http1_connection.rs -8c6ca5eaffb9acab57199d0349722703b903cfff3752baad2cf2596bd51188de src/server/listeners.rs +164384468c933320abf1c656f1e07a687aa869f5794d3e5a9b429885b5272266 src/server/http1_connection.rs +cd33fa634a5f1ee1d425e9e78269f9575b2e324967115383c80c6883c878ccfe src/server/listeners.rs 4424a99b0b688d1119ee25626778b4804aedfaa0799a4a9bc0ee99bc5c7ba96b src/server/sendfile.rs 71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs diff --git a/src/forwarding.rs b/src/forwarding.rs index 40a8e00..16450f4 100644 --- a/src/forwarding.rs +++ b/src/forwarding.rs @@ -28,6 +28,8 @@ pub struct Context { pub original_url: String, values: HeaderMap, } +include!("forwarding/peer_formatting.rs"); + impl Settings { pub fn compile(&self) -> Result { ensure!( @@ -231,24 +233,58 @@ impl Compiled { uri: &Uri, include_original_url: bool, ) -> Result { - let peer_ip = canonical(peer.ip()); + self.context_with_peer( + incoming, peer, local, tls, authority, uri, include_original_url, + None, false, + ) + } + + /// Only the host/path validation and formatting data from the same request + /// and accepted connection are reused. Dynamic trust and XFF are evaluated + /// on every request, including after an in-place configuration reload. + #[allow(clippy::too_many_arguments)] + pub(crate) fn context_with_peer( + &self, + incoming: &HeaderMap, + peer: SocketAddr, + local: SocketAddr, + tls: bool, + authority: &HeaderValue, + uri: &Uri, + include_original_url: bool, + cached: Option<&PeerFormatting>, + validated_host: bool, + ) -> Result { + let cached = cached.filter(|cached| cached.peer == peer && cached.local == local); + let peer_ip = cached.map_or_else(|| canonical(peer.ip()), |cached| cached.peer_ip); let trusted_peer = self.trusted(peer_ip); if !trusted_peer { let scheme = if tls { "https" } else { "http" }; let authority_text = authority.to_str()?; - crate::config::request_host(authority_text)?; + if !validated_host { crate::config::request_host(authority_text)?; } let target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/"); let original_url = if include_original_url { format!("{scheme}://{authority_text}{target}") } else { String::new() }; - let peer_text = peer_ip.to_string(); - let peer_header = HeaderValue::from_str(&peer_text)?; - let peer_node = if peer_ip.is_ipv4() { None } else { Some(node(peer_ip)) }; + let peer_text = cached.is_none().then(|| peer_ip.to_string()); + let ipv6_node = (cached.is_none() && peer_ip.is_ipv6()).then(|| node(peer_ip)); + let peer_header = if let Some(cached) = cached { + cached.peer_header.clone() + } else { + HeaderValue::from_str(peer_text.as_deref().unwrap_or(""))? + }; + let by_text = cached.is_none().then(|| node(canonical(local.ip()))); + let peer_node = cached.map_or_else( + || if peer_ip.is_ipv4() { peer_text.as_deref().unwrap_or("") } + else { ipv6_node.as_deref().unwrap_or("") }, + |cached| cached.peer_node.as_str(), + ); + let by_node = cached.map_or_else(|| by_text.as_deref().unwrap_or(""), + |cached| cached.by_node.as_str()); let forwarded = format!( - "for={};by={};proto={scheme};host=\"{authority_text}\"", - peer_node.as_deref().unwrap_or(&peer_text), node(canonical(local.ip())) + "for={peer_node};by={by_node};proto={scheme};host=\"{authority_text}\"" ); let mut values = HeaderMap::with_capacity(7); values.insert("forwarded", forwarded.parse()?); @@ -256,7 +292,11 @@ impl Compiled { values.insert("x-real-ip", peer_header); values.insert("x-forwarded-host", authority.clone()); values.insert("x-forwarded-proto", HeaderValue::from_static(scheme)); - values.insert("x-forwarded-port", local.port().to_string().parse()?); + values.insert("x-forwarded-port", if let Some(cached) = cached { + cached.port_header.clone() + } else { + local.port().to_string().parse()? + }); values.insert("x-forwarded-uri", target.parse()?); return Ok(Context { client_ip: peer_ip, @@ -287,29 +327,39 @@ impl Compiled { } let authority_text = authority.to_str()?; // Validate before inserting authority into Forwarded's quoted host field. - crate::config::request_host(authority_text)?; + if !validated_host { crate::config::request_host(authority_text)?; } let original_target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/"); let original_url = if include_original_url { format!("{scheme}://{authority_text}{original_target}") } else { String::new() }; - let mut forwarded: Vec<_> = ips.iter().map(|ip| format!("for={}", node(*ip))).collect(); - let last = forwarded.last_mut().expect("actual peer is always present"); - last.push_str(&format!( - ";by={};proto={scheme};host=\"{authority_text}\"", - node(canonical(local.ip())) - )); - let mut values = HeaderMap::new(); - values.insert("forwarded", forwarded.join(", ").parse()?); - values.insert( - "x-forwarded-for", - ips.iter() - .map(ToString::to_string) - .collect::>() - .join(", ") - .parse()?, - ); + use std::fmt::Write as _; + let mut forwarded = String::with_capacity(ips.len() * 52 + 80 + authority_text.len()); + let mut xff = String::with_capacity(ips.len() * 44); + for (index, ip) in ips.iter().enumerate() { + if index > 0 { forwarded.push_str(", "); xff.push_str(", "); } + forwarded.push_str("for="); + if index + 1 == ips.len() { + if let Some(cached) = cached { forwarded.push_str(&cached.peer_node); } + else { forwarded.push_str(&node(*ip)); } + } else { + forwarded.push_str(&node(*ip)); + } + if index + 1 == ips.len() { + if let Some(cached) = cached { xff.push_str(cached.peer_header.to_str()?); } + else { write!(&mut xff, "{ip}")?; } + } else { + write!(&mut xff, "{ip}")?; + } + } + forwarded.push_str(";by="); + if let Some(cached) = cached { forwarded.push_str(&cached.by_node); } + else { forwarded.push_str(&node(canonical(local.ip()))); } + write!(&mut forwarded, ";proto={scheme};host=\"{authority_text}\"")?; + let mut values = HeaderMap::with_capacity(7); + values.insert("forwarded", forwarded.parse()?); + values.insert("x-forwarded-for", xff.parse()?); values.insert("x-real-ip", client_ip.to_string().parse()?); values.insert("x-forwarded-host", authority.clone()); values.insert("x-forwarded-proto", HeaderValue::from_static(scheme)); @@ -321,7 +371,11 @@ impl Compiled { } else { local.port() }; - values.insert("x-forwarded-port", port.to_string().parse()?); + values.insert("x-forwarded-port", if cached.is_some() && !(trusted_peer && self.trust_proto) { + cached.expect("tested above").port_header.clone() + } else { + port.to_string().parse()? + }); values.insert("x-forwarded-uri", original_target.parse()?); Ok(Context { client_ip, @@ -371,21 +425,35 @@ impl Context { Ok(()) } pub fn apply(&self, headers: &mut HeaderMap) { - let remove: Vec<_> = headers - .keys() - .filter(|name| { - name.as_str().starts_with("x-forwarded-") - || matches!( - name.as_str(), - "forwarded" | "x-real-ip" | "x-original-url" | "x-original-method" - ) - }) - .cloned() - .collect(); - for name in remove { - headers.remove(name); + self.apply_clean(headers, true); + } + fn apply_clean(&self, headers: &mut HeaderMap, include_client_ip: bool) { + // HeaderMap::insert replaces all values for a known canonical name. + // Remove unknown/spoofed forwarding names while retaining the valid ones. + // The normal request has no extra spoofed header names. Allocate + // a removal list only for requests which actually need sanitization. + let mut remove = None; + for name in headers.keys() { + let text = name.as_str(); + if matches!(text, "x-original-url" | "x-original-method") + || (text.starts_with("x-forwarded-") + && !self.values.contains_key(text)) + { + remove.get_or_insert_with(Vec::new).push(name.clone()); + } + } + if let Some(remove) = remove { + for name in remove { headers.remove(name); } + } + if !include_client_ip { + for name in ["forwarded", "x-forwarded-for", "x-real-ip"] { + headers.remove(name); + } } for (name, value) in &self.values { + if !include_client_ip && matches!(name.as_str(), "forwarded" | "x-forwarded-for" | "x-real-ip") { + continue; + } headers.insert(name.clone(), value.clone()); } } @@ -395,12 +463,7 @@ impl Context { /// the visitor address so frameworks such as ProxyFix see the RustProxy socket peer. /// Authentication verifiers use `apply` directly and still receive the real client IP. pub fn apply_upstream(&self, headers: &mut HeaderMap, forward_client_ip: bool) { - self.apply(headers); - if !forward_client_ip { - headers.remove("forwarded"); - headers.remove("x-forwarded-for"); - headers.remove("x-real-ip"); - } + self.apply_clean(headers, forward_client_ip); } } #[cfg(test)] diff --git a/src/forwarding/peer_formatting.rs b/src/forwarding/peer_formatting.rs new file mode 100644 index 0000000..685db12 --- /dev/null +++ b/src/forwarding/peer_formatting.rs @@ -0,0 +1,29 @@ +/// Immutable formatting shared by all HTTP/1 requests on one accepted socket. +/// No routing or trust decision is cached: those follow the current snapshot. +#[derive(Debug)] +pub(crate) struct PeerFormatting { + peer: SocketAddr, + local: SocketAddr, + peer_ip: IpAddr, + peer_header: HeaderValue, + peer_node: String, + by_node: String, + port_header: HeaderValue, +} +impl PeerFormatting { + pub(crate) fn new(peer: SocketAddr, local: SocketAddr) -> Self { + let peer_ip = canonical(peer.ip()); + let peer_text = peer_ip.to_string(); + Self { + peer, + local, + peer_ip, + peer_header: HeaderValue::from_str(&peer_text).expect("IP text is a valid header"), + peer_node: if peer_ip.is_ipv4() { peer_text } else { node(peer_ip) }, + by_node: node(canonical(local.ip())), + port_header: HeaderValue::from_str(&local.port().to_string()) + .expect("TCP port is a valid header"), + } + } + fn peer_header(&self) -> &HeaderValue { &self.peer_header } +} diff --git a/src/forwarding/tests.rs b/src/forwarding/tests.rs index e141b56..607dce8 100644 --- a/src/forwarding/tests.rs +++ b/src/forwarding/tests.rs @@ -14,6 +14,105 @@ mod tests { ) .unwrap() } + #[test] + fn socket_cached_forwarding_matches_uncached_for_ipv4_ipv6_and_trusted_hops() { + for (peer, local, networks) in [ + ("192.0.2.51:32000", "127.0.0.1:8080", Vec::::new()), + ("[2001:db8::8]:32100", "[2001:db8::1]:8443", Vec::new()), + ("10.2.3.4:4123", "10.0.0.1:8443", vec!["10.0.0.0/8".to_owned()]), + ] { + let peer: SocketAddr = peer.parse().unwrap(); + let local: SocketAddr = local.parse().unwrap(); + let formatter = PeerFormatting::new(peer, local); + let compiled = Settings { + trusted_proxies: networks, + trust_forwarded_proto: true, + }.compile().unwrap(); + let mut incoming = HeaderMap::new(); + incoming.insert("x-forwarded-for", "198.51.100.8, 10.3.2.1".parse().unwrap()); + incoming.insert("x-forwarded-proto", "https".parse().unwrap()); + let authority = HeaderValue::from_static("app.example:8443"); + let uri: Uri = "/some%20file?q=4".parse().unwrap(); + let plain = compiled.context_with_original_url( + &incoming, peer, local, true, &authority, &uri, false + ).unwrap(); + let cached = compiled.context_with_peer( + &incoming, peer, local, true, &authority, &uri, false, + Some(&formatter), true, + ).unwrap(); + assert_eq!(cached.client_ip, plain.client_ip); + assert_eq!(cached.scheme, plain.scheme); + assert_eq!(cached.original_url, plain.original_url); + assert_eq!(cached.values, plain.values); + assert_eq!(formatter.peer_header().to_str().unwrap(), + crate::vhost::canonical_ip(peer.ip()).to_string()); + for include_ip in [false, true] { + let mut a = incoming.clone(); + let mut b = incoming.clone(); + a.insert("x-forwarded-evil", "spoofed".parse().unwrap()); + b.insert("x-forwarded-evil", "spoofed".parse().unwrap()); + a.append("x-forwarded-for", "1.2.3.4".parse().unwrap()); + b.append("x-forwarded-for", "1.2.3.4".parse().unwrap()); + plain.apply_upstream(&mut a, include_ip); + cached.apply_upstream(&mut b, include_ip); + assert_eq!(a, b); + assert!(!b.contains_key("x-forwarded-evil")); + assert_eq!(b.contains_key("x-real-ip"), include_ip); + assert_eq!(b.contains_key("forwarded"), include_ip); + } + } + } + + #[test] + fn canonical_forwarding_headers_replace_duplicate_untrusted_values() { + let compiled = Settings::default().compile().unwrap(); + let peer: SocketAddr = "192.0.2.9:4000".parse().unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let value = compiled.context_with_peer( + &HeaderMap::new(), peer, local, false, + &HeaderValue::from_static("app.example"), + &"/".parse().unwrap(), false, + Some(&PeerFormatting::new(peer, local)), false, + ).unwrap(); + let mut headers = HeaderMap::new(); + for (name, val) in [ + ("forwarded", "for=203.0.113.1"), + ("forwarded", "for=203.0.113.2"), + ("x-forwarded-for", "203.0.113.3"), + ("x-forwarded-for", "203.0.113.4"), + ("x-real-ip", "203.0.113.5"), + ("x-forwarded-privilege", "admin"), + ("x-original-method", "DELETE"), + ] { + headers.append(name, val.parse().unwrap()); + } + value.apply(&mut headers); + assert_eq!(headers.get_all("forwarded").iter().count(), 1); + assert_eq!(headers.get_all("x-forwarded-for").iter().count(), 1); + assert_eq!(headers["x-forwarded-for"], "192.0.2.9"); + assert!(!headers.contains_key("x-forwarded-privilege")); + assert!(!headers.contains_key("x-original-method")); + } + + #[test] + fn peer_cache_can_never_override_the_actual_socket() { + let compiled = Settings::default().compile().unwrap(); + let different = PeerFormatting::new( + "192.0.2.1:9001".parse().unwrap(), + "192.0.2.2:8080".parse().unwrap(), + ); + let actual: SocketAddr = "198.51.100.6:4000".parse().unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let authority = HeaderValue::from_static("app.example"); + let uri: Uri = "/safe".parse().unwrap(); + let value = compiled.context_with_peer( + &HeaderMap::new(), actual, local, false, + &authority, &uri, false, Some(&different), false, + ).unwrap(); + assert_eq!(value.client_ip, actual.ip()); + assert_eq!(value.values["x-forwarded-for"], "198.51.100.6"); + } + #[test] fn untrusted_headers_are_rebuilt_not_appended() { let mut headers = HeaderMap::new(); diff --git a/src/proxy/cache_helpers.rs b/src/proxy/cache_helpers.rs index e78dd65..bec01c1 100644 --- a/src/proxy/cache_helpers.rs +++ b/src/proxy/cache_helpers.rs @@ -157,27 +157,43 @@ fn encoded_origin_path(path: &str) -> Option { fn response_context( snapshot: &crate::config::Snapshot, request: &Request, -) -> ( - Option>, - Option>, -) { +) -> SelectedRouteContext { let tls = request .extensions() .get::() .copied() .unwrap_or_default() .tls; - let context = (|| { + let selected = (|| { let local = request.extensions().get::()?.0; - let authority = effective_host(request, tls).ok()?; - let host = request_host(authority.to_str().ok()?).ok()?; + let original_host = effective_host(request, tls).ok()?; + let host = request_host(original_host.to_str().ok()?).ok()?; let frontend = snapshot.frontend(&host, local, tls).cloned(); - let route = crate::acl::routing_path_view(request.uri().path()) - .ok() - .and_then(|path| snapshot.select_on_arc(&host, path.as_ref(), local, tls).cloned()); - Some((frontend, route)) + let path = crate::acl::routing_path_view(request.uri().path()).ok(); + let route = path.as_ref().and_then(|path| { + snapshot.select_on_arc(&host, path.as_ref(), local, tls).cloned() + }); + // An ordinary path borrows the original URI in forward_request. Only + // a decoded path needs to be moved between middleware and forwarding. + let decoded_path = path.and_then(|path| match path { + std::borrow::Cow::Borrowed(_) => None, + std::borrow::Cow::Owned(path) => Some(path), + }); + Some(SelectedRouteContext { + frontend, + route, + validated: Some(ValidatedRoutingIdentity { + original_host, + host, + decoded_path, + }), + }) })(); - context.unwrap_or((None, None)) + selected.unwrap_or(SelectedRouteContext { + frontend: None, + route: None, + validated: None, + }) } /// Reserved administration-session cookies may cross the proxy boundary only diff --git a/src/proxy/forward_entry.rs b/src/proxy/forward_entry.rs index 7c70e1b..8ce7826 100644 --- a/src/proxy/forward_entry.rs +++ b/src/proxy/forward_entry.rs @@ -27,20 +27,11 @@ async fn forward_request( .copied() .unwrap_or_default() .tls; - let Ok(original_host) = effective_host(&request, tls) else { - return error( - StatusCode::BAD_REQUEST, - "RustProxy: invalid or conflicting request authority\n", - ); - }; - let Ok(host) = original_host - .to_str() - .ok() - .and_then(|v| request_host(v).ok()) - .ok_or(()) - else { - return error(StatusCode::BAD_REQUEST, "RustProxy: invalid hostname\n"); - }; + let (selected_context, original_host, host, decoded_path, host_already_validated) = + match take_routing_identity(&mut request, tls) { + Ok(identity) => identity, + Err(response) => return response, + }; let handshake = if request.method() == Method::CONNECT { let protocol = match request.version() { Version::HTTP_2 => websocket::h2_protocol(&request), @@ -106,13 +97,17 @@ async fn forward_request( // forwarding operation, even after the request URI/headers are rewritten. // Encoded/slash-folded paths still allocate via the strict decoder. let routing_uri = request.uri().clone(); - let Ok(path) = crate::acl::routing_path_view(routing_uri.path()) else { - return error( - StatusCode::BAD_REQUEST, - "RustProxy: ambiguous request path\n", - ); + let path = if let Some(path) = decoded_path.as_deref() { + std::borrow::Cow::Borrowed(path) + } else { + let Ok(path) = crate::acl::routing_path_view(routing_uri.path()) else { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: ambiguous request path\n", + ); + }; + path }; - let selected_context = request.extensions().get::().cloned(); let frontend = if let Some(selection) = &selected_context { selection.frontend.as_ref() } else { @@ -193,7 +188,7 @@ async fn forward_request( } } } - let mut context = match snapshot.forwarding.context_with_original_url( + let mut context = match snapshot.forwarding.context_with_peer( request.headers(), peer.0, local.0, @@ -201,6 +196,9 @@ async fn forward_request( &original_host, request.uri(), frontend.is_some_and(|frontend| frontend.tinyauth.is_some()), + request.extensions().get::>() + .map(Arc::as_ref), + host_already_validated, ) { Ok(context) => context, Err(_) => { diff --git a/src/proxy/identity.rs b/src/proxy/identity.rs new file mode 100644 index 0000000..e90decf --- /dev/null +++ b/src/proxy/identity.rs @@ -0,0 +1,43 @@ +// Observation has already validated routing identity. Transfer it to the +// forward handler instead of repeating hostname parsing and percent decoding. +#[derive(Clone)] +struct ValidatedRoutingIdentity { + original_host: HeaderValue, + host: String, + decoded_path: Option, +} +#[derive(Clone)] +struct SelectedRouteContext { + frontend: Option>, + route: Option>, + validated: Option, +} +/// Transfer middleware-validated identity without cloning strings. Internal +/// fallback dispatches still use the complete authority validation path. +fn take_routing_identity( + request: &mut Request, + tls: bool, +) -> Result<(Option, HeaderValue, String, Option, bool), Response> { + let mut selected_context = request.extensions_mut().remove::(); + let validated = selected_context.as_mut().and_then(|context| context.validated.take()); + let host_already_validated = validated.is_some(); + let (original_host, host, decoded_path) = match validated { + Some(validated) => ( + validated.original_host, + validated.host, + validated.decoded_path, + ), + None => { + let original_host = effective_host(request, tls).map_err(|_| error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid or conflicting request authority\n", + ))?; + let host = original_host.to_str().ok() + .and_then(|v| request_host(v).ok()) + .ok_or_else(|| error(StatusCode::BAD_REQUEST, + "RustProxy: invalid hostname\n"))?; + (original_host, host, None) + } + }; + Ok((selected_context, original_host, host, decoded_path, host_already_validated)) +} diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index 3788a26..b743760 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -1,4 +1,5 @@ include!("types.rs"); +include!("identity.rs"); include!("error_fallback.rs"); include!("cache_helpers.rs"); mod cache_plan; diff --git a/src/proxy/tests/authority_tests.rs b/src/proxy/tests/authority_tests.rs index 340a623..7bf531f 100644 --- a/src/proxy/tests/authority_tests.rs +++ b/src/proxy/tests/authority_tests.rs @@ -11,6 +11,32 @@ mod authority_tests { } } + #[test] + fn observation_transfers_validated_host_and_decoded_path() { + let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); + for (raw, normalized) in [ + ("/assets/app.js?rev=5", None), + ("/assets/%61pp.js?rev=5", Some("/assets/app.js")), + ("/assets//app.js", Some("/assets/app.js")), + ] { + let mut request = Request::builder().method(Method::GET).uri(raw) + .header(HOST, "localhost").body(Body::empty()).unwrap(); + request.extensions_mut().insert(LocalAddr("127.0.0.1:8080".parse().unwrap())); + let context = response_context(&snapshot, &request); + let validated = context.validated.unwrap(); + assert_eq!(validated.host, "localhost"); + assert_eq!(validated.original_host, "localhost"); + assert_eq!(validated.decoded_path.as_deref(), normalized); + } + let mut invalid = Request::builder().uri("/ok") + .header(HOST, "localhost").header(HOST, "another.example") + .body(Body::empty()).unwrap(); + invalid.extensions_mut().insert(LocalAddr("127.0.0.1:8080".parse().unwrap())); + assert!(response_context(&snapshot, &invalid).validated.is_none()); + assert!(crate::acl::routing_path_view("/encoded%2fslash").is_err()); + } + #[test] fn retry_is_limited_to_safe_methods() { for method in [Method::GET, Method::HEAD, Method::OPTIONS] { diff --git a/src/proxy/types.rs b/src/proxy/types.rs index 49656cd..cd43e0f 100644 --- a/src/proxy/types.rs +++ b/src/proxy/types.rs @@ -21,13 +21,7 @@ use http::{ }; use http_body::Body as HttpBody; use http_body_util::BodyExt; -/// A route resolved against the immutable snapshot in the observation layer. -/// The internal error-fallback dispatcher must resolve its target independently. -#[derive(Clone)] -struct SelectedRouteContext { - frontend: Option>, - route: Option>, -} +/// Mark responses whose configured outbound policies were already applied. #[derive(Clone)] struct ResponseRulesApplied; #[derive(Clone)] diff --git a/src/proxy/upstream_helpers.rs b/src/proxy/upstream_helpers.rs index 9885972..d9ea996 100644 --- a/src/proxy/upstream_helpers.rs +++ b/src/proxy/upstream_helpers.rs @@ -37,13 +37,12 @@ fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool async fn observe(State(state): State>, mut request: Request, next: Next) -> Response { let snapshot = state.config.load_full(); - let (frontend_context, route_context) = response_context(&snapshot, &request); - // The same snapshot is inserted into the request. No second host/regex - // selection or expensive full CompiledRoute clone is necessary in forward(). - request.extensions_mut().insert(SelectedRouteContext { - frontend: frontend_context.clone(), - route: route_context.clone(), - }); + let selected = response_context(&snapshot, &request); + let frontend_context = selected.frontend.clone(); + let route_context = selected.route.clone(); + // The same validated request identity and snapshot are transferred to + // forward(); no second hostname parsing or encoded-path decoding. + request.extensions_mut().insert(selected); let local = request.extensions().get::().map(|v| v.0); let transport_tls = request .extensions() diff --git a/src/server/http1_connection.rs b/src/server/http1_connection.rs index 7b1f324..e3635e0 100644 --- a/src/server/http1_connection.rs +++ b/src/server/http1_connection.rs @@ -39,25 +39,29 @@ async fn connection( } let request_stop = stop.clone(); let service_frontend_state = frontend_state.clone(); + let formatted_peer = Arc::new(crate::forwarding::PeerFormatting::new(peer, local)); let service = service_fn(move |mut request: Request| { - let router = router.clone(); + let mut router = router.clone(); let request_stop = request_stop.clone(); let request_lease = lease.clone(); let upgrade_state = service_frontend_state.clone(); + let formatted_peer = formatted_peer.clone(); #[cfg(target_os = "linux")] let sendfile_slots = sendfile_slots.clone(); async move { - request.extensions_mut().insert(PeerAddr(peer)); - request.extensions_mut().insert(LocalAddr(local)); - request.extensions_mut().insert(transport); - request.extensions_mut().insert(request_lease.clone()); + // Reuse immutable per-socket formatting and insert extensions in + // one pass. Configuration and trust policies are still per-request. + let extensions = request.extensions_mut(); + extensions.insert(PeerAddr(peer)); + extensions.insert(LocalAddr(local)); + extensions.insert(transport); + extensions.insert(formatted_peer); + extensions.insert(request_lease.clone()); #[cfg(target_os = "linux")] if let Some(slots) = sendfile_slots { - request.extensions_mut().insert(slots); + extensions.insert(slots); } - request - .extensions_mut() - .insert(ServerShutdown(request_stop.clone())); + extensions.insert(ServerShutdown(request_stop.clone())); if h2c_attempt(&request) { if !h2c_upgrade { @@ -175,7 +179,9 @@ async fn connection( ); } - router.oneshot(request.map(Body::new)).await + // Axum Router is always ready; avoid the per-request Oneshot + // readiness adapter while retaining the same middleware stack. + router.call(request.map(Body::new)).await } }); let mut builder = http1::Builder::new(); diff --git a/src/server/listeners.rs b/src/server/listeners.rs index f1ea0b9..f50e06b 100644 --- a/src/server/listeners.rs +++ b/src/server/listeners.rs @@ -30,7 +30,7 @@ use tokio::{ task::JoinSet, }; use tokio_util::sync::CancellationToken; -use tower::ServiceExt; +use tower::{Service, ServiceExt}; const HTTP2_PRIOR_KNOWLEDGE_PREFACE: &[u8; 24] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; const H2_FRAME_HEADER_LEN: usize = 9;