Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<GuardExclusionScoped>` 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
Expand Down
92 changes: 42 additions & 50 deletions src/fairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1740,8 +1740,10 @@ impl GuardFairing {
client_ip: Option<std::net::IpAddr>,
) -> Option<Verdict> {
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
Expand All @@ -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) {
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions tests/data/route_country.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading