v0.34.0_4

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-18 07:00:34 +02:00
1 parent f20c8da718
commit 5910e6e901
21 files changed
+377 -51

No files matched your search

+26
View File
@@ -166,6 +166,32 @@ pub fn validate_decoded_routing_path(path: &str) -> Result<()> {
Ok(())
}
/// Validate a routing path without allocating for the common already-normalized
/// form. The slow path delegates to `routing_path` to preserve percent decode,
/// UTF-8 validation, slash folding and ambiguity rejection exactly as before.
/// Callers must not use this value as the upstream URL: the raw URI is forwarded.
pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
ensure!(
raw.starts_with('/') && raw.len() <= 8192,
"invalid path length or form"
);
let bytes = raw.as_bytes();
for (index, &byte) in bytes.iter().enumerate() {
if byte == b'%' || (byte == b'/' && index > 0 && bytes[index - 1] == b'/') {
return routing_path(raw).map(std::borrow::Cow::Owned);
}
ensure!(
byte > 32 && byte != 127 && !matches!(byte, b'\\' | b'?' | b'#' | b';'),
"invalid path character"
);
}
ensure!(
!raw.split('/').any(|segment| segment == "." || segment == ".."),
"ambiguous path segments"
);
Ok(std::borrow::Cow::Borrowed(raw))
}
/// Routing/ACL use one decoded representation; upstream still receives the original path.
/// Reject encoded separators, percent (double decoding), controls, ambiguous dot segments,
/// matrix parameters and malformed UTF-8. Repeated literal separators are
+17
View File
@@ -255,4 +255,21 @@ mod tests {
assert!(routing_path(path).is_err(), "{path}");
}
}
#[test]
fn borrowed_route_path_matches_original_validator_for_safe_and_unsafe_targets() {
use std::borrow::Cow;
for value in ["/", "/a/b", "/static/app.js", "/café", "/admin/%61", "/a//b", "/%2e%2e/a", "/..", "/a;secret", "/x%2Fy", "/%00", "/a b", "/bad%", "/a\\b", "not-absolute"] {
let original = routing_path(value);
let borrowed = routing_path_view(value);
assert_eq!(original.is_ok(), borrowed.is_ok(), "{value}");
if let (Ok(normalized), Ok(view)) = (original, borrowed) {
assert_eq!(normalized, view.as_ref(), "{value}");
}
}
assert!(matches!(routing_path_view("/foo/bar").unwrap(), Cow::Borrowed(_)));
assert!(matches!(routing_path_view("/foo//bar").unwrap(), Cow::Owned(_)));
assert!(matches!(routing_path_view("/%61").unwrap(), Cow::Owned(_)));
assert!(routing_path_view(&format!("/{}", "x".repeat(8192))).is_err());
}
}
+3 -3
View File
@@ -5,7 +5,7 @@ impl Cache {
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let n = |value: &AtomicU64| value.load(Ordering::Relaxed);
let hits = n(&self.counters.hits);
let misses = n(&self.counters.misses);
@@ -243,7 +243,7 @@ impl Cache {
count
}
pub fn scope_view(&self, scope: &str) -> ScopeView {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let mut view = ScopeView {
entries: 0,
resident_bytes: 0,
@@ -269,7 +269,7 @@ impl Cache {
self.domain_stats.record_reason(scope, domain, reason);
}
pub fn dashboard(&self, scope: Option<&str>, offset: usize, limit: usize) -> Dashboard {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let revision = store.revision;
let mut groups: BTreeMap<(String, String), DomainView> = self
.domain_stats
+4
View File
@@ -58,6 +58,10 @@ impl DomainStats {
fn record_reason(&self, reason: &'static str) {
// Cache OFF is the highest-volume case; avoid reason map lookup and
// any shared lock/allocator activity for its dashboard reason.
if reason == "cache_disabled" {
self.fast_reasons[0].fetch_add(1, Ordering::Relaxed);
return;
}
if let Some(index) = FAST_REASONS.iter().position(|label| *label == reason) {
self.fast_reasons[index].fetch_add(1, Ordering::Relaxed);
return;
+28 -5
View File
@@ -217,11 +217,34 @@ impl Compiled {
) -> Result<Context> {
let peer_ip = canonical(peer.ip());
let trusted_peer = self.trusted(peer_ip);
let mut ips = if trusted_peer {
chain(incoming).unwrap_or_default()
} else {
Vec::new()
};
if !trusted_peer {
let scheme = if tls { "https" } else { "http" };
let authority_text = authority.to_str()?;
crate::config::request_host(authority_text)?;
let target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = format!("{scheme}://{authority_text}{target}");
let peer_text = peer_ip.to_string();
let peer_header = HeaderValue::from_str(&peer_text)?;
let forwarded = format!(
"for={};by={};proto={scheme};host=\"{authority_text}\"",
node(peer_ip), node(canonical(local.ip()))
);
let mut values = HeaderMap::with_capacity(7);
values.insert("forwarded", forwarded.parse()?);
values.insert("x-forwarded-for", peer_header.clone());
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-uri", target.parse()?);
return Ok(Context {
client_ip: peer_ip,
scheme,
original_url,
values,
});
}
let mut ips = chain(incoming).unwrap_or_default();
ips.push(peer_ip);
// Discard everything left of the first untrusted hop walking from us.
let first = ips.iter().rposition(|ip| !self.trusted(*ip)).unwrap_or(0);
+29
View File
@@ -200,4 +200,33 @@ mod tests {
.unwrap();
assert_eq!(c.values["x-forwarded-for"], "203.0.113.8, 104.16.1.2");
}
#[test]
fn untrusted_fast_path_preserves_ipv6_and_forwarding_metadata() {
let source = Settings {
trusted_proxies: vec!["10.0.0.0/8".into()],
trust_forwarded_proto: true,
}.compile().unwrap();
let mut supplied = HeaderMap::new();
supplied.insert("forwarded", "for=203.0.113.8".parse().unwrap());
supplied.insert("x-forwarded-proto", "http".parse().unwrap());
let context = source.context(
&supplied,
"[2001:db8::5]:5050".parse().unwrap(),
"[2001:db8::10]:8080".parse().unwrap(),
true,
&"app.example:443".parse().unwrap(),
&"/s?q=1".parse().unwrap(),
).unwrap();
assert_eq!(context.client_ip, "2001:db8::5".parse::<IpAddr>().unwrap());
assert_eq!(context.scheme, "https");
assert_eq!(context.original_url, "https://app.example:443/s?q=1");
assert_eq!(context.values["forwarded"],
"for=\"[2001:db8::5]\";by=\"[2001:db8::10]\";proto=https;host=\"app.example:443\"");
assert_eq!(context.values["x-forwarded-for"], "2001:db8::5");
assert_eq!(context.values["x-real-ip"], "2001:db8::5");
assert_eq!(context.values["x-forwarded-proto"], "https");
assert_eq!(context.values["x-forwarded-port"], "8080");
assert_eq!(context.values["x-forwarded-uri"], "/s?q=1");
}
}
+2 -2
View File
@@ -172,9 +172,9 @@ fn response_context(
let authority = effective_host(request, tls).ok()?;
let host = request_host(authority.to_str().ok()?).ok()?;
let frontend = snapshot.frontend(&host, local, tls).cloned();
let route = crate::acl::routing_path(request.uri().path())
let route = crate::acl::routing_path_view(request.uri().path())
.ok()
.and_then(|path| snapshot.select_on_arc(&host, &path, local, tls).cloned());
.and_then(|path| snapshot.select_on_arc(&host, path.as_ref(), local, tls).cloned());
Some((frontend, route))
})();
context.unwrap_or((None, None))
+3 -3
View File
@@ -42,7 +42,7 @@ if let Some(ticket) = &cache_ticket {
uri: uri.clone(),
public_suffix: public_suffix.clone(),
host: host.clone(),
path: path.clone(),
path: path.as_ref().to_owned(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
@@ -79,7 +79,7 @@ if let Some(ticket) = &cache_ticket {
uri: uri.clone(),
public_suffix: public_suffix.clone(),
host: host.clone(),
path: path.clone(),
path: path.as_ref().to_owned(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
@@ -240,7 +240,7 @@ if handshake.is_none()
uri: uri.clone(),
public_suffix: public_suffix.clone(),
host: host.clone(),
path: path.clone(),
path: path.as_ref().to_owned(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
+1 -1
View File
@@ -170,7 +170,7 @@ if let (Some(ticket), Some(plan), Some(transformed)) =
uri: active_uri.clone(),
public_suffix: public_suffix.clone(),
host: host.clone(),
path: path.clone(),
path: path.as_ref().to_owned(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
+1 -1
View File
@@ -7,7 +7,7 @@
}
let service_path = match route.rewritten_path(&path) {
Ok(Some(value)) => value,
Ok(None) => path.clone(),
Ok(None) => path.as_ref().to_owned(),
Err(_) => return error(StatusCode::BAD_REQUEST,
"RustProxy: invalid Web Server rewrite path\n"),
};
+15 -4
View File
@@ -101,7 +101,12 @@ async fn forward_request(
.extensions()
.get::<InternalFallbackRoute>()
.cloned();
let Ok(path) = crate::acl::routing_path(request.uri().path()) else {
// Retain the original URI's shared backing bytes independently of `request`.
// A normalized, already-safe path can then be borrowed for the entire
// 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",
@@ -216,9 +221,15 @@ async fn forward_request(
cloudflare_country = crate::forwarding::cloudflare_country(request.headers(), peer.0);
}
}
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) =
Some(context.client_ip.to_string());
// `observe` already stored the actual transport peer. Most requests
// don't use a trusted forwarding hop, so avoid formatting the same IP
// and taking the per-request mutex a second time. IPv4-mapped peers
// still take this path so their canonical form is correctly logged.
if context.client_ip != peer.0.ip() {
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) =
Some(context.client_ip.to_string());
}
}
// GeoIP stays off the normal request path. Resolve lazily for any vhost or
// location policy that actually needs a country code, or for log capture.
+21 -9
View File
@@ -4,15 +4,27 @@ use http::{
};
/// Remove both standard hop-by-hop fields and fields named by Connection.
pub fn strip_hop_by_hop(headers: &mut HeaderMap) {
let nominated: Vec<HeaderName> = headers
.get_all(CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.filter_map(|v| v.trim().parse().ok())
.collect();
for name in nominated {
headers.remove(name);
// The common HTTP/1.1 "Connection: keep-alive" case has no dynamic
// nominated headers to collect. Keep the general multi-value/token
// parser for all other requests (including maliciously repeated fields).
let only_keep_alive = {
let mut values = headers.get_all(CONNECTION).iter();
values.next().is_some_and(|value| {
value.as_bytes().eq_ignore_ascii_case(b"keep-alive")
&& values.next().is_none()
})
};
if !only_keep_alive {
let nominated: Vec<HeaderName> = headers
.get_all(CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.filter_map(|v| v.trim().parse().ok())
.collect();
for name in nominated {
headers.remove(name);
}
}
for name in [
"connection",
+30
View File
@@ -52,4 +52,34 @@ mod tests {
restore_admin_cookie(&mut h, &admin);
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2; rustproxy_session=secret");
}
#[test]
fn common_keep_alive_does_not_strip_unrelated_headers() {
for value in ["keep-alive", "KEEP-ALIVE", "Keep-Alive"] {
let mut headers = HeaderMap::new();
headers.insert("connection", value.parse().unwrap());
headers.insert("keep-alive", "timeout=2".parse().unwrap());
headers.insert("x-tenant", "alpha".parse().unwrap());
headers.insert("authorization", "Bearer allowed".parse().unwrap());
strip_hop_by_hop(&mut headers);
assert!(!headers.contains_key("connection"));
assert!(!headers.contains_key("keep-alive"));
assert_eq!(headers["x-tenant"], "alpha");
assert_eq!(headers["authorization"], "Bearer allowed");
}
}
#[test]
fn multiple_connection_fields_still_strip_all_nominated_headers() {
let mut headers = HeaderMap::new();
headers.append("connection", "keep-alive".parse().unwrap());
headers.append("connection", "x-hidden, upgrade".parse().unwrap());
headers.insert("x-hidden", "sensitive".parse().unwrap());
headers.insert("x-public", "allowed".parse().unwrap());
headers.insert("upgrade", "websocket".parse().unwrap());
strip_hop_by_hop(&mut headers);
assert!(!headers.contains_key("x-hidden"));
assert!(!headers.contains_key("connection"));
assert!(!headers.contains_key("upgrade"));
assert_eq!(headers["x-public"], "allowed");
}
}
+7 -5
View File
@@ -13,12 +13,14 @@ fn request_id_hash(sequence: u64) -> String {
fn request_id_hex(digest: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(digest.len() * 2);
for &byte in digest {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
// Encode two ASCII bytes per digest byte in one allocation; avoid 64
// separate String::push bounds checks for the 32-byte SHA-256 digest.
let mut encoded = vec![0u8; digest.len() * 2];
for (i, &byte) in digest.iter().enumerate() {
encoded[2 * i] = HEX[(byte >> 4) as usize];
encoded[2 * i + 1] = HEX[(byte & 0x0f) as usize];
}
encoded
String::from_utf8(encoded).expect("hex digits are always valid UTF-8")
}
#[cfg(test)]