diff --git a/CHANGELOG.md b/CHANGELOG.md index 15bc493..7ede4f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Unreleased ### 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 (the reference `handle_passthrough` and `extract_client_ip`): the connecting peer (`GuardClientIp`) is resolved through a `ClientPolicy` (`trusted_proxies`, `trusted_proxy_depth`, `fail_secure`, new builder `with_client_policy`) and the resolved identity replaces the peer for the gate, bans, rate limits, geo, cloud, events and the response pass; a request with no resolvable client address answers `403 Client address could not be determined` under `fail_secure` (the `from_security_config` default) and fires `on_block` under the `client_address_unresolved` check, builder layers default to `fail_secure = false` - `from_security_config_with(config, WiringOptions)` and `from_wiring(Wiring)`: the telemetry agent is injected through the facade's `TelemetryHandler`, which wires the event bus, the request metrics, `agent_stats()` (now reporting `enabled` and `degraded` from the sink), `spawn_dynamic_rules()` (host-started poll loop) and the cloud refresh (per-request `refresh_if_due` at the cloud position plus `refresh_cloud_ip_ranges`) - The `GuardExclusionScoped` request extension, the `security_bypass` event for all-checks-bypass routes, the global `auth_verifier` as the fallback verifier of a route that carries none (`with_auth_verifier`), and re-exports of `ResponseBits`, `RequestBits`, `SecurityMetric`, `SecurityEvent`, `CustomResponse`, `CloudRefreshScheduler`, `StoreError` and the wiring types diff --git a/src/service.rs b/src/service.rs index 266d1f8..314035d 100644 --- a/src/service.rs +++ b/src/service.rs @@ -304,71 +304,60 @@ where // 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, like an invalid route tier). + // `ip_whitelist` / `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, like an invalid + // route tier). 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 = facts.ip.map_or( - guard_core_engine::ip_gate::RouteIpVerdict::Unrestricted, - |ip| gate.evaluate(ip), + 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), + ); + let access = gate.evaluate_access( + facts.ip, + &countries, + layer.geo_handler.as_deref(), ); - if verdict == guard_core_engine::ip_gate::RouteIpVerdict::Denied { + if access.verdict == guard_core_engine::ip_gate::RouteIpVerdict::Denied { return Ok(block_response(&parts, &layer, 403, FORBIDDEN_MESSAGE)); } skip_global_ip_lists = gate.whitelist_configured(); + skip_global_countries = access.country_whitelist_matched; } Err(_) => return Ok(failure_response(&parts, &layer)), } } - // 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(ip), Some(handler)) = (facts.ip, layer.geo_handler.as_ref()) { - if let Some(_block) = guard_core_engine::geo::check_countries( - ip, - &route_gate, - handler.as_ref(), - false, - ) { - return Ok(block_response(&parts, &layer, 403, FORBIDDEN_MESSAGE)); - } - skip_global_countries = !route_gate.whitelist_countries.is_empty(); - } - } - // The IP gate runs before anything else: a denied IP must not // cost a body buffer, and detection still scans whatever passes. // The reference `ip_security` bypass skips the gate (and the @@ -1577,14 +1566,19 @@ fn stage_answer_body(answer: &guard_core_rs::tower::StageResponse) -> &str { /// request extensions (the family-local skip state, the equivalent of the /// reference engine's `state.is_whitelisted` / `state.is_exempt`) so /// downstream handlers can read it. Without a gate or without a -/// [`GuardClientIp`] extension the request is not attributed: the gate does -/// not run, and nothing is inserted. +/// [`GuardClientIp`] extension the request is the unknown identity: a +/// configured whitelist denies it, otherwise nothing is inserted. fn enforce_ip_gate(parts: &mut Parts, layer: &GuardLayer) -> Option>> { let gate = layer.ip_gate()?; - let GuardClientIp(ip) = parts.extensions.get::()?; - match gate.evaluate(*ip) { + let client = parts.extensions.get::().map(|ip| ip.0); + // No client address is the reference `UNKNOWN_CLIENT_IDENTITY`: a + // configured whitelist denies it, otherwise it passes unattributed. + let verdict = client.map_or_else(|| gate.evaluate_unknown(), |ip| gate.evaluate(ip)); + match verdict { IpGateVerdict::Allowed(decision) => { - parts.extensions.insert(decision); + if client.is_some() { + parts.extensions.insert(decision); + } None } IpGateVerdict::Denied(denial) => { @@ -1597,7 +1591,10 @@ fn enforce_ip_gate(parts: &mut Parts, layer: &GuardLayer) -> Option); + +impl GeoIpHandler for Countries { + fn get_country(&self, ip: IpAddr) -> Option { + self.0.get(&ip.to_string()).cloned() + } +} + +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() + } +} + +async fn status_of(layer: &GuardLayer, ip: &str, route: Option<&RouteConfig>) -> StatusCode { + let mut request = Request::builder().uri("/api"); + if ip != "unknown" { + request = request.extension(GuardClientIp(ip.parse::().expect("ip"))); + } + let mut request = request.body(Full::new(Bytes::new())).expect("request"); + if let Some(route) = route { + request.extensions_mut().insert(Arc::new(route.clone())); + } + let response = layer + .layer(tower::service_fn(|_request: Request>| async { + Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("ok")))) + })) + .oneshot(request) + .await + .expect("response"); + let status = response.status(); + let _ = response.into_body().collect().await; + status +} + +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 global_layer(table: &Table, whitelist: &[String], countries: &[String]) -> GuardLayer { + let mut layer = GuardLayer::new(default_config()); + if !whitelist.is_empty() { + layer = layer.with_ip_gate( + IpGateConfig::new(whitelist, [] as [&str; 0], [] as [&str; 0]).expect("lists"), + ); + } + let handler = table.handler(); + if !countries.is_empty() { + layer = layer.with_geo_blocking( + GeoStage::builder(GeoStageConfig { + gate: parse_country_lists(countries, [] as [&str; 0]), + handler: Some(handler.clone()), + passive_mode: false, + }) + .build(), + ); + } + layer.with_geo_handler(handler) +} + +fn allowed(verdict: char) -> bool { + verdict != 'F' +} + +#[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 layer = global_layer(&table, &[], &[]); + let mut row = 0; + let mut failures = 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 got = status_of(&layer, &ip, Some(&route)).await; + let want = if allowed(verdicts[row]) { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }; + if got != want { + failures.push(format!( + "rwl={rwl:?} rbl={rbl:?} bc={bc:?} wc={wc:?} ip={ip}: got {got} want {want}" + )); + } + row += 1; + } + } + } + } + } + assert_eq!(row, verdicts.len()); + assert!( + failures.is_empty(), + "{} of {row} route rows differ:\n{}", + failures.len(), + failures.join("\n") + ); +} + +#[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 = 0; + let mut failures = Vec::new(); + for wl in &lists { + for wc in &countries { + let layer = global_layer(&table, wl, wc); + for ip in table.ips() { + let got = status_of(&layer, &ip, None).await; + let want = if allowed(verdicts[row]) { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }; + if got != want { + failures.push(format!( + "wl={wl:?} wc={wc:?} ip={ip}: got {got} want {want}" + )); + } + row += 1; + } + } + } + assert_eq!(row, verdicts.len()); + assert!( + failures.is_empty(), + "{} of {row} global rows differ:\n{}", + failures.len(), + failures.join("\n") + ); +} + +#[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 = 0; + let mut failures = Vec::new(); + for rwl in &lists { + for bc in &blocked { + for wc in &countries { + for gwl in &glists { + for gwc in &gcountries { + let layer = global_layer(&table, gwl, gwc); + let route = route_of(rwl, &[], bc, wc); + for ip in table.ips() { + let got = status_of(&layer, &ip, Some(&route)).await; + let want = if allowed(verdicts[row]) { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }; + if got != want { + failures.push(format!( + "route(wl={rwl:?} bc={bc:?} wc={wc:?}) global(wl={gwl:?} wc={gwc:?}) ip={ip}: got {got} want {want}" + )); + } + row += 1; + } + } + } + } + } + } + assert_eq!(row, verdicts.len()); + assert!( + failures.is_empty(), + "{} of {row} combined rows differ:\n{}", + failures.len(), + failures.join("\n") + ); +}