v0.34.0_12

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-18 10:11:16 +02:00
1 parent 943d51b528
commit 9f20d5195e
14 files changed
+198 -57

No files matched your search

+14 -7
View File
@@ -100,7 +100,7 @@ fn cloudflare_peer(peer: SocketAddr) -> bool {
/// Return CF-Connecting-IP only when the TCP peer itself is in Cloudflare's
/// published edge ranges. The header is otherwise untrusted user input.
pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option<IpAddr> {
if !cloudflare_peer(peer) {
if !headers.contains_key("cf-connecting-ip") || !cloudflare_peer(peer) {
return None;
}
let mut values = headers.get_all("cf-connecting-ip").iter();
@@ -120,7 +120,7 @@ pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option<IpA
/// Return a trusted ISO country code supplied by Cloudflare. `CF-IPCountry` is
/// accepted only from a Cloudflare edge and only as one two-letter code.
pub fn cloudflare_country(headers: &HeaderMap, peer: SocketAddr) -> Option<String> {
if !cloudflare_peer(peer) {
if !headers.contains_key("cf-ipcountry") || !cloudflare_peer(peer) {
return None;
}
let mut values = headers.get_all("cf-ipcountry").iter();
@@ -319,11 +319,18 @@ impl Compiled {
values,
});
}
let mut ips = chain(incoming).unwrap_or_default();
ips.push(peer_ip);
// Discard everything left of the first untrusted hop walking from us.
let first = ips.iter().rposition(|ip| !self.trusted(*ip)).unwrap_or(0);
ips.drain(..first);
let mut proxy_chain = chain(incoming).unwrap_or_default();
// No forwarded hops: borrow the actual peer from the stack instead of
// allocating a one-element Vec on every trusted-proxy request.
let direct_peer = [peer_ip];
let ips: &[IpAddr] = if proxy_chain.is_empty() {
&direct_peer
} else {
proxy_chain.push(peer_ip);
// Discard untrusted upstream prefixes without shifting the Vec.
let first = proxy_chain.iter().rposition(|ip| !self.trusted(*ip)).unwrap_or(0);
&proxy_chain[first..]
};
let client_ip = ips[0];
let mut scheme = if tls { "https" } else { "http" };
if trusted_peer
+11 -1
View File
@@ -1,5 +1,15 @@
use serde::Serialize;
use std::sync::atomic::{AtomicU64, Ordering};
/// Isolate a high-contention counter without spreading every request-level
/// atomic across a separate cache line. Existing read/write API is unchanged.
#[repr(align(64))]
#[derive(Default, Debug)]
pub struct CacheAlignedAtomicU64(AtomicU64);
impl std::ops::Deref for CacheAlignedAtomicU64 {
type Target = AtomicU64;
fn deref(&self) -> &Self::Target { &self.0 }
}
#[derive(Default)]
pub struct Metrics {
pub admin_requests: AtomicU64,
@@ -35,7 +45,7 @@ pub struct Metrics {
pub websocket_bytes_in: AtomicU64,
pub websocket_bytes_out: AtomicU64,
pub websocket_duration_us: AtomicU64,
pub requests: AtomicU64,
pub requests: CacheAlignedAtomicU64,
pub completed: AtomicU64,
pub active: AtomicU64,
pub errors: AtomicU64,
+1 -1
View File
@@ -8,7 +8,7 @@ pub struct VhostMetrics {
pub bytes_in: AtomicU64,
pub bytes_out: AtomicU64,
pub duration_us: AtomicU64,
pub ewma_duration_us: AtomicU64,
pub ewma_duration_us: CacheAlignedAtomicU64,
pub status: [AtomicU64; 5],
pub buckets: [AtomicU64; 11],
}
+14
View File
@@ -63,4 +63,18 @@ mod compression_counter_tests {
assert_eq!(view.compression_zstd, 1);
assert_eq!(view.compression_deflate, 1);
}
#[test]
fn cache_aligned_counters_keep_exact_values_and_separate_hot_lines() {
let m = VhostMetrics::default();
let ewma = &m.ewma_duration_us as *const _ as usize;
assert_eq!(ewma % 64, 0);
m.active.fetch_add(1, Ordering::Relaxed);
m.requests.fetch_add(1, Ordering::Relaxed);
m.complete(200, false, std::time::Duration::from_micros(100), 20, 1040);
assert_eq!(n(&m.completed), 1);
assert_eq!(n(&m.active), 0);
assert_eq!(n(&m.requests), 1);
assert_eq!(n(&m.bytes_out), 1040);
assert_eq!(n(&m.ewma_duration_us), 100);
}
}
+16
View File
@@ -282,6 +282,22 @@ fn finish_headers(
crate::http3::advertise(response.headers_mut(), local, tls, active);
}
}
fn attach_origin_diagnostics(
response: &mut Response,
frontend: Option<&crate::config::CompiledFrontend>,
elapsed: Duration,
backend_id: &str,
) {
if let Some(frontend) = frontend {
if frontend.timing_headers || frontend.backend_timing_header {
response.extensions_mut().insert(OriginTiming(elapsed));
}
if frontend.backend_header {
response.extensions_mut().insert(OriginBackend(backend_id.to_owned()));
}
}
}
fn apply_timing_headers(
response: &mut Response,
frontend: Option<&crate::config::CompiledFrontend>,
+1 -4
View File
@@ -179,10 +179,7 @@ let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str
hit.resident_age_seconds,
);
hit.response.extensions_mut().insert(ResponseRulesApplied);
hit.response.extensions_mut().insert(OriginTiming(elapsed));
hit.response
.extensions_mut()
.insert(OriginBackend(origin_backend.to_owned()));
attach_origin_diagnostics(&mut hit.response, route.frontend.as_deref(), elapsed, origin_backend);
state.cache.record(scope, &host, "HIT", reason);
if let Some(exchange) = &exchange {
exchange.cache_status.store(1, Ordering::Relaxed);
+2 -6
View File
@@ -73,12 +73,8 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
revalidated = crate::cache::make_not_modified(revalidated);
}
revalidated.extensions_mut().insert(ResponseRulesApplied);
revalidated
.extensions_mut()
.insert(OriginTiming(upstream_started.elapsed()));
revalidated
.extensions_mut()
.insert(OriginBackend(active_backend.id.clone()));
attach_origin_diagnostics(&mut revalidated, route.frontend.as_deref(),
upstream_started.elapsed(), &active_backend.id);
if let Some(exchange) = &exchange {
exchange.cache_status.store(1, Ordering::Relaxed);
*exchange
+2 -6
View File
@@ -366,12 +366,8 @@ if intercept_origin_error {
);
}
response.extensions_mut().insert(ResponseRulesApplied);
response
.extensions_mut()
.insert(OriginTiming(upstream_started.elapsed()));
response
.extensions_mut()
.insert(OriginBackend(active_backend.id.clone()));
attach_origin_diagnostics(&mut response, route.frontend.as_deref(),
upstream_started.elapsed(), &active_backend.id);
if !range_requested && handshake.is_none() {
if let Some(settings) = transform_settings.filter(|settings| settings.enabled) {
let transform_started = Instant::now();
+28
View File
@@ -409,6 +409,34 @@ mod authority_tests {
assert!(std::str::from_utf8(detail.as_bytes()).is_ok());
}
#[test]
fn origin_diagnostics_are_created_only_for_enabled_frontend_features() {
let mut response = Response::new(Body::empty());
attach_origin_diagnostics(&mut response, None, Duration::from_millis(5), "origin-a");
assert!(response.extensions().get::<OriginTiming>().is_none());
assert!(response.extensions().get::<OriginBackend>().is_none());
for (timing, backend, backend_timing) in [
(false, false, false),
(true, false, false),
(false, true, false),
(false, false, true),
(true, true, true),
] {
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].timing_headers = timing;
config.vhosts[0].backend_header = backend;
config.vhosts[0].backend_timing_header = backend_timing;
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
let route = snapshot.select("localhost", "/").unwrap();
let mut response = Response::new(Body::empty());
attach_origin_diagnostics(&mut response, route.frontend.as_deref(),
Duration::from_millis(5), "origin-a");
assert_eq!(response.extensions().get::<OriginTiming>().is_some(),
timing || backend_timing);
assert_eq!(response.extensions().get::<OriginBackend>().is_some(), backend);
}
}
#[test]
fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() {
let mut response = Response::new(Body::empty());
+12 -7
View File
@@ -211,13 +211,18 @@ impl Settings {
self.country_access.allows(country)
}
fn effective(&self, country: Option<&str>, user_agent: Option<&str>) -> Option<(Limit, Option<usize>)> {
// Matching only needs a bounded copy. Oversized headers cannot force
// unbounded work in the hot request path.
if let Some(agent) = user_agent.filter(|ua| ua.len() <= MAX_USER_AGENT_BYTES) {
let candidate = agent.to_ascii_lowercase();
if let Some((index, rule)) = self.user_agent_limits.iter().enumerate()
.find(|(_, rule)| crate::user_agent::wildcard_match(&candidate, &rule.pattern)) {
return Some((Limit { requests: rule.requests, window_seconds: rule.window_seconds, burst: rule.burst }, Some(index)));
// With no UA overrides the common request never allocates a lowercase
// copy (and oversized headers still skip matching entirely).
if !self.user_agent_limits.is_empty() {
if let Some(agent) = user_agent.filter(|ua| ua.len() <= MAX_USER_AGENT_BYTES) {
if let Some((index, rule)) = self.user_agent_limits.iter().enumerate()
.find(|(_, rule)| crate::user_agent::wildcard_match_case_insensitive(agent, &rule.pattern))
{
return Some((Limit {
requests: rule.requests, window_seconds: rule.window_seconds,
burst: rule.burst,
}, Some(index)));
}
}
}
if let Some(country) = country {
+28
View File
@@ -157,4 +157,32 @@ mod tests {
assert!(settings.validate().is_ok());
}
#[test]
fn no_ua_rules_do_not_create_buckets_when_base_limiter_is_off() {
let settings = Settings::default();
let limiter = Limiter::new();
let ip = "192.0.2.34".parse().unwrap();
for _ in 0..32 {
assert!(limiter.check_with_user_agent(1, "site", ip, None,
Some("Mozilla/5.0 (compatible; Requests)"), &settings).is_ok());
}
assert!(lock(&limiter.state).buckets.is_empty());
}
#[test]
fn rate_limit_ua_wildcards_preserve_case_insensitive_quotas() {
let mut settings = Settings { user_agent_limits: vec![UserAgentLimit {
pattern: "*Scanner*Bot".into(), requests: 1, window_seconds: 60,
burst: 0,
}], ..Default::default() };
settings.validate().unwrap();
let limiter = Limiter::new();
let ip = "192.0.2.53".parse().unwrap();
assert!(limiter.check_with_user_agent(1, "site", ip, None,
Some("ACME-SCANNER-XXbot"), &settings).is_ok());
assert!(limiter.check_with_user_agent(1, "site", ip, None,
Some("acme-scanner-XXBOT"), &settings).is_err());
assert!(limiter.check_with_user_agent(1, "site", ip, None,
Some("ordinary-browser"), &settings).is_ok());
}
}
+35 -12
View File
@@ -76,24 +76,47 @@ pub(crate) fn wildcard_match(candidate: &str, pattern: &str) -> bool {
}
let starts_wild = pattern.starts_with('*');
let ends_wild = pattern.ends_with('*');
let parts = pattern
.split('*')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.is_empty() {
return true;
}
let mut cursor = 0usize;
for (index, part) in parts.iter().enumerate() {
let mut found_any = false;
for part in pattern.split('*').filter(|part| !part.is_empty()) {
let Some(found) = candidate[cursor..].find(part) else {
return false;
};
if index == 0 && !starts_wild && found != 0 {
if !found_any && !starts_wild && found != 0 {
return false;
}
found_any = true;
cursor += found + part.len();
}
ends_wild || cursor == candidate.len()
!found_any || ends_wild || cursor == candidate.len()
}
/// Match normalized policy patterns against ASCII agents without allocating
/// a lowercased request value. Non-ASCII inputs retain the original matcher.
pub(crate) fn wildcard_match_case_insensitive(candidate: &str, pattern: &str) -> bool {
if !candidate.is_ascii() || !pattern.is_ascii() {
return wildcard_match(&candidate.to_ascii_lowercase(), pattern);
}
let text = candidate.as_bytes();
let pattern_bytes = pattern.as_bytes();
if pattern_bytes.is_empty() { return true; }
let find_part = |from: usize, part: &[u8]| -> Option<usize> {
text[from..].windows(part.len()).position(|window| window.eq_ignore_ascii_case(part))
};
if !pattern_bytes.contains(&b'*') {
return find_part(0, pattern_bytes).is_some();
}
let starts_wild = pattern_bytes.first() == Some(&b'*');
let ends_wild = pattern_bytes.last() == Some(&b'*');
let mut cursor = 0usize;
let mut found_any = false;
for part in pattern_bytes.split(|byte| *byte == b'*').filter(|part| !part.is_empty()) {
let Some(found) = find_part(cursor, part) else { return false; };
if !found_any && !starts_wild && found != 0 { return false; }
found_any = true;
cursor += found + part.len();
}
!found_any || ends_wild || cursor == text.len()
}
impl CompiledPolicy {
@@ -104,10 +127,10 @@ impl CompiledPolicy {
let Some(value) = headers.get(USER_AGENT) else {
return false;
};
let candidate = String::from_utf8_lossy(value.as_bytes()).to_ascii_lowercase();
let candidate = String::from_utf8_lossy(value.as_bytes());
self.patterns
.iter()
.any(|pattern| wildcard_match(&candidate, pattern))
.any(|pattern| wildcard_match_case_insensitive(&candidate, pattern))
}
}
+21
View File
@@ -51,3 +51,24 @@
.unwrap();
assert!(!enabled.blocks(&headers(None)));
}
#[test]
fn allocation_free_ascii_matcher_matches_original_policy() {
for candidate in ["", "Mozilla/5.0", "CURL/8.4.0", "AcmeScanner/1",
"abc-DEF-ghi", "aBcXDeFz", "Content:Token", "żółw TOKEN"] {
for pattern in ["*", "curl/*", "*scanner*", "abc*ghi", "abc*def*", "*def",
"token", "missing", "mozilla", "*x*d*f*", "żółw*"] {
let normalized = pattern.to_ascii_lowercase();
assert_eq!(
wildcard_match_case_insensitive(candidate, &normalized),
wildcard_match(&candidate.to_ascii_lowercase(), &normalized),
"{candidate:?} vs {pattern:?}",
);
}
}
let policy = Policy { enabled: true,
patterns: vec!["*bot*".into(), "curl/*".into()] }.compile().unwrap();
assert!(policy.blocks(&headers(Some("GOOD-BOT/1.0"))));
assert!(policy.blocks(&headers(Some("CURL/9.0"))));
assert!(!policy.blocks(&headers(Some("Mozilla/5.0"))));
}