v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
Vendored
+2
-570
@@ -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");
|
||||
Reference in new issue
Block a user