v0.31.0
This commit is contained in:
1 parent
f213d853e2
commit
76b8bb7ee2
78 files changed
+7750
-5204
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user