Files
RustProxy/src/cache/policy/request.rs
T
2026-10-08 15:59:37 +02:00

414 lines
14 KiB
Rust

/// Reject malformed/duplicate directives instead of guessing their meaning.
fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
let mut result = HashMap::new();
for header in headers.get_all("cache-control") {
for raw in header.to_str().ok()?.split(',') {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let (key, value) = match raw.split_once('=') {
Some((k, v)) => {
let v = v.trim();
let value = if v.starts_with('"') {
// Fail closed on truncated/escaped/compound values. Quoted
// commas need not be supported by this conservative cache.
let inner = v.strip_prefix('"')?.strip_suffix('"')?;
if inner.contains(['"', '\\']) {
return None;
}
inner
} else {
if v.is_empty() || v.bytes().any(|b| b <= 32 || b == b'"' || b == b'\\') {
return None;
}
v
};
(k.trim().to_ascii_lowercase(), Some(value.to_owned()))
}
None => (raw.to_ascii_lowercase(), None),
};
if key.parse::<http::HeaderName>().is_err() {
return None;
}
if matches!(
key.as_str(),
"public" | "no-store" | "must-revalidate" | "proxy-revalidate" | "only-if-cached"
) && value.is_some()
{
return None;
}
if key == "immutable" {
// RFC 8246: immutable has no meaningful argument and duplicate
// occurrences are equivalent to one occurrence. Preserve only
// the presence of the directive instead of invalidating the
// complete Cache-Control field.
result.entry(key).or_insert(None);
} else if result.insert(key, value).is_some() {
return None;
}
}
}
Some(result)
}
pub fn only_if_cached(headers: &HeaderMap) -> bool {
headers
.get_all("cache-control")
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| {
v.split(',')
.any(|v| v.trim().eq_ignore_ascii_case("only-if-cached"))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestPolicy {
Use,
Revalidate(&'static str),
Bypass(&'static str),
}
impl RequestPolicy {
pub fn bypass_reason(self) -> Option<&'static str> {
match self {
Self::Bypass(reason) => Some(reason),
_ => None,
}
}
pub fn revalidation_reason(self) -> Option<&'static str> {
match self {
Self::Revalidate(reason) => Some(reason),
_ => None,
}
}
}
fn pragma_no_cache(headers: &HeaderMap) -> bool {
headers
.get_all("pragma")
.iter()
.filter_map(|value| value.to_str().ok())
.any(|value| {
value
.split(',')
.any(|token| token.trim().eq_ignore_ascii_case("no-cache"))
})
}
pub fn forced_request_policy(
method: &Method,
headers: &HeaderMap,
empty_body: bool,
) -> RequestPolicy {
if method != Method::GET && method != Method::HEAD {
return RequestPolicy::Bypass("request_method");
}
if !empty_body {
return RequestPolicy::Bypass("request_body");
}
if matches!(request_range(method, headers), RangeSelection::Unsupported) {
return RequestPolicy::Bypass("request_range_unsupported");
}
if headers.contains_key("if-range") && !headers.contains_key("range") {
return RequestPolicy::Bypass("request_precondition");
}
for key in ["if-match", "if-unmodified-since", "upgrade"] {
if headers.contains_key(key) {
return RequestPolicy::Bypass(if key == "upgrade" { "request_upgrade" } else { "request_precondition" });
}
}
RequestPolicy::Use
}
pub fn request_policy(
method: &Method,
headers: &HeaderMap,
empty_body: bool,
path: &str,
has_query: bool,
settings: &Settings,
) -> RequestPolicy {
if method != Method::GET && method != Method::HEAD {
return RequestPolicy::Bypass("request_method");
}
if !empty_body {
return RequestPolicy::Bypass("request_body");
}
if matches!(request_range(method, headers), RangeSelection::Unsupported) {
return RequestPolicy::Bypass("request_range_unsupported");
}
if headers.contains_key("if-range") && !headers.contains_key("range") {
return RequestPolicy::Bypass("request_precondition");
}
if settings.bypass_query && has_query {
return RequestPolicy::Bypass("query_excluded");
}
if settings
.bypass_prefixes
.iter()
.any(|prefix| crate::config::prefix_matches(prefix, path))
{
return RequestPolicy::Bypass("path_excluded");
}
for (key, reason) in [
("authorization", "request_authorization"),
("cookie", "request_cookie"),
// Preconditions other than cache validators can produce 412 and must be
// evaluated by the origin until RustProxy implements the full precondition set.
("if-match", "request_precondition"),
("if-unmodified-since", "request_precondition"),
("upgrade", "request_upgrade"),
] {
if headers.contains_key(key) {
if key == "cookie" && settings.ignore_cookie_for_path(path) {
continue;
}
return RequestPolicy::Bypass(reason);
}
}
let Some(control) = directives(headers) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
// RFC 9111: request no-store forbids using a stored response and storing this
// exchange. It is the hard client-side cache bypass.
if control.contains_key("no-store") {
return RequestPolicy::Bypass("request_no_store");
}
// no-cache does not mean no-store. It requires successful validation before a
// stored response can be reused. max-age/min-fresh are evaluated against the
// cached representation during lookup; only max-age=0 is an unconditional
// request-side validation signal.
if control.contains_key("no-cache") {
return RequestPolicy::Revalidate("request_no_cache");
}
if let Some(value) = control.get("max-age") {
let Some(value) = value.as_deref().and_then(|value| value.parse::<u64>().ok()) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
if value == 0 {
return RequestPolicy::Revalidate("request_max_age");
}
}
if let Some(value) = control.get("min-fresh") {
if value
.as_deref()
.and_then(|value| value.parse::<u64>().ok())
.is_none()
{
return RequestPolicy::Bypass("request_cache_control_invalid");
}
}
if let Some(Some(value)) = control.get("max-stale") {
if value.parse::<u64>().is_err() {
return RequestPolicy::Bypass("request_cache_control_invalid");
}
}
// HTTP/1.0 Pragma: no-cache is a revalidation request when Cache-Control did
// not already define the policy. Other Pragma values do not disable caching.
if !headers.contains_key("cache-control") && pragma_no_cache(headers) {
return RequestPolicy::Revalidate("request_pragma_no_cache");
}
RequestPolicy::Use
}
pub fn request_reason(
method: &Method,
headers: &HeaderMap,
empty_body: bool,
path: &str,
has_query: bool,
settings: &Settings,
) -> Option<&'static str> {
request_policy(method, headers, empty_body, path, has_query, settings).bypass_reason()
}
pub fn request_cacheable(
method: &Method,
headers: &HeaderMap,
empty_body: bool,
path: &str,
has_query: bool,
settings: &Settings,
) -> bool {
!matches!(
request_policy(method, headers, empty_body, path, has_query, settings),
RequestPolicy::Bypass(_)
)
}
fn weak_etag(value: &str) -> Option<&str> {
let value = value.trim();
let value = value
.strip_prefix("W/")
.or_else(|| value.strip_prefix("w/"))
.unwrap_or(value)
.trim();
(value.len() >= 2 && value.starts_with('"') && value.ends_with('"')).then_some(value)
}
fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> Option<bool> {
let target = weak_etag(etag)?;
let values: Vec<_> = headers.get_all("if-none-match").iter().collect();
if values.is_empty() {
return None;
}
for value in values {
let Ok(value) = value.to_str() else {
return Some(false);
};
let mut start = 0usize;
let mut quoted = false;
for (index, ch) in value.char_indices() {
if ch == '"' {
quoted = !quoted;
} else if ch == ',' && !quoted {
let token = value[start..index].trim();
if token == "*" || weak_etag(token).is_some_and(|candidate| candidate == target) {
return Some(true);
}
start = index + 1;
}
}
if quoted {
return Some(false);
}
let token = value[start..].trim();
if token == "*" || weak_etag(token).is_some_and(|candidate| candidate == target) {
return Some(true);
}
}
Some(false)
}
fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
let Some(target) = weak_etag(etag) else {
return false;
};
let values: Vec<_> = headers.get_all("if-none-match").iter().collect();
if values.len() != 1 {
return false;
}
let Ok(value) = values[0].to_str() else {
return false;
};
let mut tokens = value
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty());
let Some(token) = tokens.next() else {
return false;
};
tokens.next().is_none()
&& token != "*"
&& weak_etag(token).is_some_and(|candidate| candidate == target)
}
/// Varnish-like client-side conditional delivery for a selected cached response.
/// If-None-Match takes precedence over If-Modified-Since.
pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool {
if request.contains_key("if-none-match") {
return response
.get("etag")
.and_then(|etag| etag.to_str().ok())
.and_then(|etag| if_none_match_matches(request, etag))
.unwrap_or(false);
}
let Some(since) = request
.get("if-modified-since")
.and_then(|value| value.to_str().ok())
.and_then(|value| httpdate::parse_http_date(value).ok())
else {
return false;
};
response
.get("last-modified")
.and_then(|value| value.to_str().ok())
.and_then(|value| httpdate::parse_http_date(value).ok())
.is_some_and(|modified| modified <= since)
}
pub fn make_not_modified(mut response: Response) -> Response {
*response.status_mut() = StatusCode::NOT_MODIFIED;
*response.body_mut() = Body::empty();
for name in ["content-length", "content-range", "transfer-encoding"] {
response.headers_mut().remove(name);
}
response
}
/// Merge a successful backend 304 into the stored representation metadata.
pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap {
let mut merged = cached.clone();
// A new validation starts a new age calculation. Preserve origin Age only if
// it is explicitly supplied by the 304 response.
if !validation.contains_key("age") {
merged.remove("age");
}
for name in validation.keys() {
if matches!(
name.as_str(),
"content-length" | "content-range" | "transfer-encoding"
) {
continue;
}
merged.remove(name);
for value in validation.get_all(name).iter() {
merged.append(name.clone(), value.clone());
}
}
if !validation.contains_key("date") {
if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() {
merged.insert("date", value);
}
}
merged
}
pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
headers.get_all("vary").iter().any(|value| {
value.to_str().ok().is_some_and(|value| {
value
.split(',')
.any(|name| name.trim().eq_ignore_ascii_case(wanted))
})
})
}
/// Explains rejected storage; it never relaxes origin or transformed policies.
pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str {
if status != StatusCode::OK {
return "response_status";
}
if headers.contains_key("set-cookie") {
return "response_set_cookie";
}
if headers.contains_key("content-range") {
return "response_range";
}
if headers.contains_key("trailer") {
return "response_trailers";
}
let Some(control) = directives(headers) else {
return "response_cache_control_invalid";
};
for key in ["no-store", "private", "must-understand"] {
if control.contains_key(key) {
return "response_cache_control";
}
}
for value in headers.get_all("vary") {
if value.to_str().ok().is_none_or(|value| {
value.split(',').any(|name| {
let name = name.trim();
name == "*" || name.parse::<http::HeaderName>().is_err()
})
}) {
return "response_vary";
}
}
"response_ttl_size_or_metadata"
}
pub struct Plan {
received: Instant,
initial_age: Duration,
ttl: u64,
stale_while_revalidate: u64,
stale_if_error: u64,
must_revalidate: bool,
proxy_revalidate: bool,
expected_length: Option<usize>,
requires_revalidation: bool,
vary: Vec<String>,
}