0.34 #2

Merged
gru merged 16 commits from 0.34 into master 2026-10-18 12:01:51 +02:00
13 changed files with 382 additions and 106 deletions
Showing only changes of commit dd2502dd11 - Show all commits

No files matched your search

+12 -10
View File
@@ -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
+107 -44
View File
@@ -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<Compiled> {
ensure!(
@@ -231,24 +233,58 @@ impl Compiled {
uri: &Uri,
include_original_url: bool,
) -> Result<Context> {
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<Context> {
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::<Vec<_>>()
.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 {
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)]
+29
View File
@@ -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 }
}
+99
View File
@@ -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::<String>::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();
+28 -12
View File
@@ -157,27 +157,43 @@ fn encoded_origin_path(path: &str) -> Option<String> {
fn response_context(
snapshot: &crate::config::Snapshot,
request: &Request,
) -> (
Option<Arc<crate::config::CompiledFrontend>>,
Option<Arc<crate::config::CompiledRoute>>,
) {
) -> SelectedRouteContext {
let tls = request
.extensions()
.get::<Transport>()
.copied()
.unwrap_or_default()
.tls;
let context = (|| {
let selected = (|| {
let local = request.extensions().get::<LocalAddr>()?.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
+13 -15
View File
@@ -27,19 +27,10 @@ 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() {
@@ -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 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",
);
};
let selected_context = request.extensions().get::<SelectedRouteContext>().cloned();
path
};
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::<Arc<crate::forwarding::PeerFormatting>>()
.map(Arc::as_ref),
host_already_validated,
) {
Ok(context) => context,
Err(_) => {
+43
View File
@@ -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<String>,
}
#[derive(Clone)]
struct SelectedRouteContext {
frontend: Option<Arc<crate::config::CompiledFrontend>>,
route: Option<Arc<crate::config::CompiledRoute>>,
validated: Option<ValidatedRoutingIdentity>,
}
/// 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<SelectedRouteContext>, HeaderValue, String, Option<String>, bool), Response> {
let mut selected_context = request.extensions_mut().remove::<SelectedRouteContext>();
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))
}
+1
View File
@@ -1,4 +1,5 @@
include!("types.rs");
include!("identity.rs");
include!("error_fallback.rs");
include!("cache_helpers.rs");
mod cache_plan;
+26
View File
@@ -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] {
+1 -7
View File
@@ -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<Arc<crate::config::CompiledFrontend>>,
route: Option<Arc<crate::config::CompiledRoute>>,
}
/// Mark responses whose configured outbound policies were already applied.
#[derive(Clone)]
struct ResponseRulesApplied;
#[derive(Clone)]
+6 -7
View File
@@ -37,13 +37,12 @@ fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool
async fn observe(State(state): State<Arc<App>>, 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::<LocalAddr>().map(|v| v.0);
let transport_tls = request
.extensions()
+16 -10
View File
@@ -39,25 +39,29 @@ async fn connection<I>(
}
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<Incoming>| {
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<I>(
);
}
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();
+1 -1
View File
@@ -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;