This commit is contained in:
Mateusz Gruszczyński committed 2026-10-06 23:54:01 +02:00
1 parent 9d6614c1b4
commit a9f9450f80
16 files changed
+171 -47

No files matched your search

+3 -3
View File
@@ -2172,9 +2172,9 @@
type="checkbox" /><span>Cache-Status (RFC 9211)</span></label><label
class="check"><input id="cache-resident-age-header"
type="checkbox" /><span>X-Cache-Resident-Age</span></label></div>
<p class="fine" data-i18n="cacheAgeHelp">Age is always sent on cache hits. Counters
remain available even when diagnostic headers are hidden. Every configuration
apply invalidates the cache.</p>
<p class="fine" data-i18n="cacheAgeHelp">Age is sent on cache hits unless an
explicit response-header rule removes or replaces it. Such Age rules are
wire-only and do not change cache freshness.</p>
</div>
<div class="form-actions"><button class="primary" data-i18n="saveCache"
type="submit">Apply cache policy</button><button class="outline"
+1 -1
View File
@@ -72,7 +72,7 @@
for (const direction of ["request", "response"]) {
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
const name = rule.name.toLowerCase(); if (["host", "age", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
}
} return value;
+36 -15
View File
@@ -31,6 +31,7 @@ pub struct CompiledRule {
op: Operation,
name: HeaderName,
value: Option<HeaderValue>,
wire_only: bool,
}
#[derive(Debug, Clone, Default)]
pub struct CompiledHeaders {
@@ -43,16 +44,20 @@ impl HeaderRules {
}
pub fn compile(&self) -> Result<CompiledHeaders> {
Ok(CompiledHeaders {
request: compile_rules(&self.request)?,
response: compile_rules(&self.response)?,
request: compile_rules(&self.request, Direction::Request)?,
response: compile_rules(&self.response, Direction::Response)?,
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Direction {
Request,
Response,
}
fn protected(name: &str) -> bool {
matches!(
name,
"host"
| "age"
| "connection"
| "content-length"
| "transfer-encoding"
@@ -74,7 +79,7 @@ fn protected(name: &str) -> bool {
) || name.starts_with("x-forwarded-")
|| name.starts_with("sec-websocket-")
}
fn compile_rules(rules: &[Rule]) -> Result<Vec<CompiledRule>> {
fn compile_rules(rules: &[Rule], direction: Direction) -> Result<Vec<CompiledRule>> {
ensure!(
rules.len() <= 32,
"at most 32 header rules per direction and scope"
@@ -102,23 +107,39 @@ fn compile_rules(rules: &[Rule]) -> Result<Vec<CompiledRule>> {
op: rule.op,
name,
value,
wire_only: direction == Direction::Response
&& rule.name.eq_ignore_ascii_case("age"),
})
})
.collect()
}
fn apply_rule(headers: &mut HeaderMap, rule: &CompiledRule) {
match (&rule.op, &rule.value) {
(Operation::Remove, _) => {
headers.remove(&rule.name);
}
(Operation::Set, Some(value)) => {
headers.insert(rule.name.clone(), value.clone());
}
(Operation::Append, Some(value)) => {
headers.append(rule.name.clone(), value.clone());
}
_ => unreachable!("only compiled header rules reach the request path"),
}
}
pub fn apply(headers: &mut HeaderMap, rules: &[CompiledRule]) {
for rule in rules {
match (&rule.op, &rule.value) {
(Operation::Remove, _) => {
headers.remove(&rule.name);
}
(Operation::Set, Some(value)) => {
headers.insert(rule.name.clone(), value.clone());
}
(Operation::Append, Some(value)) => {
headers.append(rule.name.clone(), value.clone());
}
_ => unreachable!("only compiled header rules reach the request path"),
// Response Age rules are a wire-only presentation override. Applying
// them here would let a configured value enter cache freshness/storage.
if !rule.wire_only {
apply_rule(headers, rule);
}
}
}
pub fn apply_wire_age(headers: &mut HeaderMap, rules: &[CompiledRule]) {
for rule in rules {
if rule.wire_only {
apply_rule(headers, rule);
}
}
}
+58
View File
@@ -45,4 +45,62 @@ mod tests {
};
assert!(rules.compile().is_err());
}
#[test]
fn response_age_is_allowed_but_applied_only_at_wire_stage() {
let request_age = HeaderRules {
request: vec![Rule {
op: Operation::Set,
name: "Age".into(),
value: Some("7".into()),
}],
response: vec![],
};
let request_compiled = request_age.compile().unwrap();
let mut request_headers = HeaderMap::new();
apply(&mut request_headers, &request_compiled.request);
assert_eq!(request_headers["age"], "7");
let response_age = HeaderRules {
request: vec![],
response: vec![Rule {
op: Operation::Remove,
name: "Age".into(),
value: None,
}],
};
let compiled = response_age.compile().unwrap();
let mut headers = HeaderMap::new();
headers.insert("age", HeaderValue::from_static("42"));
apply(&mut headers, &compiled.response);
assert_eq!(headers["age"], "42");
apply_wire_age(&mut headers, &compiled.response);
assert!(!headers.contains_key("age"));
}
#[test]
fn response_age_keeps_0266_rule_compatibility() {
let rules = HeaderRules {
request: vec![],
response: vec![
Rule {
op: Operation::Set,
name: "age".into(),
value: Some("custom".into()),
},
Rule {
op: Operation::Append,
name: "age".into(),
value: Some("7".into()),
},
],
};
let compiled = rules.compile().unwrap();
let mut headers = HeaderMap::new();
headers.insert("age", HeaderValue::from_static("42"));
apply(&mut headers, &compiled.response);
assert_eq!(headers["age"], "42");
apply_wire_age(&mut headers, &compiled.response);
assert_eq!(headers.get_all("age").iter().count(), 2);
}
}
+14
View File
@@ -219,6 +219,20 @@ fn apply_response_rules(
headers::strip_admin_set_cookie(headers);
}
}
fn apply_response_age_rules(
headers: &mut http::HeaderMap,
snapshot: &crate::config::Snapshot,
frontend: Option<&crate::config::CompiledFrontend>,
route: Option<&crate::config::CompiledRoute>,
) {
crate::headers::apply_wire_age(headers, &snapshot.headers.response);
if let Some(frontend) = frontend {
crate::headers::apply_wire_age(headers, &frontend.headers.response);
}
if let Some(route) = route {
crate::headers::apply_wire_age(headers, &route.headers.response);
}
}
/// Final policies apply to cache hits, errors, static pages and ALL transports.
fn finish_headers(
response: &mut Response,
+14
View File
@@ -87,6 +87,12 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
&request_id,
&state.active_listeners,
);
apply_response_age_rules(
response.headers_mut(),
&snapshot,
frontend_context.as_deref(),
route_context.as_ref(),
);
return response;
};
state.metrics.active.fetch_add(1, Ordering::Relaxed);
@@ -356,6 +362,14 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
cache.annotate_transformed(response.headers_mut(), transformed);
}
}
if !minimal_welcome {
apply_response_age_rules(
response.headers_mut(),
&snapshot,
frontend_context.as_deref(),
route_context.as_ref(),
);
}
if response
.extensions()
.get::<crate::pages::PageServed>()