This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

+2 -570
View File
@@ -1,570 +1,2 @@
/// 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 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>,
}
impl Plan {
pub fn remaining_ttl(&self) -> u64 {
self.ttl.saturating_sub(self.initial_age.as_secs())
}
pub fn without_vary(mut self, name: &str) -> Self {
self.vary.retain(|value| !value.eq_ignore_ascii_case(name));
self
}
pub fn restrict(mut self, transformed: Self) -> Self {
self.ttl = self.ttl.min(transformed.ttl);
self.stale_while_revalidate = self
.stale_while_revalidate
.min(transformed.stale_while_revalidate);
self.stale_if_error = self.stale_if_error.min(transformed.stale_if_error);
self.must_revalidate |= transformed.must_revalidate;
self.proxy_revalidate |= transformed.proxy_revalidate;
self.requires_revalidation |= transformed.requires_revalidation;
self.vary.extend(transformed.vary);
self.vary.sort();
self.vary.dedup();
// The transformed response is the representation that is actually
// written to cache. Its Content-Length therefore replaces the origin
// length; comparing transformed bytes with the origin length rejects a
// valid fill and causes a permanent MISS -> MISS loop.
self.expected_length = transformed.expected_length;
// Express both age clocks at the later observation point before taking
// the stricter value. This avoids resetting or double-counting Age when
// origin headers are transformed inside the proxy.
if transformed.received >= self.received {
let carried = self
.initial_age
.saturating_add(transformed.received.duration_since(self.received));
self.received = transformed.received;
self.initial_age = carried.max(transformed.initial_age);
} else {
let carried = transformed
.initial_age
.saturating_add(self.received.duration_since(transformed.received));
self.initial_age = self.initial_age.max(carried);
}
self
}
}
pub fn plan(
status: StatusCode,
headers: &HeaderMap,
settings: &Settings,
response_delay: Duration,
) -> Option<Plan> {
if ["age", "date", "expires", "content-length"]
.iter()
.any(|name| headers.get_all(*name).iter().count() > 1)
{
return None;
}
if status != StatusCode::OK
|| headers.contains_key("set-cookie")
|| headers.contains_key("content-range")
|| headers.contains_key("trailer")
{
return None;
}
let bytes: usize = headers
.iter()
.map(|(k, v)| k.as_str().len() + v.as_bytes().len() + 32)
.sum();
if bytes > MAX_HEADER_BYTES {
return None;
}
if headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
let v = v.trim_start().to_ascii_lowercase();
v.starts_with("text/event-stream") || v.starts_with("application/grpc")
})
{
return None;
}
// Cache-policy and validator fields govern reuse rather than representation
// identity. Response Vary drives a secondary key, matching Varnish-style
// variants without fragmenting on unrelated request headers.
let vary = vary_names(headers)?;
let control = directives(headers)?;
if ["no-store", "private", "must-understand"]
.iter()
.any(|name| control.contains_key(*name))
{
return None;
}
let requires_revalidation = control.contains_key("no-cache");
let must_revalidate = control.contains_key("must-revalidate");
let proxy_revalidate = control.contains_key("proxy-revalidate");
let parse_window = |name: &str| -> Option<u64> {
match control.get(name) {
None => Some(0),
Some(Some(value)) => value.parse::<u64>().ok().map(|v| v.min(604_800)),
Some(None) => None,
}
};
let stale_while_revalidate = parse_window("stale-while-revalidate")?;
let stale_if_error = parse_window("stale-if-error")?;
if requires_revalidation
&& !headers.contains_key("etag")
&& !headers.contains_key("last-modified")
{
return None;
}
let now = SystemTime::now();
let date = match headers.get("date") {
Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?,
None => now,
};
let declared_ttl =
if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
Some(value.as_ref()?.parse::<u64>().ok()?)
} else if let Some(expires) = headers.get("expires") {
Some(
httpdate::parse_http_date(expires.to_str().ok()?)
.ok()?
.duration_since(date)
.ok()?
.as_secs(),
)
} else if control.contains_key("public") {
Some(settings.default_ttl_seconds)
} else {
None
};
// Keep the freshness lifetime separate from validator/stale retention.
// `no-cache` may be stored with a zero freshness lifetime but must validate
// every reuse; it is retained by the bounded revalidation horizon.
let ttl = match declared_ttl {
Some(value) => value.min(settings.max_ttl_seconds),
None if requires_revalidation => 0,
None => return None,
};
let age = match headers.get("age") {
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0,
};
// Keep sub-second precision internally and floor only when serializing the
// Age header. RFC 9111 adds response delay after correcting received age.
let apparent_age = now.duration_since(date).unwrap_or_default();
// RFC 9111 current_age: corrected_received_age is the greater of Date-based
// apparent age and the received Age value; response delay is then added to
// that result (not only to the Age branch).
let corrected_received_age = apparent_age.max(Duration::from_secs(age));
let initial_age = corrected_received_age.saturating_add(response_delay);
if !requires_revalidation
&& initial_age.as_secs()
>= retention_deadline(
ttl,
stale_while_revalidate,
stale_if_error,
has_validator(headers),
)
{
return None;
}
let expected_length = match headers.get("content-length") {
Some(v) => Some(v.to_str().ok()?.parse::<usize>().ok()?),
None => None,
};
if expected_length.is_some_and(|n| n > settings.max_object_bytes) {
return None;
}
Some(Plan {
received: Instant::now(),
initial_age,
ttl,
stale_while_revalidate,
stale_if_error,
must_revalidate,
proxy_revalidate,
expected_length,
requires_revalidation,
vary,
})
}
include!("policy/request.rs");
include!("policy/response.rs");