diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b0bd4e..ee8808f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The parity release: the reference `exclude_paths`, client and response-pass sema ### Added +- Route country rules and the unknown client identity follow the reference `check_route_ip_access`: the route `ip_whitelist` / `ip_blacklist` verdict is combined with the route `blocked_countries` / `whitelist_countries` verdict (a route whitelist match no longer overrides a blocked country, `blocked_countries` is asked first, no loopback exemption on the route arm), the unknown client identity is denied by a route `ip_whitelist` or `whitelist_countries`, and a global `whitelist` denies it (it passed before). Verified against 2125 rows generated by running the reference (`tests/route_country.rs`) - Client resolution with the reference rules: the connecting peer plus `X-Forwarded-For` through `trusted_proxies` and `trusted_proxy_depth`, IPv4-mapped peers canonicalized. A request with no peer and no resolvable client answers `403 Client address could not be determined` under `fail_secure` (check name `client_address_unresolved` on the `on_block` hook), after the exclusion decision. `GuardFairing::with_client_policy` and `ClientPolicy` install the policy (a bare fairing keeps `fail_secure = false`), and `WebSocketGuardConfig::with_client_policy` gives the upgrade guard the same resolution - `GuardExclusionScoped`, the marker an `exclude_paths` request carries (the reference `guard_exclusion_scoped`, read it as an `Option` request guard), and the scoped pipeline it runs: route resolution, bans, the global IP, country and cloud rules, and the rate limit. The response pass runs scoped (metrics, security headers, CORS and the modifier, no return rules) - `GuardFairing::from_security_config_with(config, &WiringOptions)` and `from_wiring(Wiring)`, with the telemetry agent injected through the facade's `TelemetryHandler`. `agent_stats()` now reports the wired sink and its degraded state, `telemetry()` exposes the fan-out, `spawn_dynamic_rules()` starts the dynamic-rule poll loop on request, and the cloud-range refresh runs per request at the cloud check position. New builders: `with_telemetry`, `with_dynamic_rules`, `with_cloud_refresh`, `with_auth_verifier`, `with_body_read_timeout`, `with_path_exclusions`. `GuardConfigError` gained the `Cloud`, `Redis` and `Geo` variants diff --git a/src/fairing.rs b/src/fairing.rs index 2ef689f..b4f6055 100644 --- a/src/fairing.rs +++ b/src/fairing.rs @@ -1740,8 +1740,10 @@ impl GuardFairing { client_ip: Option, ) -> Option { let gate = self.ip_gate.as_ref()?; - let ip = client_ip?; - match gate.evaluate(ip) { + // No client address is the reference `UNKNOWN_CLIENT_IDENTITY`: a + // configured whitelist denies it, otherwise it passes unattributed. + let verdict = client_ip.map_or_else(|| gate.evaluate_unknown(), |ip| gate.evaluate(ip)); + match verdict { IpGateVerdict::Denied(denial) => { // The reference `ip_filter` block path: passive mode logs // the crossing and forwards (no verdict is stashed and no @@ -1752,7 +1754,10 @@ impl GuardFairing { if self.passive_mode { return None; } - let ip_string = ip.to_string(); + let ip_string = client_ip.map_or_else( + || guard_core_rs::geo::UNKNOWN_CLIENT_IP.to_owned(), + |ip| ip.to_string(), + ); // With no override the catcher's verdict arm renders the // family default itself. if let Some(custom) = engine.custom_error_responses.get(&403) { @@ -1776,7 +1781,9 @@ impl GuardFairing { Some(Verdict::IpBlocked) } IpGateVerdict::Allowed(decision) => { - record_gate_decision(request, decision); + if client_ip.is_some() { + record_gate_decision(request, decision); + } None } } @@ -1966,70 +1973,55 @@ impl GuardFairing { // The reference `_check_route_ip_access` (inside the `ip_security` // block, before the global lists): the route's `ip_whitelist` / - // `ip_blacklist` decide first - a configured whitelist takes over - // the route verdict (a match passes, a miss denies), then the - // blacklist denies. A configured whitelist also overrides the - // global IP lists for the request - // (`_route_overrides_ip_lists` / `skip_ip_lists`), so the global - // gate is skipped and the skip flags stay unset. An invalid list - // entry fails secure (the reference validates at decoration time; - // the carrier's invalid entry rides the family's fail-secure arm). + // `ip_blacklist` and its country rules (`blocked_countries` / + // `whitelist_countries`, the reference `check_country_access`) + // decide together. A configured whitelist takes over the IP + // verdict (a match passes, a miss denies), then the blacklist + // denies; the country verdict is combined with it (false if either + // is false, true if either is true), so a whitelist match never + // overrides a blocked country. The unknown identity (no resolvable + // client address) is denied when the route configures an + // `ip_whitelist` or a `whitelist_countries`. A configured route + // whitelist also overrides the global IP lists for the request + // (`_route_overrides_ip_lists` / `skip_ip_lists`), and a matching + // route country whitelist skips the global geo stage + // (`skip_countries`). An invalid list entry fails secure (the + // reference validates at decoration time; the carrier's invalid + // entry rides the family's fail-secure arm). let mut skip_global_ip_lists = false; + let mut skip_global_countries = false; if !bypassed("ip") && let Some(route) = route - && (route.ip_whitelist.is_some() || route.ip_blacklist.is_some()) + && (route.ip_whitelist.is_some() + || route.ip_blacklist.is_some() + || route.blocked_countries.is_some() + || route.whitelist_countries.is_some()) { match guard_core_engine::ip_gate::RouteIpGate::new( route.ip_whitelist.iter().flatten().map(String::as_str), route.ip_blacklist.iter().flatten().map(String::as_str), ) { Ok(gate) => { - let verdict = client_ip.map_or( - guard_core_engine::ip_gate::RouteIpVerdict::Unrestricted, - |addr| gate.evaluate(addr), + let countries = guard_core_engine::geo::parse_country_lists( + route + .whitelist_countries + .iter() + .flatten() + .map(String::as_str), + route.blocked_countries.iter().flatten().map(String::as_str), ); - if verdict == guard_core_engine::ip_gate::RouteIpVerdict::Denied { + let access = + gate.evaluate_access(client_ip, &countries, self.geo_handler.as_deref()); + if access.verdict == guard_core_engine::ip_gate::RouteIpVerdict::Denied { return crate::scan::stage_block(request, 403, crate::FORBIDDEN_MESSAGE); } skip_global_ip_lists = gate.whitelist_configured(); + skip_global_countries = access.country_whitelist_matched; } Err(_) => return Verdict::Failed, } } - // The route's country rules (`blocked_countries` / - // `whitelist_countries`, the reference `check_country_access` - // inside `_check_route_ip_access`): a route whitelist that passes - // also skips the global geo stage for the request - // (`skip_countries`). An unresolvable country blocks only under a - // restrictive route whitelist, exactly the engine's - // `check_countries` reading. - let mut skip_global_countries = false; - if !bypassed("ip") - && let Some(route) = route - && (route.blocked_countries.is_some() || route.whitelist_countries.is_some()) - { - let route_gate = guard_core_engine::geo::parse_country_lists( - route - .whitelist_countries - .iter() - .flatten() - .map(String::as_str), - route.blocked_countries.iter().flatten().map(String::as_str), - ); - if let (Some(addr), Some(handler)) = (client_ip, self.geo_handler.as_ref()) { - if let Some(_block) = guard_core_engine::geo::check_countries( - addr, - &route_gate, - handler.as_ref(), - false, - ) { - return crate::scan::stage_block(request, 403, crate::FORBIDDEN_MESSAGE); - } - skip_global_countries = !route_gate.whitelist_countries.is_empty(); - } - } - if !bypassed("ip") && !skip_global_ip_lists && let Some(verdict) = self.enforce_ip_gate(engine, request, client_ip) diff --git a/tests/data/route_country.json b/tests/data/route_country.json new file mode 100644 index 0000000..bf6d2d8 --- /dev/null +++ b/tests/data/route_country.json @@ -0,0 +1,68 @@ +{ +"_note": "generated by running guard_core (check_route_ip_access, check_ip_access); rows enumerate the dimensions in itertools.product order, last varies fastest; N=no verdict T=true F=false", +"countries": { +"10.0.0.1": "US", +"10.0.0.2": "CN", +"192.168.1.5": null, +"127.0.0.1": null +}, +"ips": [ +"10.0.0.1", +"10.0.0.2", +"192.168.1.5", +"127.0.0.1", +"unknown" +], +"route_lists": [ +[], +[ +"10.0.0.1" +], +[ +"10.0.0.2" +], +[ +"192.168.1.5" +] +], +"blocked_countries": [ +[], +[ +"CN" +] +], +"whitelist_countries": [ +[], +[ +"US" +], +[ +"CN" +], +[ +"US", +"CN" +] +], +"global_lists": [ +[], +[ +"10.0.0.1" +], +[ +"192.168.1.5" +] +], +"global_whitelist_countries": [ +[], +[ +"US" +], +[ +"CN" +] +], +"route": "NNNNNTFFFFFTFFFTTFFFNFNNNTFFFFFFFFFTFFFFFNNNNFFFFFFTFFFFTFFFFFNNNFFFFFFFFFFFFFFFNFNNNTFFFFFFFFFTFFFFNFNNNTFFFFFFFFFTFFFFNNFNNTFFFFFTFFFTTFFFNFFNNTFFFFFFFFFTFFFFTFFFFTFFFFFFFFFTFFFFTFFFFTFFFFFFFFFTFFFFTFFFFTFFFFFFFFFTFFFFTFFFFTFFFFFFFFFTFFFFTFFFFTFFFFFFFFFTFFFFTFFFFTFFFFFFFFFTFFFFTFFFFTFFFFFFFFFTFFFFTFFFFTFFFFFFFFFTFFFFFTFFFFFFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFFFFFTFFFFFFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFFFFFTFFFFFFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFFFFFTFFFFFFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFF", +"global": "TTTTTTFFTFFTFTFTFFFFTFFFFTFFFFFFTFFFFTFFFFTFF", +"combined": "TTTTTTFFTFFTFTFTFFFFTFFFFTFFFFFFTFFFFTFFFFTFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFTFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFTTFFFTTFFFTTFFFTFFFFTFFFFTFFFFFFFFFFFFFFFFFFFTFTTTTFFTFFFFTFTFFFFTFFFFTFFFFFFTFFFFTFFFFTFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFFFFFFFFFFFFFFFFTFFFFTFFFFFFFFFTFFFFTFFFFFFFFFTFFFFTFFFFFFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFFFFFFTFFFFTFFFFFFFFFTFFFFTFFFFFFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFFTFFFFFFFFFTFFFFTFFFFFFFFFTFFFFTFFFFFFFFFTFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFTFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFTFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFTFFFFFFFFFFFFFFTFFFFFFFFFFFFFFTFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" +} \ No newline at end of file diff --git a/tests/route_country.rs b/tests/route_country.rs new file mode 100644 index 0000000..da40268 --- /dev/null +++ b/tests/route_country.rs @@ -0,0 +1,259 @@ +//! Route-level country rules and the unknown client identity, through the +//! public API, against the reference engine (`check_route_ip_access`, +//! `check_ip_access`). The verdict tables in `tests/data/route_country.json` +//! were generated by running the reference, never written by hand. + +use guard_core_engine::geo::{GeoIpHandler, parse_country_lists}; +use guard_core_engine::ip_gate::IpGateConfig; +use guard_core_engine::route_config::{RouteConfig, RouteConfigResolver}; +use guard_core_rs::geo::{GeoStage, GeoStageConfig}; +use rocket::http::Status; +use rocket::local::asynchronous::Client; +use rocket::{get, routes}; +use rocket_guard_rs::{BlockGuard, GuardFairing}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::{Arc, Mutex}; + +const TABLE: &str = include_str!("data/route_country.json"); + +struct Countries(BTreeMap); + +impl GeoIpHandler for Countries { + fn get_country(&self, ip: IpAddr) -> Option { + self.0.get(&ip.to_string()).cloned() + } +} + +struct Table { + json: Value, +} + +impl Table { + fn new() -> Self { + Self { + json: serde_json::from_str(TABLE).expect("fixture"), + } + } + + fn lists(&self, key: &str) -> Vec> { + self.json[key] + .as_array() + .expect(key) + .iter() + .map(|list| { + list.as_array() + .expect("list") + .iter() + .map(|v| v.as_str().expect("entry").to_owned()) + .collect() + }) + .collect() + } + + fn ips(&self) -> Vec { + self.json["ips"] + .as_array() + .expect("ips") + .iter() + .map(|v| v.as_str().expect("ip").to_owned()) + .collect() + } + + fn handler(&self) -> Arc { + let map = self.json["countries"] + .as_object() + .expect("countries") + .iter() + .filter_map(|(ip, country)| Some((ip.clone(), country.as_str()?.to_owned()))) + .collect(); + Arc::new(Countries(map)) + } + + fn verdicts(&self, key: &str) -> Vec { + self.json[key].as_str().expect(key).chars().collect() + } +} + +fn route_of( + whitelist: &[String], + blacklist: &[String], + blocked: &[String], + countries: &[String], +) -> RouteConfig { + let some = |list: &[String]| (!list.is_empty()).then(|| list.to_vec()); + RouteConfig { + ip_whitelist: some(whitelist), + ip_blacklist: some(blacklist), + blocked_countries: some(blocked), + whitelist_countries: some(countries), + ..RouteConfig::default() + } +} + +fn stage_for(table: &Table, countries: &[String]) -> GeoStage { + GeoStage::builder(GeoStageConfig { + gate: parse_country_lists(countries, [] as [&str; 0]), + handler: Some(table.handler()), + passive_mode: false, + }) + .build() +} + +fn gate_for(whitelist: &[String]) -> IpGateConfig { + IpGateConfig::new(whitelist, [] as [&str; 0], [] as [&str; 0]).expect("lists") +} + +fn expected(verdict: char) -> bool { + verdict != 'F' +} + +fn report(kind: &str, rows: usize, total: usize, failures: &[String]) { + assert_eq!(rows, total); + assert!( + failures.is_empty(), + "{} of {rows} {kind} rows differ:\n{}", + failures.len(), + failures.join("\n") + ); +} + +#[tokio::test] +async fn route_verdicts_match_the_reference() { + let table = Table::new(); + let (lists, blocked, countries) = ( + table.lists("route_lists"), + table.lists("blocked_countries"), + table.lists("whitelist_countries"), + ); + let verdicts = table.verdicts("route"); + let harness = Harness::new(&table, &[], &[]).await; + let (mut row, mut failures) = (0, Vec::new()); + for rwl in &lists { + for rbl in &lists { + for bc in &blocked { + for wc in &countries { + for ip in table.ips() { + let route = route_of(rwl, rbl, bc, wc); + let allowed = harness.allowed(&ip, &route).await; + if allowed != expected(verdicts[row]) { + failures.push(format!( + "rwl={rwl:?} rbl={rbl:?} bc={bc:?} wc={wc:?} ip={ip}: allowed={allowed} want={}", + expected(verdicts[row]) + )); + } + row += 1; + } + } + } + } + } + report("route", row, verdicts.len(), &failures); +} + +#[tokio::test] +async fn global_verdicts_match_the_reference() { + let table = Table::new(); + let (lists, countries) = ( + table.lists("global_lists"), + table.lists("global_whitelist_countries"), + ); + let verdicts = table.verdicts("global"); + let (mut row, mut failures) = (0, Vec::new()); + for wl in &lists { + for wc in &countries { + let harness = Harness::new(&table, wl, wc).await; + for ip in table.ips() { + let allowed = harness.allowed(&ip, &RouteConfig::default()).await; + if allowed != expected(verdicts[row]) { + failures.push(format!( + "wl={wl:?} wc={wc:?} ip={ip}: allowed={allowed} want={}", + expected(verdicts[row]) + )); + } + row += 1; + } + } + } + report("global", row, verdicts.len(), &failures); +} + +#[tokio::test] +async fn route_and_global_rules_compose_like_the_reference() { + let table = Table::new(); + let (lists, blocked, countries, glists, gcountries) = ( + table.lists("route_lists"), + table.lists("blocked_countries"), + table.lists("whitelist_countries"), + table.lists("global_lists"), + table.lists("global_whitelist_countries"), + ); + let verdicts = table.verdicts("combined"); + let (mut row, mut failures) = (0, Vec::new()); + for rwl in &lists { + for bc in &blocked { + for wc in &countries { + for gwl in &glists { + for gwc in &gcountries { + let harness = Harness::new(&table, gwl, gwc).await; + let route = route_of(rwl, &[], bc, wc); + for ip in table.ips() { + let allowed = harness.allowed(&ip, &route).await; + if allowed != expected(verdicts[row]) { + failures.push(format!( + "route(wl={rwl:?} bc={bc:?} wc={wc:?}) global(wl={gwl:?} wc={gwc:?}) ip={ip}: allowed={allowed} want={}", + expected(verdicts[row]) + )); + } + row += 1; + } + } + } + } + } + } + report("combined", row, verdicts.len(), &failures); +} + +#[get("/api")] +fn api(_guard: BlockGuard) -> &'static str { + "ok" +} + +/// One global configuration; the route under test is swapped per request. +struct Harness { + client: Client, + route: Arc>>>, +} + +impl Harness { + async fn new(table: &Table, whitelist: &[String], countries: &[String]) -> Self { + let route: Arc>>> = Arc::new(Mutex::new(None)); + let cell = route.clone(); + let resolver: RouteConfigResolver = + Arc::new(move |_method, _path| cell.lock().expect("route").clone()); + let mut fairing = GuardFairing::with_defaults() + .with_geo_handler(table.handler()) + .with_route_configs(resolver); + if !whitelist.is_empty() { + fairing = fairing.with_ip_gate(gate_for(whitelist)); + } + if !countries.is_empty() { + fairing = fairing.with_geo_blocking(stage_for(table, countries)); + } + let client = Client::tracked(rocket::build().attach(fairing).mount("/", routes![api])) + .await + .expect("valid rocket"); + Self { client, route } + } + + async fn allowed(&self, ip: &str, route: &RouteConfig) -> bool { + *self.route.lock().expect("route") = Some(Arc::new(route.clone())); + let mut request = self.client.get("/api"); + if ip != "unknown" { + request = request.remote(SocketAddr::new(ip.parse::().expect("ip"), 45_000)); + } + request.dispatch().await.status() == Status::Ok + } +}