This commit is contained in:
Mateusz Gruszczyński committed 2026-10-10 20:56:36 +02:00
1 parent f213d853e2
commit 76b8bb7ee2
78 files changed
+7750 -5204

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
// Varnish-like HTTP BAN/PURGE on public vhost listeners. No new admin
// endpoint or session token: security rests on a strict, opt-in client IP/CIDR
// allowlist plus normal frontend/host ACLs and rate limits. Never forward.
fn cache_http_invalidation_method(method: &Method) -> bool {
method.as_str() == "BAN" || method.as_str() == "PURGE"
}
fn cache_http_invalidation_request(
method: &Method,
host: &str,
vhost: &str,
scheme: &str,
request: &Request,
) -> Result<crate::cache::InvalidationRequest, &'static str> {
// Allow a single explicit Content-Length: 0 (used by some PURGE clients),
// but reject ambiguous lengths and all transfer-encoded bodies.
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.body().is_end_stream()
{
return Err("BAN/PURGE must not contain a request body or upgrade");
}
// Only this vhost and Host are permitted; no client-supplied cross-vhost
// selectors, regex, or arbitrary Cache-Tag from the public listener.
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");
}
let operation = if method.as_str() == "BAN" {
crate::cache::InvalidationOperation::Ban
} else {
crate::cache::InvalidationOperation::Purge
};
let selector = if method.as_str() == "BAN" {
crate::cache::InvalidationSelector::Prefix
} else {
crate::cache::InvalidationSelector::Exact
};
// Never resolve ambiguous duplicate maintenance headers differently from
// intermediaries or audit logs. Reject them before selecting a target.
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())
.ok_or("invalid BAN TTL")?;
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")
.is_some_and(|value| value == "1");
if target == "/" && method.as_str() == "BAN" && !confirm_wide {
return Err("BAN / requires X-RustProxy-Confirm-Wide: 1");
}
if request.headers().contains_key("x-rustproxy-confirm-wide") && !confirm_wide {
return Err("invalid wide confirmation header");
}
if method.as_str() != "BAN" && request.headers().contains_key("x-rustproxy-confirm-wide") {
return Err("wide confirmation is only supported for BAN");
}
// Explicitly scope maintenance to the externally visible scheme. The
// ordinary /path matcher would also invalidate the opposite scheme on
// a vhost shared between HTTP and HTTPS.
if !matches!(scheme, "http" | "https") {
return Err("invalid request scheme");
}
let authority = if host.parse::<std::net::Ipv6Addr>().is_ok() {
format!("[{host}]")
} else {
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,
})
}
async fn cache_http_invalidation(
state: &Arc<App>,
snapshot: &crate::config::Snapshot,
frontend: Option<&Arc<crate::config::CompiledFrontend>>,
host: &str,
request: Request,
client_ip: std::net::IpAddr,
peer_ip: std::net::IpAddr,
scheme: &str,
) -> Response {
// Fail closed even when the vhost cache is disabled; never pass commands
// through to the backend, and never trust arbitrary X-Forwarded-For.
let Some(frontend) = frontend else {
return error(StatusCode::NOT_FOUND, "RustProxy: no matching vhost for cache invalidation\n");
};
// A trusted proxy missing a usable client chain must not inherit its own
// allowlist permission for every external visitor.
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: BAN/PURGE client IP is not allowed\n");
}
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),
};
// Axum's Request<Body> is Send but its Body is not Sync. Never keep
// a shared `&Request` inside the handler future across an .await.
// Release the unused body before the asynchronous audit/cache work.
let method = request.method().clone();
drop(request);
// Allocate the independent maintenance slot before even writing an audit
// row, so a burst from an allowed IP cannot pile up DB writes and workers.
let Ok(permit) = state.cache_invalidation_slots.clone().try_acquire_owned() else {
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 {
tracing::error!(error = %audit_error, "cache HTTP invalidation audit failed");
return error(StatusCode::SERVICE_UNAVAILABLE, "RustProxy: cache maintenance unavailable\n");
}
// Rate-limit the expensive maintenance path independently of the optional
// per-vhost traffic limiter. The permit lives inside the blocking worker
// even if a client disconnects while the cache scan is running.
let cache = state.cache.clone();
let result = tokio::task::spawn_blocking(move || {
let _permit = permit;
cache.invalidate(requested)
}).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"),
};
let code = if result.operation == crate::cache::InvalidationOperation::Ban {
StatusCode::ACCEPTED
} 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
}
+18 -13
View File
@@ -154,22 +154,24 @@ async fn forward_request(
"RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n",
);
}
if let Some(response) = frontend
.and_then(|frontend| method_policy_denial(&frontend.method_policy, request.method()))
{
return response;
}
if let Some(response) = frontend.and_then(|frontend| {
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
}) {
return response;
}
if !tls {
if let Some(response) =
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
if !cache_http_invalidation_method(request.method()) {
if let Some(response) = frontend
.and_then(|frontend| method_policy_denial(&frontend.method_policy, request.method()))
{
return response;
}
if let Some(response) = frontend.and_then(|frontend| {
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
}) {
return response;
}
if !tls {
if let Some(response) = frontend.and_then(|frontend| {
force_ssl_redirect(frontend, &host, request.uri())
}) {
return response;
}
}
}
let mut context = match snapshot.forwarding.context(
request.headers(),
@@ -267,6 +269,9 @@ async fn forward_request(
"RustProxy: denied by access policy\n",
);
}
if cache_http_invalidation_method(request.method()) {
return cache_http_invalidation(&state, &snapshot, frontend, &host, request, context.client_ip, peer.0.ip(), context.scheme).await;
}
if let Some((_redirect, rule)) = redirect {
let authority = original_host.to_str().unwrap_or(&host);
let Some(mut response) = rule.response(authority, tls, request.uri()) else {
+4
View File
@@ -1,9 +1,13 @@
include!("types.rs");
include!("error_fallback.rs");
include!("cache_helpers.rs");
include!("cache_http_invalidation.rs");
include!("upstream_helpers.rs");
include!("redirect_helpers.rs");
include!("forward_entry.rs");
#[cfg(test)]
include!("tests/authority_tests.rs");
#[cfg(test)]
include!("tests/http_invalidation_tests.rs");
@@ -0,0 +1,69 @@
mod http_invalidation_request_tests {
use super::*;
fn request(method: &str, target: &str) -> Request {
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>
{
cache_http_invalidation_request(
&Method::from_bytes(method.as_bytes()).unwrap(), host, "frontend-1", scheme, request,
)
}
#[test]
fn exact_purge_and_prefix_ban_are_scoped_to_current_host_vhost_and_scheme() {
let purge = request("PURGE", "/a?v=1");
let parsed = parse("PURGE", "a.example", "https", &purge).unwrap();
assert_eq!(parsed.selector, crate::cache::InvalidationSelector::Exact);
assert_eq!(parsed.host.as_deref(), Some("a.example"));
assert_eq!(parsed.vhost.as_deref(), Some("frontend-1"));
assert_eq!(parsed.value, "https://a.example/a?v=1");
let ban = parse("BAN", "a.example", "http", &request("BAN", "/assets/")).unwrap();
assert_eq!(ban.selector, crate::cache::InvalidationSelector::Prefix);
assert_eq!(ban.value, "http://a.example/assets/");
let ipv6 = parse("PURGE", "2001:db8::1", "https", &purge).unwrap();
assert_eq!(ipv6.value, "https://[2001:db8::1]/a?v=1");
assert!(parse("BAN", "a.example", "ftp", &request("BAN", "/assets/")).is_err());
}
#[test]
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);
let mut ttl = request("BAN", "/foo");
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());
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());
assert!(parse("PURGE", "a.example", "https", &body).is_err());
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());
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());
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());
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());
assert!(parse("BAN", "a.example", "https", &unexpected).is_err());
assert!(parse("PURGE", "a.example", "https", &request("PURGE", &format!("/{}", "a".repeat(4097)))).is_err());
}
}