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 { 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"); } 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 }; 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::().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"); } if !matches!(scheme, "http" | "https") { return Err("invalid request scheme"); } let authority = if host.parse::().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, snapshot: &crate::config::Snapshot, frontend: Option<&Arc>, host: &str, request: Request, client_ip: std::net::IpAddr, peer_ip: std::net::IpAddr, scheme: &str, ) -> Response { let Some(frontend) = frontend else { 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 { 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), }; 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"); }; 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"); } 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 }