0.34.13_cleaup_code

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-26 14:14:41 +01:00
1 parent ec46a96a29
commit 5c2e50ec06
87 files changed
+4890 -1338

No files matched your search

+13 -6
View File
@@ -50,9 +50,8 @@ mod tests {
async fn optimized_empty_request_keeps_exchange_until_response() {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let body = super::super::wrap_observed_request(
Body::empty(), &exchange, Duration::from_secs(1),
);
let body =
super::super::wrap_observed_request(Body::empty(), &exchange, Duration::from_secs(1));
assert!(body.is_end_stream());
drop(body);
assert_eq!(slots.available_permits(), 0);
@@ -66,7 +65,9 @@ mod tests {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let body = super::super::wrap_observed_request(
Body::from("payload"), &exchange, Duration::from_secs(1),
Body::from("payload"),
&exchange,
Duration::from_secs(1),
);
assert!(!body.is_end_stream());
drop(exchange);
@@ -272,7 +273,10 @@ mod tests {
assert_eq!(entries[0].route.as_deref(), Some("route-1"));
assert_eq!(entries[0].client_ip.as_deref(), Some("198.51.100.9"));
assert_eq!(entries[0].country.as_deref(), Some("DE"));
assert_eq!(entries[0].upstream_error.as_deref(), Some("upstream closed"));
assert_eq!(
entries[0].upstream_error.as_deref(),
Some("upstream closed")
);
assert_eq!(entries[0].cache_reason, "cache_disabled");
assert_eq!(entries[0].websocket_error, Some("upstream_http_error"));
assert_eq!(entries[0].h3_reset_code, Some("h3_general_protocol_error"));
@@ -296,7 +300,10 @@ mod tests {
assert_eq!(entries[0].host, "localhost");
assert_eq!(entries[0].path, "/echo");
assert_eq!(entries[0].request_id.len(), 64);
assert_eq!(ExchangeSource::name(ExchangeSource::ReverseProxy as u8), "reverse_proxy");
assert_eq!(
ExchangeSource::name(ExchangeSource::ReverseProxy as u8),
"reverse_proxy"
);
assert_eq!(read_effective(0), None);
assert_eq!(read_effective(1), Some(false));
assert_eq!(read_effective(2), Some(true));
+4 -1
View File
@@ -268,7 +268,10 @@ fn finish_headers(
active: &[crate::vhost::Listener],
) {
// Global security headers form a baseline; explicitly set vhost values take precedence.
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
if let Some(frontend) = frontend {
frontend.security.apply(response.headers_mut(), tls);
}
+103 -29
View File
@@ -9,19 +9,28 @@ fn cache_http_invalidation_request(
scheme: &str,
request: &Request,
) -> Result<crate::cache::InvalidationRequest, &'static str> {
let mut lengths = request.headers().get_all(http::header::CONTENT_LENGTH).iter();
let mut lengths = request
.headers()
.get_all(http::header::CONTENT_LENGTH)
.iter();
if let Some(length) = lengths.next() {
if lengths.next().is_some() || length.as_bytes() != b"0" {
return Err("BAN/PURGE must not contain a request body");
}
}
if request.headers().contains_key("upgrade")
|| request.headers().contains_key(http::header::TRANSFER_ENCODING)
|| request
.headers()
.contains_key(http::header::TRANSFER_ENCODING)
|| !request.body().is_end_stream()
{
return Err("BAN/PURGE must not contain a request body or upgrade");
}
let target = request.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
let target = request
.uri()
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
if target.len() > 4096 || !target.starts_with('/') {
return Err("invalid BAN/PURGE target");
}
@@ -35,23 +44,40 @@ fn cache_http_invalidation_request(
} else {
crate::cache::InvalidationSelector::Exact
};
if request.headers().get_all("x-rustproxy-ban-ttl").iter().count() > 1
|| request.headers().get_all("x-rustproxy-confirm-wide").iter().count() > 1
if request
.headers()
.get_all("x-rustproxy-ban-ttl")
.iter()
.count()
> 1
|| request
.headers()
.get_all("x-rustproxy-confirm-wide")
.iter()
.count()
> 1
{
return Err("duplicate BAN/PURGE control header");
}
let mut ttl_seconds = None;
if method.as_str() == "BAN" {
if let Some(value) = request.headers().get("x-rustproxy-ban-ttl") {
let seconds = value.to_str().ok().and_then(|v| v.parse::<u64>().ok())
let seconds = value
.to_str()
.ok()
.and_then(|v| v.parse::<u64>().ok())
.ok_or("invalid BAN TTL")?;
if !(1..=604_800).contains(&seconds) { return Err("BAN TTL must be 1..604800"); }
if !(1..=604_800).contains(&seconds) {
return Err("BAN TTL must be 1..604800");
}
ttl_seconds = Some(seconds);
}
} else if request.headers().contains_key("x-rustproxy-ban-ttl") {
return Err("BAN TTL is only supported for BAN");
}
let confirm_wide = request.headers().get("x-rustproxy-confirm-wide")
let confirm_wide = request
.headers()
.get("x-rustproxy-confirm-wide")
.is_some_and(|value| value == "1");
if target == "/" && method.as_str() == "BAN" && !confirm_wide {
return Err("BAN / requires X-RustProxy-Confirm-Wide: 1");
@@ -71,9 +97,13 @@ fn cache_http_invalidation_request(
host.to_owned()
};
Ok(crate::cache::InvalidationRequest {
operation, selector, value: format!("{scheme}://{authority}{target}"),
host: Some(host.to_owned()), vhost: Some(vhost.to_owned()),
ttl_seconds, confirm_wide,
operation,
selector,
value: format!("{scheme}://{authority}{target}"),
host: Some(host.to_owned()),
vhost: Some(vhost.to_owned()),
ttl_seconds,
confirm_wide,
})
}
@@ -88,51 +118,95 @@ async fn cache_http_invalidation(
scheme: &str,
) -> Response {
let Some(frontend) = frontend else {
return error(StatusCode::NOT_FOUND, "RustProxy: no matching vhost for cache invalidation\n");
return error(
StatusCode::NOT_FOUND,
"RustProxy: no matching vhost for cache invalidation\n",
);
};
if snapshot.forwarding.trusted(peer_ip)
&& crate::vhost::canonical_ip(peer_ip) == client_ip
if snapshot.forwarding.trusted(peer_ip) && crate::vhost::canonical_ip(peer_ip) == client_ip {
return error(
StatusCode::FORBIDDEN,
"RustProxy: missing trusted proxy client identity\n",
);
}
if !snapshot
.envelope
.config
.cache
.permits_http_invalidation(client_ip)
{
return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity\n");
return error(
StatusCode::FORBIDDEN,
"RustProxy: BAN/PURGE client IP is not allowed\n",
);
}
if !snapshot.envelope.config.cache.permits_http_invalidation(client_ip) {
return error(StatusCode::FORBIDDEN, "RustProxy: BAN/PURGE client IP is not allowed\n");
}
let requested = match cache_http_invalidation_request(request.method(), host, &frontend.id, scheme, &request) {
let requested = match cache_http_invalidation_request(
request.method(),
host,
&frontend.id,
scheme,
&request,
) {
Ok(requested) => requested,
Err(reason) => return error(StatusCode::BAD_REQUEST, reason),
};
let method = request.method().clone();
drop(request);
let Ok(permit) = state.cache_invalidation_slots.clone().try_acquire_owned() else {
return error(StatusCode::TOO_MANY_REQUESTS, "RustProxy: cache maintenance busy\n");
return error(
StatusCode::TOO_MANY_REQUESTS,
"RustProxy: cache maintenance busy\n",
);
};
let detail = format!(
"client_ip={client_ip}; method={}; host={host}; vhost={}; target={:?}",
method, frontend.id, requested.value
);
if let Err(audit_error) = state.store.audit(&format!("http:{client_ip}"), "cache.http_invalidate", &detail).await {
if let Err(audit_error) = state
.store
.audit(
&format!("http:{client_ip}"),
"cache.http_invalidate",
&detail,
)
.await
{
tracing::error!(error = %audit_error, "cache HTTP invalidation audit failed");
return error(StatusCode::SERVICE_UNAVAILABLE, "RustProxy: cache maintenance unavailable\n");
return error(
StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: cache maintenance unavailable\n",
);
}
let cache = state.cache.clone();
let result = tokio::task::spawn_blocking(move || {
let _permit = permit;
cache.invalidate(requested)
}).await;
})
.await;
let result = match result {
Ok(Ok(result)) => result,
Ok(Err(invalid)) => {
tracing::warn!(reason = %invalid, "cache HTTP invalidation rejected");
return error(StatusCode::UNPROCESSABLE_ENTITY, "RustProxy: invalid cache maintenance request\n");
},
Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "RustProxy: cache maintenance failed\n"),
return error(
StatusCode::UNPROCESSABLE_ENTITY,
"RustProxy: invalid cache maintenance request\n",
);
}
Err(_) => {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: cache maintenance failed\n",
)
}
};
let code = if result.operation == crate::cache::InvalidationOperation::Ban {
StatusCode::ACCEPTED
} else { StatusCode::OK };
} else {
StatusCode::OK
};
let mut response = (code, axum::Json(serde_json::json!({"result": result}))).into_response();
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
response
}
+60 -19
View File
@@ -2,14 +2,25 @@ mod http_invalidation_request_tests {
use super::*;
fn request(method: &str, target: &str) -> Request {
Request::builder().method(method).uri(target).body(Body::empty()).unwrap()
Request::builder()
.method(method)
.uri(target)
.body(Body::empty())
.unwrap()
}
fn parse(method: &str, host: &str, scheme: &str, request: &Request)
-> Result<crate::cache::InvalidationRequest, &'static str>
{
fn parse(
method: &str,
host: &str,
scheme: &str,
request: &Request,
) -> Result<crate::cache::InvalidationRequest, &'static str> {
cache_http_invalidation_request(
&Method::from_bytes(method.as_bytes()).unwrap(), host, "frontend-1", scheme, request,
&Method::from_bytes(method.as_bytes()).unwrap(),
host,
"frontend-1",
scheme,
request,
)
}
@@ -34,36 +45,66 @@ mod http_invalidation_request_tests {
fn wide_ban_must_be_confirmed_and_ttl_is_bounded() {
assert!(parse("BAN", "a.example", "https", &request("BAN", "/")).is_err());
let mut wide = request("BAN", "/");
wide.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &wide).unwrap().confirm_wide);
wide.headers_mut()
.insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(
parse("BAN", "a.example", "https", &wide)
.unwrap()
.confirm_wide
);
let mut ttl = request("BAN", "/foo");
ttl.headers_mut().insert("x-rustproxy-ban-ttl", "604801".parse().unwrap());
ttl.headers_mut()
.insert("x-rustproxy-ban-ttl", "604801".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &ttl).is_err());
ttl.headers_mut().insert("x-rustproxy-ban-ttl", "30".parse().unwrap());
assert_eq!(parse("BAN", "a.example", "https", &ttl).unwrap().ttl_seconds, Some(30));
ttl.headers_mut().append("x-rustproxy-ban-ttl", "60".parse().unwrap());
ttl.headers_mut()
.insert("x-rustproxy-ban-ttl", "30".parse().unwrap());
assert_eq!(
parse("BAN", "a.example", "https", &ttl)
.unwrap()
.ttl_seconds,
Some(30)
);
ttl.headers_mut()
.append("x-rustproxy-ban-ttl", "60".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &ttl).is_err());
}
#[test]
fn maintenance_rejects_bodies_and_ambiguous_or_unsupported_controls() {
let mut body = request("PURGE", "/foo");
body.headers_mut().insert(http::header::CONTENT_LENGTH, "99".parse().unwrap());
body.headers_mut()
.insert(http::header::CONTENT_LENGTH, "99".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &body).is_err());
body.headers_mut().insert(http::header::CONTENT_LENGTH, "0".parse().unwrap());
body.headers_mut()
.insert(http::header::CONTENT_LENGTH, "0".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &body).is_ok());
body.headers_mut().append(http::header::CONTENT_LENGTH, "0".parse().unwrap());
body.headers_mut()
.append(http::header::CONTENT_LENGTH, "0".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &body).is_err());
let mut transfer = request("PURGE", "/foo");
transfer.headers_mut().insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap());
transfer
.headers_mut()
.insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &transfer).is_err());
let mut unexpected = request("PURGE", "/foo");
unexpected.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
unexpected
.headers_mut()
.insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &unexpected).is_err());
unexpected = request("BAN", "/foo");
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap());
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap());
unexpected
.headers_mut()
.append("x-rustproxy-confirm-wide", "1".parse().unwrap());
unexpected
.headers_mut()
.append("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &unexpected).is_err());
assert!(parse("PURGE", "a.example", "https", &request("PURGE", &format!("/{}", "a".repeat(4097)))).is_err());
assert!(parse(
"PURGE",
"a.example",
"https",
&request("PURGE", &format!("/{}", "a".repeat(4097)))
)
.is_err());
}
}
+85 -21
View File
@@ -16,19 +16,33 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade
let settings = settings();
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("private"));
headers.insert("cache-control", HeaderValue::from_static("max-age=not-a-number"));
headers.insert(
"cache-control",
HeaderValue::from_static("max-age=not-a-number"),
);
for reason in [
"cache_disabled", "route_disabled", "admin_control_plane", "basic_auth",
"tinyauth", "sticky_cookie", "request_upgrade",
"cache_disabled",
"route_disabled",
"admin_control_plane",
"basic_auth",
"tinyauth",
"sticky_cookie",
"request_upgrade",
] {
let plan = CachePlan::new(Some(reason), &settings, "/style.css", || {
panic!("disabled cache must not match force-cache rules")
});
assert!(!plan.force_cache());
assert!(!plan.ignore_cookie());
let policy = plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings);
let policy =
plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings);
assert_eq!(policy, RequestPolicy::Bypass(reason));
assert_eq!(combine_policy(policy, || panic!("disabled cache must not parse post-rule policy")), policy);
assert_eq!(
combine_policy(policy, || panic!(
"disabled cache must not parse post-rule policy"
)),
policy
);
}
}
@@ -38,23 +52,49 @@ fn cache_on_keeps_all_existing_request_policy_outcomes() {
for forced in [false, true] {
let plan = CachePlan::new(None, &settings, "/style.css", || forced);
for (name, value) in [
("x-irrelevant", "yes"), ("cache-control", "no-store"),
("cache-control", "no-cache"), ("cache-control", "max-age=0"),
("cache-control", "min-fresh=broken"), ("authorization", "private"),
("cookie", "session=private"), ("range", "bytes=0-2"),
("range", "bytes=0-2,4-5"), ("if-range", "\"etag\""),
("if-match", "\"etag\""), ("upgrade", "websocket"),
("x-irrelevant", "yes"),
("cache-control", "no-store"),
("cache-control", "no-cache"),
("cache-control", "max-age=0"),
("cache-control", "min-fresh=broken"),
("authorization", "private"),
("cookie", "session=private"),
("range", "bytes=0-2"),
("range", "bytes=0-2,4-5"),
("if-range", "\"etag\""),
("if-match", "\"etag\""),
("upgrade", "websocket"),
] {
let mut headers = HeaderMap::new();
headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap());
headers.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
for method in [Method::GET, Method::HEAD, Method::POST] {
for body_empty in [false, true] {
let expected = if forced {
crate::cache::forced_request_policy(&method, &headers, body_empty)
} else {
crate::cache::request_policy(&method, &headers, body_empty, "/style.css", false, &settings)
crate::cache::request_policy(
&method,
&headers,
body_empty,
"/style.css",
false,
&settings,
)
};
assert_eq!(plan.request_policy(&method, &headers, body_empty, "/style.css", false, &settings), expected);
assert_eq!(
plan.request_policy(
&method,
&headers,
body_empty,
"/style.css",
false,
&settings
),
expected
);
}
}
}
@@ -73,8 +113,12 @@ fn request_header_rules_cannot_relax_bypass_or_revalidation() {
for original in cases {
for rewritten in cases {
let expected = match (original, rewritten) {
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason),
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason),
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => {
RequestPolicy::Bypass(reason)
}
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => {
RequestPolicy::Revalidate(reason)
}
_ => RequestPolicy::Use,
};
assert_eq!(combine_policy(original, || rewritten), expected);
@@ -85,16 +129,36 @@ fn request_header_rules_cannot_relax_bypass_or_revalidation() {
#[test]
fn cache_off_still_blocks_range_transformation_and_preserves_origin_headers() {
for method in [Method::GET, Method::HEAD, Method::POST] {
for range in [None, Some("bytes=0-2"), Some("bytes=0-2,4-5"), Some("broken"), Some("bytes=99-")] {
for range in [
None,
Some("bytes=0-2"),
Some("bytes=0-2,4-5"),
Some("broken"),
Some("bytes=99-"),
] {
let mut headers = HeaderMap::new();
if let Some(range) = range { headers.insert("range", range.parse().unwrap()); }
if let Some(range) = range {
headers.insert("range", range.parse().unwrap());
}
headers.insert("if-range", HeaderValue::from_static("\"same-validator\""));
headers.insert("if-none-match", HeaderValue::from_static("\"client-validator\""));
headers.insert(
"if-none-match",
HeaderValue::from_static("\"client-validator\""),
);
let original = headers.clone();
let baseline = crate::cache::request_range(&method, &headers);
assert_eq!(range_selection(true, &method, &headers), baseline);
assert_eq!(matches!(range_selection(false, &method, &headers), RangeSelection::None), matches!(baseline, RangeSelection::None));
assert_eq!(headers, original, "origin must receive the original Range/conditional fields");
assert_eq!(
matches!(
range_selection(false, &method, &headers),
RangeSelection::None
),
matches!(baseline, RangeSelection::None)
);
assert_eq!(
headers, original,
"origin must receive the original Range/conditional fields"
);
}
}
}
+15 -7
View File
@@ -35,10 +35,7 @@ impl ErrorFallbackRequest {
transport: request.extensions().get::<Transport>().copied(),
snapshot: snapshot.clone(),
exchange: request.extensions().get::<Arc<Exchange>>().cloned(),
connection_lease: request
.extensions()
.get::<Arc<ConnectionLease>>()
.cloned(),
connection_lease: request.extensions().get::<Arc<ConnectionLease>>().cloned(),
stop: request.extensions().get::<ServerShutdown>().cloned(),
depth: request
.extensions()
@@ -48,7 +45,13 @@ impl ErrorFallbackRequest {
}
}
fn rebuild(&self, body: Bytes, peer: PeerAddr, local: LocalAddr, dispatch: InternalFallbackRoute) -> Request {
fn rebuild(
&self,
body: Bytes,
peer: PeerAddr,
local: LocalAddr,
dispatch: InternalFallbackRoute,
) -> Request {
let mut request = Request::new(Body::from(body));
*request.method_mut() = self.method.clone();
*request.uri_mut() = self.uri.clone();
@@ -83,7 +86,10 @@ enum ErrorFallbackBody {
const MAX_ERROR_FALLBACK_DEPTH: u8 = 8;
const MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES: u64 = 8 * 1024 * 1024;
async fn prepare_error_fallback_body(request: &mut Request, enabled: bool) -> Result<ErrorFallbackBody, Response> {
async fn prepare_error_fallback_body(
request: &mut Request,
enabled: bool,
) -> Result<ErrorFallbackBody, Response> {
if !enabled {
return Ok(ErrorFallbackBody::Disabled);
}
@@ -193,6 +199,8 @@ fn intercept_route_origin_error(
} else {
response
};
response.extensions_mut().insert(crate::pages::OriginProvidedResponse);
response
.extensions_mut()
.insert(crate::pages::OriginProvidedResponse);
response
}
+23 -8
View File
@@ -735,7 +735,10 @@ mod cache_fast_path_tests {
let fixture = Fixture::new(config).await;
let first = fixture.get("/vary", &[]).await;
assert_eq!(first.headers["x-cache"], "MISS");
assert!(cache_chain(&first.headers).last().unwrap().starts_with("RustProxy; fwd=miss"));
assert!(cache_chain(&first.headers)
.last()
.unwrap()
.starts_with("RustProxy; fwd=miss"));
let revalidated = fixture.get("/vary", &[("cache-control", "no-cache")]).await;
assert_eq!(revalidated.status, StatusCode::OK);
assert_eq!(revalidated.headers["x-cache"], "HIT");
@@ -743,7 +746,9 @@ mod cache_fast_path_tests {
cache_chain(&revalidated.headers).last().copied(),
Some("RustProxy; fwd=request; fwd-status=304; detail=\"cache_revalidated\""),
);
let posted = fixture.send(Method::POST, "/echo", &[], Body::from("posted")).await;
let posted = fixture
.send(Method::POST, "/echo", &[], Body::from("posted"))
.await;
assert_eq!(posted.status, StatusCode::OK);
assert_eq!(posted.body, "posted");
assert_eq!(
@@ -783,9 +788,10 @@ mod cache_fast_path_tests {
let stale = fixture.get("/stale-no-validator", &[]).await;
assert_eq!(stale.status, StatusCode::OK);
assert_eq!(stale.headers["x-cache"], "MISS");
assert!(
cache_chain(&stale.headers).last().unwrap().starts_with("RustProxy; fwd=stale"),
);
assert!(cache_chain(&stale.headers)
.last()
.unwrap()
.starts_with("RustProxy; fwd=stale"),);
assert_eq!(origin.requests.load(Ordering::Relaxed), 2);
fixture.stop().await;
}
@@ -942,9 +948,18 @@ mod cache_fast_path_tests {
);
let cache_status = cache_chain(&stale.headers);
let last = cache_status.last().unwrap();
let freshness = last.strip_prefix("RustProxy; hit; ttl=").unwrap()
.split(';').next().unwrap().parse::<i64>().unwrap();
assert!(freshness <= 0, "expired response must not claim positive freshness");
let freshness = last
.strip_prefix("RustProxy; hit; ttl=")
.unwrap()
.split(';')
.next()
.unwrap()
.parse::<i64>()
.unwrap();
assert!(
freshness <= 0,
"expired response must not claim positive freshness"
);
assert!(last.ends_with("; detail=\"cache_stale_while_revalidate\""));
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
+56 -18
View File
@@ -106,7 +106,11 @@ async fn forward_request(
} else {
snapshot.frontend(&host, local.0, tls)
};
if snapshot.global_security.content_type_policy.blocks(request.headers()) {
if snapshot
.global_security
.content_type_policy
.blocks(request.headers())
{
return error(
StatusCode::FORBIDDEN,
"RustProxy: request Content-Type denied by global policy\n",
@@ -118,7 +122,10 @@ async fn forward_request(
"RustProxy: request Content-Type denied by vhost policy\n",
);
}
if snapshot.global_security.user_agent_blocking.blocks(request.headers())
if snapshot
.global_security
.user_agent_blocking
.blocks(request.headers())
|| frontend.is_some_and(|frontend| frontend.user_agent_blocking.blocks(request.headers()))
{
return error(
@@ -170,7 +177,9 @@ async fn forward_request(
"RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n",
);
}
if let Some(response) = method_policy_denial(&snapshot.global_security.method_policy, request.method()) {
if let Some(response) =
method_policy_denial(&snapshot.global_security.method_policy, request.method())
{
return response;
}
if !cache_http_invalidation_method(request.method()) {
@@ -239,11 +248,24 @@ async fn forward_request(
}
}
// Global rules are cumulative: an explicit IP allow cannot bypass a country deny/allowlist.
if !snapshot.global_security.country_access.allows(country.as_deref())
|| !snapshot.global_security.rate_limit.country_allowed(country.as_deref())
|| snapshot.global_security.acl.as_ref().is_some_and(|acl| !acl.allows(context.client_ip))
if !snapshot
.global_security
.country_access
.allows(country.as_deref())
|| !snapshot
.global_security
.rate_limit
.country_allowed(country.as_deref())
|| snapshot
.global_security
.acl
.as_ref()
.is_some_and(|acl| !acl.allows(context.client_ip))
{
return error(StatusCode::FORBIDDEN, "RustProxy: denied by global access policy\n");
return error(
StatusCode::FORBIDDEN,
"RustProxy: denied by global access policy\n",
);
}
if internal_dispatch.is_none() {
if let Err(limited) = state.rate_limiter.check_with_user_agent(
@@ -251,7 +273,10 @@ async fn forward_request(
"\u{1f}rustproxy-global-security",
context.client_ip,
country.as_deref(),
request.headers().get(http::header::USER_AGENT).and_then(|value| value.to_str().ok()),
request
.headers()
.get(http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
&snapshot.global_security.rate_limit,
) {
let mut response = error(
@@ -265,12 +290,13 @@ async fn forward_request(
}
}
if let Some(frontend) = frontend {
if !country_resolved && (capture_country
|| frontend
.profile_policies
.iter()
.any(|profile| profile.countries.needs_geoip())
|| frontend.rate_limit.needs_geoip())
if !country_resolved
&& (capture_country
|| frontend
.profile_policies
.iter()
.any(|profile| profile.countries.needs_geoip())
|| frontend.rate_limit.needs_geoip())
{
country = cloudflare_country
.clone()
@@ -331,14 +357,20 @@ async fn forward_request(
if let Some(mut response) = frontend.and_then(|frontend| {
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
}) {
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
return response;
}
if !tls {
if let Some(mut response) =
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
{
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
return response;
}
}
@@ -364,7 +396,10 @@ async fn forward_request(
"RustProxy: redirect target could not be rendered\n",
);
};
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
response.extensions_mut().insert(RedirectResponse {
http3_enabled: _redirect.http3_enabled,
});
@@ -372,7 +407,10 @@ async fn forward_request(
}
if let Some(item) = local_host {
let mut response = item.page.serve(request.method(), StatusCode::NOT_FOUND);
snapshot.global_security.headers.apply(response.headers_mut(), tls);
snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
response.extensions_mut().insert(LocalHostResponse {
http3_enabled: item.http3_enabled,
});
+19 -6
View File
@@ -23,7 +23,9 @@ mod tests {
fn common_connection_tokens_do_not_request_websocket_upgrade() {
for connection in [None, Some("keep-alive"), Some("KEEP-ALIVE"), Some("close")] {
let mut headers = HeaderMap::new();
if let Some(value) = connection { headers.insert("connection", value.parse().unwrap()); }
if let Some(value) = connection {
headers.insert("connection", value.parse().unwrap());
}
assert!(!requests_upgrade(&headers));
}
let mut headers = HeaderMap::new();
@@ -52,20 +54,29 @@ mod tests {
headers.append("set-cookie", "rustproxy_session=secret".parse().unwrap());
headers.append("set-cookie", "b=2; HttpOnly".parse().unwrap());
strip_admin_set_cookie(&mut headers);
let cookies: Vec<_> = headers.get_all("set-cookie").iter()
.map(|value| value.to_str().unwrap()).collect();
let cookies: Vec<_> = headers
.get_all("set-cookie")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(cookies, vec!["a=1; Secure", "b=2; HttpOnly"]);
}
#[test]
fn admin_cookie_can_be_restored_only_from_captured_fragment() {
let mut h = HeaderMap::new();
h.insert("cookie", "a=1; rustproxy_session=secret; b=2".parse().unwrap());
h.insert(
"cookie",
"a=1; rustproxy_session=secret; b=2".parse().unwrap(),
);
let admin = strip_admin_cookie(&mut h);
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2");
assert_eq!(admin, vec!["rustproxy_session=secret"]);
restore_admin_cookie(&mut h, &admin);
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2; rustproxy_session=secret");
assert_eq!(
h.get("cookie").unwrap(),
"a=1; b=2; rustproxy_session=secret"
);
}
#[test]
@@ -87,7 +98,9 @@ mod tests {
fn absent_and_close_connection_preserve_non_hop_headers() {
for value in [None, Some("close"), Some("CLOSE")] {
let mut headers = HeaderMap::new();
if let Some(value) = value { headers.insert("connection", value.parse().unwrap()); }
if let Some(value) = value {
headers.insert("connection", value.parse().unwrap());
}
headers.insert("x-request-policy", "allowed".parse().unwrap());
headers.insert("transfer-encoding", "chunked".parse().unwrap());
strip_hop_by_hop(&mut headers);
+30 -10
View File
@@ -13,9 +13,20 @@ struct SelectedRouteContext {
fn take_routing_identity(
request: &mut Request,
tls: bool,
) -> Result<(Option<SelectedRouteContext>, HeaderValue, String, Option<String>, bool), Response> {
) -> 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 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) => (
@@ -24,16 +35,25 @@ fn take_routing_identity(
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()
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"))?;
.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))
Ok((
selected_context,
original_host,
host,
decoded_path,
host_already_validated,
))
}
+11 -4
View File
@@ -29,7 +29,10 @@ fn canonical_host_redirect(
} else {
("http", local_port)
};
let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/");
let path = uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let default_port = (scheme == "https" && port == 443) || (scheme == "http" && port == 80);
let location = if default_port {
format!("{scheme}://{to}{path}")
@@ -37,8 +40,13 @@ fn canonical_host_redirect(
format!("{scheme}://{to}:{port}{path}")
};
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response.headers_mut().insert(http::header::LOCATION, HeaderValue::from_str(&location).ok()?);
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
response.headers_mut().insert(
http::header::LOCATION,
HeaderValue::from_str(&location).ok()?,
);
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
@@ -60,4 +68,3 @@ fn force_ssl_redirect(
.insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
+1 -3
View File
@@ -32,9 +32,7 @@ mod request_id_tests {
#[test]
fn independent_sequential_ids_keep_full_length_and_unique_values() {
let ids: std::collections::HashSet<_> = (0..4096u64)
.map(request_id_hash)
.collect();
let ids: std::collections::HashSet<_> = (0..4096u64).map(request_id_hash).collect();
assert_eq!(ids.len(), 4096);
assert!(ids.iter().all(|id| id.len() == 64));
}
+9 -6
View File
@@ -129,12 +129,15 @@ pub(super) fn finish_web_revalidation(
if *method == Method::HEAD {
*response.body_mut() = Body::empty();
}
let request_forces_validation = crate::cache::request_policy(
method, client, true, "", false, settings,
).revalidation_reason().is_some()
|| cached.request_freshness_revalidation_reason(
&crate::cache::request_freshness_headers(client, settings),
).is_some();
let request_forces_validation =
crate::cache::request_policy(method, client, true, "", false, settings)
.revalidation_reason()
.is_some()
|| cached
.request_freshness_revalidation_reason(&crate::cache::request_freshness_headers(
client, settings,
))
.is_some();
settings.annotate_reason_transformed_forwarded(
response.headers_mut(),
"HIT",
+3 -1
View File
@@ -154,7 +154,9 @@ async fn strict_browser_refresh_uses_request_forward_reason() {
let mut client = HeaderMap::new();
client.insert("cache-control", HeaderValue::from_static("no-cache"));
let response = validate(&service, &cache, &ticket, &settings, Method::GET, client)
.await.unwrap().response;
.await
.unwrap()
.response;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["x-cache"], "HIT");
assert_eq!(