v0.29.0
This commit is contained in:
1 parent
65e082b9d1
commit
76cb4acef0
72 files changed
+6134
-4709
No files matched your search
Vendored
+42
@@ -277,6 +277,45 @@ fn valid_relative_cache_path(value: &str) -> bool {
|
||||
.all(|part| matches!(part, Component::Normal(_)))
|
||||
}
|
||||
|
||||
/// Explicit, per-vhost-only request-side cache override. It never weakens the
|
||||
/// response-side privacy guards (Set-Cookie/private/no-store/Vary rules).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct UnsafeForce {
|
||||
pub enabled: bool,
|
||||
pub targets: Vec<String>,
|
||||
}
|
||||
impl UnsafeForce {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!(self.targets.len() <= 32, "forced cache supports at most 32 target prefixes");
|
||||
for target in &self.targets {
|
||||
ensure!(
|
||||
!target.is_empty()
|
||||
&& target.len() <= 2048
|
||||
&& target.starts_with('/')
|
||||
&& !target.contains('#')
|
||||
&& !target.chars().any(char::is_control),
|
||||
"forced cache targets must be 1..2048 byte origin-form path/query prefixes"
|
||||
);
|
||||
let path = target.split('?').next().unwrap_or(target);
|
||||
ensure!(crate::acl::routing_path(path)? == path, "forced cache target path must be normalized");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn matches(&self, path: &str, target: &str) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
self.targets.iter().any(|configured| {
|
||||
if configured.contains('?') {
|
||||
target.starts_with(configured)
|
||||
} else {
|
||||
crate::config::prefix_matches(configured, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-vhost behavior, not independent memory pools. None inherits the global
|
||||
/// default; the global resident-byte/entry/object ceilings remain hard limits.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -294,9 +333,12 @@ pub struct Overrides {
|
||||
pub expose_hits: Option<bool>,
|
||||
pub expose_cache_status: Option<bool>,
|
||||
pub expose_resident_age: Option<bool>,
|
||||
/// Deliberately unavailable in the global cache configuration.
|
||||
pub unsafe_force: UnsafeForce,
|
||||
}
|
||||
impl Overrides {
|
||||
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
|
||||
self.unsafe_force.validate()?;
|
||||
let mut result = global.clone();
|
||||
// A vhost may narrow the global policy, but can never re-enable a cache
|
||||
// that the administrator disabled globally.
|
||||
|
||||
Vendored
+25
@@ -92,6 +92,31 @@ fn pragma_no_cache(headers: &HeaderMap) -> bool {
|
||||
.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,
|
||||
|
||||
Vendored
+8
@@ -3,6 +3,14 @@ impl Plan {
|
||||
self.ttl.saturating_sub(self.initial_age.as_secs())
|
||||
}
|
||||
|
||||
pub fn with_vary(mut self, name: &str) -> Self {
|
||||
if !self.vary.iter().any(|value| value.eq_ignore_ascii_case(name)) {
|
||||
self.vary.push(name.to_ascii_lowercase());
|
||||
self.vary.sort();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn without_vary(mut self, name: &str) -> Self {
|
||||
self.vary.retain(|value| !value.eq_ignore_ascii_case(name));
|
||||
self
|
||||
|
||||
Vendored
+37
@@ -97,3 +97,40 @@ mod scope_tests {
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_force_cache_is_per_vhost_and_matches_path_or_target_prefix() {
|
||||
let force = UnsafeForce {
|
||||
enabled: true,
|
||||
targets: vec!["/convert?url=".into(), "/assets".into()],
|
||||
};
|
||||
force.validate().unwrap();
|
||||
assert!(force.matches(
|
||||
"/convert",
|
||||
"/convert?url=https%3A%2F%2Fexample.test%2Flist.txt"
|
||||
));
|
||||
assert!(force.matches("/assets/app.js", "/assets/app.js?v=1"));
|
||||
assert!(!force.matches("/api", "/api?url=x"));
|
||||
assert!(UnsafeForce {
|
||||
enabled: true,
|
||||
targets: vec!["https://example.test/".into()],
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_request_policy_keeps_protocol_safety_but_ignores_cookie_and_client_no_store() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", HeaderValue::from_static("session=abc"));
|
||||
headers.insert("authorization", HeaderValue::from_static("Bearer token"));
|
||||
headers.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
assert_eq!(
|
||||
forced_request_policy(&Method::GET, &headers, true),
|
||||
RequestPolicy::Use
|
||||
);
|
||||
assert_eq!(
|
||||
forced_request_policy(&Method::POST, &headers, true),
|
||||
RequestPolicy::Bypass("request_method")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user