0.34 #2
No files matched your search
+13
-13
@@ -326,7 +326,7 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
|
||||
4c2c988191a97f81568a8df64af772463d010844f1abf327871901cdb32d4336 src/docker/model.rs
|
||||
5f4610c59b2d4ff6a205a2d9ae11bc84be689b2a68302459f546b3f43e53593c src/docker/reconcile.rs
|
||||
a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs
|
||||
2ac87cc55b1eaeb32e8abd3b1f891321bdd20bcc134cf2ea7dbe9154f4799597 src/forwarding.rs
|
||||
61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac 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
|
||||
068436f6c5a10d5e6bd1b69299bf99690bfd70da2a9fe5fd0de57c32b982bd22 src/metrics/global.rs
|
||||
f8a9e1eb9466ca71bd70259248cd96354507d568408f5ec07cd60ce33ba15393 src/metrics/vhost.rs
|
||||
c24bbdf13406112b6494ae68748bee63077767ecd528f663f32996e5ebd2b407 src/metrics/vhost/tests.rs
|
||||
201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs
|
||||
78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs
|
||||
e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 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
|
||||
1c0ba20c9c3327d76468d3e70f82c8e53659bce166a119de82020a9f8631dc2f src/proxy/cache_helpers.rs
|
||||
7586fc88e10381eb97f3be6a66f355d16088cc9481f50c193eee8fe169c4fc8e 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
|
||||
7ee9ee1663d739a7361d97806b4cdbaf07fc0aab13c70a2c4a9804cff76cfb9c src/proxy/forward/cache_lookup.rs
|
||||
f2f9cae1c74eaf66fc365c0709a6eb64e0b27457d4a00728e02cff8a641a1295 src/proxy/forward/cache_lookup.rs
|
||||
cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forward/response.rs
|
||||
44e1bae0a35ddae6a084c1d50b936fd10dd51059f331525b758447b526a2bddf src/proxy/forward/route_cache.rs
|
||||
39332a8691946191a10b3061128d699ba768bad54045bdbc93266b6bce16b327 src/proxy/forward/success_finish.rs
|
||||
269082bb95d796decbdf71c1ce8c7cd08ef5efd0d6c6375ae57c90fd48595d25 src/proxy/forward/success_prepare.rs
|
||||
691b54a0cc33089e67d61ffc715b5c015976bae3fbd453b26cb06764be2482e5 src/proxy/forward/success_finish.rs
|
||||
0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be 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
|
||||
de104d9aba11c155e8d72edbb4f37af193c08dc20fc4c4c956b3ffc323cf927a src/proxy/tests/authority_tests.rs
|
||||
94a50c0bf147565098e06672df3625e44d5af34ef1f12fd355d82cc7a72cf033 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
|
||||
5dc44629340189e8a255992d33bbd78bbb6f2fdaf8a5e0c5bc3175d22c053c7d src/rate_limit.rs
|
||||
57c879f573ebd10329ba64e2a4ae3776a400fe835a2bc417a157e4c41be514a8 src/rate_limit/tests.rs
|
||||
545247eb00b0116efbd4cc913cc251195a55feded1530e0d8d8945abbae78039 src/rate_limit.rs
|
||||
e7e3a898e2c769b1ef11e58a13c1053104ada93dd694f8a03ea4b647c44c7d69 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
|
||||
392f03936e176e2ba9a25edb2a273d09474e2c6801ed6fba8eed5bf1a62bd970 src/user_agent.rs
|
||||
09ef411f04daa61bdcbcc395c5ba144a755f77a0402abfab636475fe6691b5ee src/user_agent/tests.rs
|
||||
a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs
|
||||
6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs
|
||||
3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs
|
||||
6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs
|
||||
63e9fc7f7be78dc3e16a89f015a56a2da6ba3aff0197b70ff48321dfb17529bb src/vhost/model.rs
|
||||
|
||||
+80
-87
@@ -1,24 +1,46 @@
|
||||
impl Snapshot {
|
||||
// Exact host: no allocation. Wildcards: lazy and most-specific-first.
|
||||
fn host_patterns(host: &str) -> impl Iterator<Item = std::borrow::Cow<'_, str>> {
|
||||
std::iter::once(std::borrow::Cow::Borrowed(host)).chain(
|
||||
host.match_indices('.').filter_map(move |(index, _)| {
|
||||
let suffix = &host[index + 1..];
|
||||
suffix.contains('.').then(|| std::borrow::Cow::Owned(format!("*.{suffix}")))
|
||||
}),
|
||||
)
|
||||
}
|
||||
pub fn host_allowed(&self, host: &str, peer: IpAddr) -> bool {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some(acl) = self.host_acls.get(pattern.as_ref()) {
|
||||
return acl.allows(peer);
|
||||
// Exact-first lookups with stack-buffered wildcard keys for normal hosts.
|
||||
fn find_host_pattern<T>(
|
||||
host: &str,
|
||||
mut lookup: impl FnMut(&str) -> Option<T>,
|
||||
) -> Option<T> {
|
||||
if let Some(value) = lookup(host) {
|
||||
return Some(value);
|
||||
}
|
||||
// Skip the final dot: only one label follows it.
|
||||
let last_dot = host.rfind('.')?;
|
||||
let mut wildcard = [0u8; 256];
|
||||
wildcard[..2].copy_from_slice(b"*.");
|
||||
for (index, _) in host.match_indices('.') {
|
||||
if index == last_dot {
|
||||
break;
|
||||
}
|
||||
let suffix = &host[index + 1..];
|
||||
let len = suffix.len() + 2;
|
||||
if len <= wildcard.len() {
|
||||
wildcard[2..len].copy_from_slice(suffix.as_bytes());
|
||||
let key = std::str::from_utf8(&wildcard[..len])
|
||||
.expect("a wildcard plus a UTF-8 host is valid UTF-8");
|
||||
if let Some(value) = lookup(key) {
|
||||
return Some(value);
|
||||
}
|
||||
} else if let Some(value) = lookup(&format!("*.{suffix}")) {
|
||||
// Preserve behavior for unusual overlong internal host inputs.
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
true
|
||||
None
|
||||
}
|
||||
/// RFC 8441 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is
|
||||
/// advertised only when at least one explicitly opted-in vhost is bound to
|
||||
/// this HTTP/2 listener. Request routing still enforces the per-vhost opt-in.
|
||||
pub fn host_allowed(&self, host: &str, peer: IpAddr) -> bool {
|
||||
if self.host_acls.is_empty() {
|
||||
return true;
|
||||
}
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
self.host_acls.get(pattern).map(|acl| acl.allows(peer))
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
/// RFC 8441 is connection-scoped; requests still enforce vhost opt-in.
|
||||
pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool {
|
||||
self.frontends.values().flatten().any(|frontend| {
|
||||
frontend.websockets
|
||||
@@ -29,10 +51,7 @@ impl Snapshot {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// RFC 9220 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is
|
||||
/// advertised only when at least one explicitly opted-in vhost is bound to
|
||||
/// this QUIC listener. Request routing still enforces the per-vhost opt-in.
|
||||
/// RFC 9220 is connection-scoped; requests still enforce vhost opt-in.
|
||||
pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool {
|
||||
self.frontends.values().flatten().any(|frontend| {
|
||||
frontend.websockets
|
||||
@@ -54,7 +73,6 @@ impl Snapshot {
|
||||
) -> Option<&CompiledRoute> {
|
||||
self.select_on_arc(host, path, local, tls).map(Arc::as_ref)
|
||||
}
|
||||
|
||||
/// Shared compiled-route handle for middleware and forward().
|
||||
pub fn select_on_arc(
|
||||
&self,
|
||||
@@ -63,28 +81,22 @@ impl Snapshot {
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
) -> Option<&Arc<CompiledRoute>> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
let fallback = self.fallbacks.get(pattern.as_ref()).and_then(|routes| {
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
let fallback = self.fallbacks.get(pattern).and_then(|routes| {
|
||||
routes
|
||||
.iter()
|
||||
.find(|route| route.bindings.iter().any(|l| l.matches(local, tls)))
|
||||
});
|
||||
// A claimed exact/wildcard vhost owns the request as a unit. First try
|
||||
// its Custom locations, then its main backend fallback; never continue
|
||||
// into a broader wildcard merely because no Custom location matched.
|
||||
if let Some(fallback) = fallback {
|
||||
return self
|
||||
.routes
|
||||
.get(pattern.as_ref())
|
||||
.and_then(|routes| {
|
||||
select_route(routes, path, |r| {
|
||||
r.bindings.iter().any(|l| l.matches(local, tls))
|
||||
})
|
||||
})?;
|
||||
// The claimed vhost owns its fallback ahead of broader wildcards.
|
||||
self.routes
|
||||
.get(pattern)
|
||||
.and_then(|routes| {
|
||||
select_route(routes, path, |r| {
|
||||
r.bindings.iter().any(|l| l.matches(local, tls))
|
||||
})
|
||||
.or(Some(fallback));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.or(Some(fallback))
|
||||
})
|
||||
}
|
||||
pub fn frontend(
|
||||
&self,
|
||||
@@ -92,21 +104,15 @@ impl Snapshot {
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
) -> Option<&Arc<CompiledFrontend>> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some(frontend) = self.frontends.get(pattern.as_ref()).and_then(|frontends| {
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
self.frontends.get(pattern).and_then(|frontends| {
|
||||
frontends
|
||||
.iter()
|
||||
.find(|f| f.bindings.iter().any(|l| l.matches(local, tls)))
|
||||
}) {
|
||||
return Some(frontend);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a persistent protocol-error record to a configured vhost using
|
||||
/// the same exact/wildcard precedence and listener binding rules as normal routing.
|
||||
/// Client-provided Host/SNI values are never used as file names directly.
|
||||
/// Resolve protocol-error attribution with the routing precedence and bindings.
|
||||
pub fn resolve_logging_vhost(
|
||||
&self,
|
||||
local: SocketAddr,
|
||||
@@ -130,7 +136,6 @@ impl Snapshot {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (candidate, matched_by) in [
|
||||
(authority, "authority"),
|
||||
(host, "host"),
|
||||
@@ -151,8 +156,7 @@ impl Snapshot {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Select a redirect host/rule using the accepted socket, TLS state and normalized path.
|
||||
/// Exact hostnames win over the longest matching wildcard, matching vhost behavior.
|
||||
/// Select redirects using accepted socket, TLS state and normalized path.
|
||||
pub fn redirect_on(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -163,8 +167,11 @@ impl Snapshot {
|
||||
&Arc<crate::redirect::CompiledHost>,
|
||||
&crate::redirect::CompiledRule,
|
||||
)> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some((redirect, rule)) = self.redirects.get(pattern.as_ref()).and_then(|redirects| {
|
||||
if self.redirects.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
self.redirects.get(pattern).and_then(|redirects| {
|
||||
redirects.iter().find_map(|redirect| {
|
||||
if !redirect
|
||||
.bindings
|
||||
@@ -179,11 +186,8 @@ impl Snapshot {
|
||||
.find(|rule| rule.matches(path, tls))
|
||||
.map(|rule| (redirect, rule))
|
||||
})
|
||||
}) {
|
||||
return Some((redirect, rule));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Select a host-owned local 404/static response using the accepted socket.
|
||||
pub fn not_found_host_on(
|
||||
@@ -192,40 +196,31 @@ impl Snapshot {
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
) -> Option<&Arc<crate::not_found::CompiledHost>> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some(item) = self.not_found_hosts.get(pattern.as_ref()).and_then(|items| {
|
||||
if self.not_found_hosts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
self.not_found_hosts.get(pattern).and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.matches(local, tls))
|
||||
})
|
||||
}) {
|
||||
return Some(item);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy inspection helper only; network handlers must call select_on.
|
||||
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some(fallback) = self
|
||||
.fallbacks
|
||||
.get(pattern.as_ref())
|
||||
.and_then(|routes| routes.first())
|
||||
{
|
||||
return self
|
||||
.routes
|
||||
.get(pattern.as_ref())
|
||||
.and_then(|routes| select_route(routes, path, |_| true))
|
||||
.or(Some(fallback))
|
||||
.map(Arc::as_ref);
|
||||
}
|
||||
}
|
||||
None
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
let fallback = self.fallbacks.get(pattern).and_then(|routes| routes.first())?;
|
||||
self.routes
|
||||
.get(pattern)
|
||||
.and_then(|routes| select_route(routes, path, |_| true))
|
||||
.or(Some(fallback))
|
||||
.map(Arc::as_ref)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn select_route<'a, F>(
|
||||
routes: &'a [Arc<CompiledRoute>],
|
||||
path: &str,
|
||||
@@ -273,7 +268,6 @@ where
|
||||
}
|
||||
prefix
|
||||
}
|
||||
|
||||
impl CompiledRoute {
|
||||
pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result<Option<http::HeaderValue>> {
|
||||
let Some(spec) = &self.redirect else {
|
||||
@@ -297,7 +291,6 @@ impl CompiledRoute {
|
||||
}
|
||||
Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?))
|
||||
}
|
||||
|
||||
/// Return a normalized replacement path for the upstream hop. `None` means
|
||||
/// preserve the client's exact origin-form path bytes.
|
||||
pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> {
|
||||
|
||||
@@ -953,4 +953,53 @@ fn shared_php_fpm_profile_reference_and_duplicate_ids_are_rejected() {
|
||||
let err = compile(config, 1, &policy()).unwrap_err().to_string();
|
||||
assert!(err.contains("duplicate PHP-FPM profile"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_host_lookup_keeps_exact_and_most_specific_wildcard_order() {
|
||||
let mut checked = Vec::new();
|
||||
let found = Snapshot::find_host_pattern("api.eu.example.test", |pattern| {
|
||||
checked.push(pattern.to_owned());
|
||||
(pattern == "*.example.test").then_some("parent")
|
||||
});
|
||||
assert_eq!(found, Some("parent"));
|
||||
assert_eq!(checked, vec![
|
||||
"api.eu.example.test", "*.eu.example.test", "*.example.test",
|
||||
]);
|
||||
|
||||
checked.clear();
|
||||
let found = Snapshot::find_host_pattern("api.eu.example.test", |pattern| {
|
||||
checked.push(pattern.to_owned());
|
||||
(pattern == "*.eu.example.test").then_some("specific")
|
||||
});
|
||||
assert_eq!(found, Some("specific"));
|
||||
assert_eq!(checked.len(), 2);
|
||||
|
||||
checked.clear();
|
||||
let found = Snapshot::find_host_pattern("api.eu.example.test", |pattern| {
|
||||
checked.push(pattern.to_owned());
|
||||
(pattern == "api.eu.example.test").then_some("exact")
|
||||
});
|
||||
assert_eq!(found, Some("exact"));
|
||||
assert_eq!(checked, vec!["api.eu.example.test"]);
|
||||
|
||||
let no_wildcard_for_tld = Snapshot::find_host_pattern("example.test", |pattern| {
|
||||
(pattern == "*.test").then_some("incorrect")
|
||||
});
|
||||
assert_eq!(no_wildcard_for_tld, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_host_lookup_preserves_long_internal_host_fallback() {
|
||||
// Normalized runtime hosts are <=253 bytes. Direct internal callers with
|
||||
// longer strings still follow the previous pattern order without panics.
|
||||
let long = format!("a.{}.sub.example.test", "x".repeat(260));
|
||||
let mut patterns = Vec::new();
|
||||
let found = Snapshot::find_host_pattern(&long, |pattern| {
|
||||
patterns.push(pattern.to_owned());
|
||||
(pattern == "*.sub.example.test").then_some(true)
|
||||
});
|
||||
assert_eq!(found, Some(true));
|
||||
assert_eq!(patterns[1], format!("*.{}.sub.example.test", "x".repeat(260)));
|
||||
assert_eq!(patterns[2], "*.sub.example.test");
|
||||
}
|
||||
}
|
||||
+7
-14
@@ -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 !headers.contains_key("cf-connecting-ip") || !cloudflare_peer(peer) {
|
||||
if !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 !headers.contains_key("cf-ipcountry") || !cloudflare_peer(peer) {
|
||||
if !cloudflare_peer(peer) {
|
||||
return None;
|
||||
}
|
||||
let mut values = headers.get_all("cf-ipcountry").iter();
|
||||
@@ -319,18 +319,11 @@ impl Compiled {
|
||||
values,
|
||||
});
|
||||
}
|
||||
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 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 client_ip = ips[0];
|
||||
let mut scheme = if tls { "https" } else { "http" };
|
||||
if trusted_peer
|
||||
|
||||
+1
-11
@@ -1,15 +1,5 @@
|
||||
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,
|
||||
@@ -45,7 +35,7 @@ pub struct Metrics {
|
||||
pub websocket_bytes_in: AtomicU64,
|
||||
pub websocket_bytes_out: AtomicU64,
|
||||
pub websocket_duration_us: AtomicU64,
|
||||
pub requests: CacheAlignedAtomicU64,
|
||||
pub requests: AtomicU64,
|
||||
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: CacheAlignedAtomicU64,
|
||||
pub ewma_duration_us: AtomicU64,
|
||||
pub status: [AtomicU64; 5],
|
||||
pub buckets: [AtomicU64; 11],
|
||||
}
|
||||
|
||||
@@ -63,18 +63,4 @@ 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,22 +282,6 @@ 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,7 +179,10 @@ let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str
|
||||
hit.resident_age_seconds,
|
||||
);
|
||||
hit.response.extensions_mut().insert(ResponseRulesApplied);
|
||||
attach_origin_diagnostics(&mut hit.response, route.frontend.as_deref(), elapsed, origin_backend);
|
||||
hit.response.extensions_mut().insert(OriginTiming(elapsed));
|
||||
hit.response
|
||||
.extensions_mut()
|
||||
.insert(OriginBackend(origin_backend.to_owned()));
|
||||
state.cache.record(scope, &host, "HIT", reason);
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(1, Ordering::Relaxed);
|
||||
|
||||
@@ -73,8 +73,12 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
|
||||
revalidated = crate::cache::make_not_modified(revalidated);
|
||||
}
|
||||
revalidated.extensions_mut().insert(ResponseRulesApplied);
|
||||
attach_origin_diagnostics(&mut revalidated, route.frontend.as_deref(),
|
||||
upstream_started.elapsed(), &active_backend.id);
|
||||
revalidated
|
||||
.extensions_mut()
|
||||
.insert(OriginTiming(upstream_started.elapsed()));
|
||||
revalidated
|
||||
.extensions_mut()
|
||||
.insert(OriginBackend(active_backend.id.clone()));
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(1, Ordering::Relaxed);
|
||||
*exchange
|
||||
|
||||
@@ -366,8 +366,12 @@ if intercept_origin_error {
|
||||
);
|
||||
}
|
||||
response.extensions_mut().insert(ResponseRulesApplied);
|
||||
attach_origin_diagnostics(&mut response, route.frontend.as_deref(),
|
||||
upstream_started.elapsed(), &active_backend.id);
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(OriginTiming(upstream_started.elapsed()));
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(OriginBackend(active_backend.id.clone()));
|
||||
if !range_requested && handshake.is_none() {
|
||||
if let Some(settings) = transform_settings.filter(|settings| settings.enabled) {
|
||||
let transform_started = Instant::now();
|
||||
|
||||
@@ -409,34 +409,6 @@ 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());
|
||||
|
||||
+7
-12
@@ -211,18 +211,13 @@ impl Settings {
|
||||
self.country_access.allows(country)
|
||||
}
|
||||
fn effective(&self, country: Option<&str>, user_agent: Option<&str>) -> Option<(Limit, Option<usize>)> {
|
||||
// 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)));
|
||||
}
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
if let Some(country) = country {
|
||||
|
||||
@@ -157,32 +157,4 @@ 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());
|
||||
}
|
||||
}
|
||||
+12
-35
@@ -76,47 +76,24 @@ 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;
|
||||
let mut found_any = false;
|
||||
for part in pattern.split('*').filter(|part| !part.is_empty()) {
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let Some(found) = candidate[cursor..].find(part) else {
|
||||
return false;
|
||||
};
|
||||
if !found_any && !starts_wild && found != 0 {
|
||||
if index == 0 && !starts_wild && found != 0 {
|
||||
return false;
|
||||
}
|
||||
found_any = true;
|
||||
cursor += found + part.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()
|
||||
ends_wild || cursor == candidate.len()
|
||||
}
|
||||
|
||||
impl CompiledPolicy {
|
||||
@@ -127,10 +104,10 @@ impl CompiledPolicy {
|
||||
let Some(value) = headers.get(USER_AGENT) else {
|
||||
return false;
|
||||
};
|
||||
let candidate = String::from_utf8_lossy(value.as_bytes());
|
||||
let candidate = String::from_utf8_lossy(value.as_bytes()).to_ascii_lowercase();
|
||||
self.patterns
|
||||
.iter()
|
||||
.any(|pattern| wildcard_match_case_insensitive(&candidate, pattern))
|
||||
.any(|pattern| wildcard_match(&candidate, pattern))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,24 +51,3 @@
|
||||
.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