From 9f20d5195e8dfaeb1b30019e31888b46b898c03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 18 Oct 2026 10:11:16 +0200 Subject: [PATCH] v0.34.0_12 --- SHA256SUMS | 26 +++++++-------- src/forwarding.rs | 21 ++++++++----- src/metrics/global.rs | 12 ++++++- src/metrics/vhost.rs | 2 +- src/metrics/vhost/tests.rs | 14 +++++++++ src/proxy/cache_helpers.rs | 16 ++++++++++ src/proxy/forward/cache_lookup.rs | 5 +-- src/proxy/forward/success_finish.rs | 8 ++--- src/proxy/forward/success_prepare.rs | 8 ++--- src/proxy/tests/authority_tests.rs | 28 +++++++++++++++++ src/rate_limit.rs | 19 ++++++----- src/rate_limit/tests.rs | 28 +++++++++++++++++ src/user_agent.rs | 47 +++++++++++++++++++++------- src/user_agent/tests.rs | 21 +++++++++++++ 14 files changed, 198 insertions(+), 57 deletions(-) diff --git a/SHA256SUMS b/SHA256SUMS index 511fc09..9e928b5 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -326,7 +326,7 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs 4c2c988191a97f81568a8df64af772463d010844f1abf327871901cdb32d4336 src/docker/model.rs 5f4610c59b2d4ff6a205a2d9ae11bc84be689b2a68302459f546b3f43e53593c src/docker/reconcile.rs a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs -61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs +2ac87cc55b1eaeb32e8abd3b1f891321bdd20bcc134cf2ea7dbe9154f4799597 src/forwarding.rs 9c712ed59cfca15b79c3339a031c8903c83468c9d3b5e29f7e59d9a222b97561 src/forwarding/peer_formatting.rs 0259def26930308047a248548ff911f64638b43bdfe7f765ae8c82a87a6aab21 src/forwarding/tests.rs af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs @@ -359,9 +359,9 @@ fc8e99eac83c083d65101f6402aacbcc8392d66508c31566a50de3ebcb12e7da src/main/admin 0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs 49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs -201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs -78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs -e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs +068436f6c5a10d5e6bd1b69299bf99690bfd70da2a9fe5fd0de57c32b982bd22 src/metrics/global.rs +f8a9e1eb9466ca71bd70259248cd96354507d568408f5ec07cd60ce33ba15393 src/metrics/vhost.rs +c24bbdf13406112b6494ae68748bee63077767ecd528f663f32996e5ebd2b407 src/metrics/vhost/tests.rs a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs @@ -376,16 +376,16 @@ e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs 2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs 66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.rs -7586fc88e10381eb97f3be6a66f355d16088cc9481f50c193eee8fe169c4fc8e src/proxy/cache_helpers.rs +1c0ba20c9c3327d76468d3e70f82c8e53659bce166a119de82020a9f8631dc2f src/proxy/cache_helpers.rs aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs 676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs 3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs a8848cbdf85368548b003af2b43823c453866c472af4c7d2ff0ec7da14c44c8d src/proxy/error_fallback.rs -f2f9cae1c74eaf66fc365c0709a6eb64e0b27457d4a00728e02cff8a641a1295 src/proxy/forward/cache_lookup.rs +7ee9ee1663d739a7361d97806b4cdbaf07fc0aab13c70a2c4a9804cff76cfb9c src/proxy/forward/cache_lookup.rs cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forward/response.rs 44e1bae0a35ddae6a084c1d50b936fd10dd51059f331525b758447b526a2bddf src/proxy/forward/route_cache.rs -691b54a0cc33089e67d61ffc715b5c015976bae3fbd453b26cb06764be2482e5 src/proxy/forward/success_finish.rs -0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs +39332a8691946191a10b3061128d699ba768bad54045bdbc93266b6bce16b327 src/proxy/forward/success_finish.rs +269082bb95d796decbdf71c1ce8c7cd08ef5efd0d6c6375ae57c90fd48595d25 src/proxy/forward/success_prepare.rs 3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs 3b9f27445c637dbf093b07a5b4e77cbff4df2bab2ad622bb87b6772dab245ec6 src/proxy/forward_entry.rs @@ -397,7 +397,7 @@ cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/head 55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs 2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs -94a50c0bf147565098e06672df3625e44d5af34ef1f12fd355d82cc7a72cf033 src/proxy/tests/authority_tests.rs +de104d9aba11c155e8d72edbb4f37af193c08dc20fc4c4c956b3ffc323cf927a src/proxy/tests/authority_tests.rs 1aa9bbbfb2659facdaeaf185e11221b1c8cd5fe6e23c7c1dacd3aa55e3db4fcb src/proxy/tests/cache_fast_path_tests.rs 0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs @@ -410,8 +410,8 @@ d866e7e72dee6ff54c2418c34e9899cbcd7e4e027d9cbe2b625044dc9062e4ee src/proxy/webs 7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs a44421714aab37f7ceda19e3ec2512c645d2a3ce5dcb3da5bc7a0d87a442d6a8 src/proxy/websocket/tunnel.rs 15fabd38576b61f650c039acdbf7ac5a5bd24330062475b94315285b2dc258b6 src/proxy/websocket/validation.rs -545247eb00b0116efbd4cc913cc251195a55feded1530e0d8d8945abbae78039 src/rate_limit.rs -e7e3a898e2c769b1ef11e58a13c1053104ada93dd694f8a03ea4b647c44c7d69 src/rate_limit/tests.rs +5dc44629340189e8a255992d33bbd78bbb6f2fdaf8a5e0c5bc3175d22c053c7d src/rate_limit.rs +57c879f573ebd10329ba64e2a4ae3776a400fe835a2bc417a157e4c41be514a8 src/rate_limit/tests.rs d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.rs 811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs @@ -479,8 +479,8 @@ ca695e246bee5389b3757fe8588e48bff5d0ad473bc8588ad4ba7b52c99d7dfa src/upstream/c 56c8a9d1c801be0f41b35c75cb42716c46976951948009c8707122c0a6a1eb58 src/upstream/client_tests.rs 75fef0a488d3f3dabfa03c2eabc5995442dfb3c9a1fde8eb6653a124dda25d43 src/upstream/protocol_tests.rs 8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs -a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs -6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs +392f03936e176e2ba9a25edb2a273d09474e2c6801ed6fba8eed5bf1a62bd970 src/user_agent.rs +09ef411f04daa61bdcbcc395c5ba144a755f77a0402abfab636475fe6691b5ee src/user_agent/tests.rs 3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs 6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs 63e9fc7f7be78dc3e16a89f015a56a2da6ba3aff0197b70ff48321dfb17529bb src/vhost/model.rs diff --git a/src/forwarding.rs b/src/forwarding.rs index 4cdafae..f9c44d0 100644 --- a/src/forwarding.rs +++ b/src/forwarding.rs @@ -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 { - 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 Option { - 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 diff --git a/src/metrics/global.rs b/src/metrics/global.rs index b855e32..c1b48dd 100644 --- a/src/metrics/global.rs +++ b/src/metrics/global.rs @@ -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, diff --git a/src/metrics/vhost.rs b/src/metrics/vhost.rs index 27cde63..7bfb9af 100644 --- a/src/metrics/vhost.rs +++ b/src/metrics/vhost.rs @@ -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], } diff --git a/src/metrics/vhost/tests.rs b/src/metrics/vhost/tests.rs index cd059f4..026d7fb 100644 --- a/src/metrics/vhost/tests.rs +++ b/src/metrics/vhost/tests.rs @@ -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); + } } diff --git a/src/proxy/cache_helpers.rs b/src/proxy/cache_helpers.rs index bec01c1..3943a40 100644 --- a/src/proxy/cache_helpers.rs +++ b/src/proxy/cache_helpers.rs @@ -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>, diff --git a/src/proxy/forward/cache_lookup.rs b/src/proxy/forward/cache_lookup.rs index ca3a3bc..01b72ed 100644 --- a/src/proxy/forward/cache_lookup.rs +++ b/src/proxy/forward/cache_lookup.rs @@ -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); diff --git a/src/proxy/forward/success_finish.rs b/src/proxy/forward/success_finish.rs index 880d8fd..db67b88 100644 --- a/src/proxy/forward/success_finish.rs +++ b/src/proxy/forward/success_finish.rs @@ -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 diff --git a/src/proxy/forward/success_prepare.rs b/src/proxy/forward/success_prepare.rs index 0fceaab..717e379 100644 --- a/src/proxy/forward/success_prepare.rs +++ b/src/proxy/forward/success_prepare.rs @@ -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(); diff --git a/src/proxy/tests/authority_tests.rs b/src/proxy/tests/authority_tests.rs index 7bf531f..f7ab671 100644 --- a/src/proxy/tests/authority_tests.rs +++ b/src/proxy/tests/authority_tests.rs @@ -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::().is_none()); + assert!(response.extensions().get::().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::().is_some(), + timing || backend_timing); + assert_eq!(response.extensions().get::().is_some(), backend); + } + } + #[test] fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() { let mut response = Response::new(Body::empty()); diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 7c155ca..0949824 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -211,13 +211,18 @@ impl Settings { self.country_access.allows(country) } fn effective(&self, country: Option<&str>, user_agent: Option<&str>) -> Option<(Limit, Option)> { - // 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 { diff --git a/src/rate_limit/tests.rs b/src/rate_limit/tests.rs index 0cf8dfc..29e0ab6 100644 --- a/src/rate_limit/tests.rs +++ b/src/rate_limit/tests.rs @@ -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()); + } } diff --git a/src/user_agent.rs b/src/user_agent.rs index 0c305e1..f3ee6ab 100644 --- a/src/user_agent.rs +++ b/src/user_agent.rs @@ -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::>(); - 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 { + 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)) } } diff --git a/src/user_agent/tests.rs b/src/user_agent/tests.rs index 89840ed..9b61460 100644 --- a/src/user_agent/tests.rs +++ b/src/user_agent/tests.rs @@ -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")))); + }