v0.34.0_12
This commit is contained in:
1 parent
943d51b528
commit
9f20d5195e
14 files changed
+198
-57
No files matched your search
+14
-7
@@ -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
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))));
|
||||
}
|
||||
Reference in new issue
Block a user