Skip to content

Commit 71b6fc3

Browse files
committed
Add cache request header value bypass
1 parent 284127c commit 71b6fc3

6 files changed

Lines changed: 118 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ behavior when the change improves security or project direction.
2222
response headers are present, while still delivering the response normally.
2323
- Cache policies can now bypass lookup and storage when configured request
2424
headers such as `Cookie` or `Authorization` are present.
25+
- Cache policies can now bypass lookup and storage when configured request
26+
header values such as `x-preview-mode = "1"` are present.
2527
- Cache policies can now bypass lookup and storage when configured cookie names
2628
such as `sessionid` or `wordpress_logged_in` are present.
2729
- Cache policies can now bypass lookup and storage when configured cookie

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -633,9 +633,9 @@ without parsing text fixtures for every module.
633633
through typed `status_ttls` and `default_status_ttl_secs` policy;
634634
- explicit request-side bypass rules and response-side no-store rules
635635
based on bounded header, cookie, and query predicates. Implemented for
636-
header-presence request bypass, raw query-parameter request bypass, and
637-
cookie-name and exact cookie-value request bypass, plus response
638-
no-store controls;
636+
header-presence and exact header-value request bypass, raw
637+
query-parameter request bypass, cookie-name and exact cookie-value
638+
request bypass, plus response no-store controls;
639639
- stale serving controls for origin error, timeout, updating, and selected
640640
status classes;
641641
- optional cache-status response headers for production debugging;

docs/config-reference.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ status_header = "X-Cache-Status"
457457
hide_response_headers = ["set-cookie"]
458458
no_store_response_headers = ["x-app-no-store"]
459459
bypass_request_headers = ["cookie", "authorization"]
460+
bypass_request_header_values = { x-preview-mode = "1" }
460461
bypass_cookie_names = ["sessionid", "wordpress_logged_in"]
461462
bypass_cookie_values = { preview = "1" }
462463
bypass_query_params = ["preview", "token"]
@@ -513,6 +514,9 @@ listed request header is present. Use it on routes where a header such as
513514
`Cookie` or `Authorization` changes the upstream response but should not become
514515
part of the shared cache identity. The default is empty so explicit static
515516
asset routes can still cache browser requests that carry unrelated cookies.
517+
`bypass_request_header_values` disables lookup and storage only when a listed
518+
request header has the exact configured value. Use it for bounded flags such as
519+
`x-preview-mode = "1"` when header presence alone is too broad.
516520
`bypass_cookie_names` disables both cache lookup and cache storage when a
517521
listed cookie name appears in any `Cookie` request header. Only names are
518522
matched; values are ignored. This is narrower than bypassing on every `Cookie`
@@ -913,6 +917,7 @@ send_timeout_secs = 600
913917
enabled = true
914918
status_header = "X-Cache-Status"
915919
hide_response_headers = ["set-cookie"]
920+
bypass_request_header_values = { x-preview-mode = "1" }
916921
bypass_cookie_values = { preview = "1" }
917922
status_ttls = { "200" = 3600, "302" = 3600, "404" = 60 }
918923
stale_while_revalidate_secs = 30

examples/vhosts.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ status_header = "X-Cache-Status"
163163
hide_response_headers = ["set-cookie"]
164164
no_store_response_headers = ["x-app-no-store"]
165165
bypass_request_headers = ["cookie", "authorization"]
166+
bypass_request_header_values = { x-preview-mode = "1" }
166167
bypass_cookie_names = ["sessionid", "wordpress_logged_in"]
167168
bypass_cookie_values = { preview = "1" }
168169
bypass_query_params = ["preview", "token"]

src/config.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3179,6 +3179,8 @@ pub struct CacheConfig {
31793179
#[serde(default)]
31803180
pub bypass_request_headers: Vec<String>,
31813181
#[serde(default)]
3182+
pub bypass_request_header_values: BTreeMap<String, String>,
3183+
#[serde(default)]
31823184
pub bypass_cookie_names: Vec<String>,
31833185
#[serde(default)]
31843186
pub bypass_cookie_values: BTreeMap<String, String>,
@@ -3226,6 +3228,7 @@ impl Default for CacheConfig {
32263228
hide_response_headers: Vec::new(),
32273229
no_store_response_headers: Vec::new(),
32283230
bypass_request_headers: Vec::new(),
3231+
bypass_request_header_values: BTreeMap::new(),
32293232
bypass_cookie_names: Vec::new(),
32303233
bypass_cookie_values: BTreeMap::new(),
32313234
bypass_query_params: Vec::new(),
@@ -3271,6 +3274,10 @@ impl CacheConfig {
32713274
for header in &self.bypass_request_headers {
32723275
validate_header_name(scope, header)?;
32733276
}
3277+
for (header, value) in &self.bypass_request_header_values {
3278+
validate_header_name(scope, header)?;
3279+
validate_cache_bypass_request_header_value(scope, header, value)?;
3280+
}
32743281
for cookie in &self.bypass_cookie_names {
32753282
validate_cache_cookie_name(scope, cookie)?;
32763283
}
@@ -3413,6 +3420,27 @@ fn validate_cache_query_param(scope: &'static str, param: &str) -> Result<(), Co
34133420
Ok(())
34143421
}
34153422

3423+
fn validate_cache_bypass_request_header_value(
3424+
scope: &'static str,
3425+
header: &str,
3426+
value: &str,
3427+
) -> Result<(), ConfigError> {
3428+
if value.trim().is_empty()
3429+
|| value.len() > 4096
3430+
|| value
3431+
.as_bytes()
3432+
.iter()
3433+
.any(|byte| matches!(byte, 0x00..=0x08 | 0x0a..=0x1f | 0x7f))
3434+
{
3435+
return Err(ConfigError::InvalidCacheBypassRequestHeaderValue {
3436+
scope,
3437+
header: header.to_owned(),
3438+
value: value.to_owned(),
3439+
});
3440+
}
3441+
Ok(())
3442+
}
3443+
34163444
fn validate_cache_cookie_name(scope: &'static str, name: &str) -> Result<(), ConfigError> {
34173445
if name.is_empty() || name.len() > 128 || !valid_cookie_name(name) {
34183446
return Err(ConfigError::InvalidCacheBypassCookieName {
@@ -3970,6 +3998,11 @@ pub enum ConfigError {
39703998
scope: &'static str,
39713999
param: String,
39724000
},
4001+
InvalidCacheBypassRequestHeaderValue {
4002+
scope: &'static str,
4003+
header: String,
4004+
value: String,
4005+
},
39734006
InvalidCacheBypassCookieName {
39744007
scope: &'static str,
39754008
name: String,
@@ -4395,6 +4428,14 @@ impl Display for ConfigError {
43954428
formatter,
43964429
"{scope}.bypass_query_params must contain raw query parameter names without whitespace, controls, '&', '=', '#', '?', or ';', got {param:?}"
43974430
),
4431+
Self::InvalidCacheBypassRequestHeaderValue {
4432+
scope,
4433+
header,
4434+
value,
4435+
} => write!(
4436+
formatter,
4437+
"{scope}.bypass_request_header_values[{header:?}] must contain a non-empty safe header value without controls, got {value:?}"
4438+
),
43984439
Self::InvalidCacheBypassCookieName { scope, name } => write!(
43994440
formatter,
44004441
"{scope}.bypass_cookie_names must contain cookie name tokens without whitespace or separators, got {name:?}"
@@ -7241,6 +7282,7 @@ mod tests {
72417282
hide_response_headers = ["set-cookie"]
72427283
no_store_response_headers = ["x-fluxheim-no-store"]
72437284
bypass_request_headers = ["cookie", "authorization"]
7285+
bypass_request_header_values = { x-preview-mode = "1" }
72447286
bypass_cookie_names = ["sessionid", "wordpress_logged_in"]
72457287
bypass_cookie_values = { preview = "1" }
72467288
bypass_query_params = ["preview", "token"]
@@ -7292,6 +7334,13 @@ mod tests {
72927334
config.cache.bypass_request_headers,
72937335
["cookie".to_owned(), "authorization".to_owned()]
72947336
);
7337+
assert_eq!(
7338+
config
7339+
.cache
7340+
.bypass_request_header_values
7341+
.get("x-preview-mode"),
7342+
Some(&"1".to_owned())
7343+
);
72957344
assert_eq!(
72967345
config.cache.bypass_cookie_names,
72977346
["sessionid".to_owned(), "wordpress_logged_in".to_owned()]
@@ -7409,6 +7458,28 @@ mod tests {
74097458
);
74107459
}
74117460

7461+
#[test]
7462+
fn rejects_invalid_cache_bypass_request_header_value() {
7463+
for value in ["", " ", "bad\nvalue"] {
7464+
let config: Config = toml::from_str(&format!(
7465+
r#"
7466+
[cache]
7467+
bypass_request_header_values = {{ x-preview-mode = {value:?} }}
7468+
"#,
7469+
))
7470+
.unwrap();
7471+
7472+
assert_eq!(
7473+
config.validate(),
7474+
Err(ConfigError::InvalidCacheBypassRequestHeaderValue {
7475+
scope: "cache",
7476+
header: "x-preview-mode".to_owned(),
7477+
value: value.to_owned()
7478+
})
7479+
);
7480+
}
7481+
}
7482+
74127483
#[test]
74137484
fn rejects_invalid_cache_no_store_response_header_name() {
74147485
let config: Config = toml::from_str(

src/proxy.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2939,6 +2939,9 @@ fn request_cache_bypass(request: &RequestHeader, cache: &crate::config::CacheCon
29392939
{
29402940
return true;
29412941
}
2942+
if request_headers_match_cache_bypass_value(request, &cache.bypass_request_header_values) {
2943+
return true;
2944+
}
29422945
if request_cookies_match_cache_bypass(
29432946
request_header_values(request, "cookie"),
29442947
&cache.bypass_cookie_names,
@@ -2960,6 +2963,17 @@ fn request_cache_bypass(request: &RequestHeader, cache: &crate::config::CacheCon
29602963
)
29612964
}
29622965

2966+
#[cfg(feature = "cache")]
2967+
fn request_headers_match_cache_bypass_value(
2968+
request: &RequestHeader,
2969+
configured_values: &std::collections::BTreeMap<String, String>,
2970+
) -> bool {
2971+
!configured_values.is_empty()
2972+
&& configured_values.iter().any(|(header, configured)| {
2973+
request_header_values(request, header).any(|value| value == configured)
2974+
})
2975+
}
2976+
29632977
#[cfg(feature = "cache")]
29642978
fn request_cookies_match_cache_bypass<'a>(
29652979
cookie_headers: impl Iterator<Item = &'a str>,
@@ -5080,6 +5094,28 @@ mod tests {
50805094
assert!(request_cache_bypass(&request, &cache));
50815095
}
50825096

5097+
#[cfg(feature = "cache")]
5098+
#[test]
5099+
fn request_cache_bypass_honors_configured_request_header_values() {
5100+
let cache = CacheConfig {
5101+
bypass_request_header_values: [("x-preview-mode".to_owned(), "1".to_owned())].into(),
5102+
..CacheConfig::default()
5103+
};
5104+
5105+
let mut request =
5106+
pingora::http::RequestHeader::build("GET", b"/assets/app.js", None).unwrap();
5107+
assert!(!request_cache_bypass(&request, &cache));
5108+
5109+
request.insert_header("x-preview-mode", "0").unwrap();
5110+
assert!(!request_cache_bypass(&request, &cache));
5111+
5112+
let mut request =
5113+
pingora::http::RequestHeader::build("GET", b"/assets/app.js", None).unwrap();
5114+
request.append_header("x-preview-mode", "0").unwrap();
5115+
request.append_header("x-preview-mode", "1").unwrap();
5116+
assert!(request_cache_bypass(&request, &cache));
5117+
}
5118+
50835119
#[cfg(feature = "cache")]
50845120
#[test]
50855121
fn request_cache_bypass_honors_configured_cookie_names() {

0 commit comments

Comments
 (0)