0.34.13_cleaup_code
This commit is contained in:
1 parent
ec46a96a29
commit
5c2e50ec06
87 files changed
+4890
-1338
No files matched your search
+13
-6
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user