440 lines
14 KiB
Rust
440 lines
14 KiB
Rust
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('"') {
|
|
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" {
|
|
result.entry(key).or_insert(None);
|
|
} else if result.insert(key, value).is_some() {
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
Some(result)
|
|
}
|
|
pub fn request_freshness_headers<'a>(
|
|
headers: &'a HeaderMap,
|
|
settings: &Settings,
|
|
) -> std::borrow::Cow<'a, HeaderMap> {
|
|
if !settings.varnish_compatible
|
|
|| (!headers.contains_key("cache-control") && !headers.contains_key("pragma"))
|
|
{
|
|
return std::borrow::Cow::Borrowed(headers);
|
|
}
|
|
let mut freshness = headers.clone();
|
|
freshness.remove("cache-control");
|
|
freshness.remove("pragma");
|
|
std::borrow::Cow::Owned(freshness)
|
|
}
|
|
|
|
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"),
|
|
("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);
|
|
}
|
|
}
|
|
// Varnish builtin.vcl performs a cache lookup without interpreting
|
|
// client Cache-Control or Pragma. Security bypasses above still apply.
|
|
if settings.varnish_compatible {
|
|
return RequestPolicy::Use;
|
|
}
|
|
let Some(control) = directives(headers) else {
|
|
return RequestPolicy::Bypass("request_cache_control_invalid");
|
|
};
|
|
if control.contains_key("no-store") {
|
|
return RequestPolicy::Bypass("request_no_store");
|
|
}
|
|
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");
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap {
|
|
let mut merged = cached.clone();
|
|
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))
|
|
})
|
|
})
|
|
}
|
|
|
|
pub fn surrogate_no_store(headers: &HeaderMap) -> bool {
|
|
headers.get_all("surrogate-control").iter().any(|value| {
|
|
value.to_str().ok().is_some_and(|text| {
|
|
text.split([',', ';']).any(|part| {
|
|
part.trim()
|
|
.split_once('=')
|
|
.map_or(part.trim(), |(key, _)| key.trim())
|
|
.eq_ignore_ascii_case("no-store")
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
pub fn response_reason(
|
|
status: StatusCode,
|
|
headers: &HeaderMap,
|
|
settings: &Settings,
|
|
) -> &'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";
|
|
}
|
|
}
|
|
if settings.varnish_compatible {
|
|
if control.contains_key("no-cache") {
|
|
return "response_cache_control";
|
|
}
|
|
if surrogate_no_store(headers) {
|
|
return "response_surrogate_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>,
|
|
}
|