diff --git a/AGENTS.md b/AGENTS.md index 26ab5e3..294cc39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ rocket-guard-rs is the Rocket adapter for the Guard ecosystem. It screens Rocket - **Repository**: https://github.com/Guard-Core/rocket-guard-rs - **Language**: Rust, edition 2024, MSRV 1.92 - **License**: MIT OR Apache-2.0 -- **Version**: 1.2.0 (published to crates.io) -- **Status**: implemented, tested, and published. The engine is a path dependency into a sibling `guard-core-rs` checkout; note the crates.io yank below (see [Engine Dependency](#engine-dependency)). +- **Version**: 1.5.0 (published to crates.io) +- **Status**: implemented, tested, and published. The engine is a path dependency into a sibling `guard-core-rs` checkout (see [Engine Dependency](#engine-dependency)). ## Ecosystem Position @@ -33,6 +33,15 @@ Rocket has no middleware chain that can abort a request: `on_request` in a fairi A route without a guard argument is scanned but never blocked; that is Rocket's own per-route opt-in model. Fail-secure rule: a guard that cannot find a stashed verdict refuses with `500` rather than passing uninspected. The body view is never scanned by the fairing: a truncated scan would be a bypass vector. +## Pipeline and config surface + +`GuardFairing::from_security_config(&config)` (and `from_security_config_with(&config, &WiringOptions)` for the injected telemetry agent) builds the whole pipeline through the facade's `Wiring`, the one `SecurityConfig` mapping the Rust adapters share; the adapter installs each part on its builder and owns only the seams the wiring leaves on `Wiring::config` (`fail_secure`, `trusted_proxies`, `exclude_paths`, `auth_verifier`, `body_read_timeout`). `route_resolution_strict` is not applied (Rocket resolves routes after `on_request`). + +- **Client identity**: resolved once per request in `on_request` from the connecting peer (`Request::remote()`) and `X-Forwarded-For` through the trusted proxies, stashed in a request-local slot and read by every stage, the guards and the response pass. Rocket's `client_ip()` is never used (it trusts `X-Real-IP`). `fail_secure` rejects a peer-less request with the `client_address_unresolved` block. +- **`exclude_paths`**: the engine's `PathExclusions` matcher; an excluded request is scoped (route resolution, bans, the global IP rules, the rate limit), carries the public `GuardExclusionScoped` marker and gets the scoped response pass. A route that bypasses `all` emits `security_bypass` and gets only the modifier. +- **Response pass**: the processor from the wiring (shared ban manager) plus the fairing-level modifier, which runs even without a processor. `return_pattern` body rules buffer the response body in `on_response` (bounded by `body_read_timeout`, a failure answers `500`) and re-emit it intact. +- **Refusals**: Rocket cannot answer from `on_request`, so every block, including the new ones, is a verdict stashed in request-local state, enforced by the guards and rendered by the catchers or the `404` rewrite. + ## Engine Integration `guard-core-engine` exposes exactly one detection entry point, which this adapter calls once per request view: @@ -56,14 +65,13 @@ The method is not scanned: `detect` has no method parameter. Non-UTF-8 header va ## Engine Dependency -- `Cargo.toml` pins `guard-core-engine` and `guard-core-rs` at 4.2.0 with paths into the sibling checkout (`../guard-core-rs/crates/guard-core-engine`, `../guard-core-rs/crates/guard-core-rs`). -- Registry note, stated plainly: the 4.1.0 dists were yanked (the version-accuracy fix for the family tag mistake), so 1.1.0 could not resolve its engine from the registry alone; the synchronized 4.2.0 train restores resolution (`rocket-guard-rs` 1.2.0 over `guard-core-engine`/`guard-core-rs` 4.2.0). +- `Cargo.toml` pins `guard-core-engine` and `guard-core-rs` at 4.3.2 (the floor) with paths into the sibling checkout (`../guard-core-rs/crates/guard-core-engine`, `../guard-core-rs/crates/guard-core-rs`). The unreleased parity work needs the engine's `PathExclusions` and `resolve_client` and the facade's `Wiring`, so it trains with the next engine release. - The engine crate is used directly for `detect`; the facade dependency supplies the pipeline-side modules (events, geo, cloud provider, responses, and the rate-limit/ban stage wiring). The facade re-exports the full engine stage set since 4.1.0. - CI checks out `Guard-Core/guard-core-rs` (branch `master`, moving branch by design, documented in `.github/workflows/ci.yml`) into `../guard-core-rs` before building, mirroring `tower-guard-rs`. Do not replace that with a git dependency without updating the CI comment and this file. ## Development Commands -CI is the source of truth (`.github/workflows/*.yml`); there is no Makefile. +CI is the source of truth (`.github/workflows/*.yml`); the Makefile wraps the same commands over the whole workspace (`make test`, `make lint`). ```bash cargo check --all-targets # type check @@ -121,7 +129,7 @@ rocket-guard-rs/ ## Testing -- `cargo test` runs the unit tests (`src/`), the integration tests (`tests/integration.rs`, Rocket local client), and the doctests. All must pass. +- `cargo test` runs the unit tests (`src/`), the integration tests (`tests/integration.rs`, Rocket local client), the parity tests (`tests/parity.rs` for the scoped exclusions, client resolution, config consumption and response pass, `tests/parity_api.rs` for the wiring entry points), and the doctests. All must pass, and CI enforces 100% line coverage (`cargo llvm-cov`). - Coverage must include: benign passthrough, threats blocked via `BlockGuard` and via `GuardBody`, engine panic to `500` (the `#[cfg(test)]` detector seam), the 404-to-403 rewrite, catcher non-collision, body cap `413`, and per-route opt-in (a route without a guard argument is not blocked). - Payloads must come from the spec 4.1.0 conformance corpus (`guard-core-rs/conformance/guard-core-spec-4.1.0/cases/`) so they are guaranteed threats, not guesses. @@ -138,7 +146,7 @@ rocket-guard-rs/ 3. **Run the full local gate before committing**: fmt, clippy, test, doc, cargo deny. CI runs all five. 4. **Example apps are part of the workspace.** `examples/simple_app` and `examples/advanced_app` build with a plain `cargo build` from the repo root; when you change the adapter's public API or response shapes, update the examples and their READMEs (and re-run the live smoke assertions) in the same change. 5. **Conventional commits** (`feat:`, `fix:`, `docs:`, `ci:`), matching history. No AI attribution in commit messages. -6. **Document status honestly.** This crate is published at 1.1.0, but its engine dependency is currently unresolvable from the registry alone (4.1.0 yanked); say so rather than implying a plain `cargo add` works. crates.io publishing is manual and owner-gated; the release workflow only gates the tag. +6. **Document status honestly.** Describe what the crate does today, not a roadmap, and keep the README, the skill file and the changelog in step with the code. crates.io publishing is manual and owner-gated; the release workflow only gates the tag. 7. **Keep the engine surface claims honest.** The engine ships the detect pipeline plus the in-memory rate limiter, ban store, geo country rules, cloud provider checks, and distributed store traits (Redis backends are wired by the consumer, not bundled). Do not document capabilities the engine does not expose. ## Related Projects diff --git a/CHANGELOG.md b/CHANGELOG.md index a8bf93a..793864e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project. +Unreleased +---------- + +The parity release: the reference `exclude_paths`, client and response-pass semantics, and the whole `SecurityConfig` through the facade's `Wiring`. + +### Added + +- 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 +- The `security_bypass` event for an all-checks-bypass route (`all_checks_bypassed`), whose request forwards with only the response modifier applied (no metrics, headers or return rules) and continues through the pipeline under `passive_mode` +- Body `return_pattern` rules now read the response body: the response body is buffered when a rule needs it, bounded in time by `body_read_timeout`, re-emitted intact, and a read error or timeout answers the fail-secure `500` + +### Changed + +- `exclude_paths` uses the engine's reference matcher instead of an exact raw-path comparison: `/static` covers `/static/app.js`, percent-encoding and dot segments are resolved, and `exclude_paths()` reports the normalized entries. An excluded request is no longer exempt from the pipeline: bans, the global IP lists, country and cloud rules and the rate limit apply, and the request is counted in the metrics. Tests that encoded the old exact-match bypass were updated +- The client identity no longer comes from Rocket's `client_ip()`, which honors the `X-Real-IP` header from any peer: it is the connecting peer, with `X-Forwarded-For` honored only through the trusted proxies +- `from_security_config` builds through `Wiring`, so the fields it used to drop now land: geo country rules and `geo_ip_handler`, `block_cloud_providers`, `endpoint_rate_limits`, `enable_rate_limit_auto_ban`, `custom_request_check`, `auth_verifier`, `custom_response_modifier`, `on_error`, `cors_expose_headers`, `trusted_proxies`, the telemetry and event bus, dynamic rules, and the redis stores when `guard-core-rs` has its `redis` feature. The response processor now shares the ban manager with the ban stage, so a return-rule `ban` is enforced by the next request. A config with `fail_secure = true` (the default) rejects a request that carries no peer address +- The custom response modifier runs even when no response processor is installed, and exactly once when the processor already carries it +- The HTTPS stage matches `trusted_proxies` against the connecting peer instead of passing no client host, and the request scheme is the transport's own, so a forged `X-Forwarded-Proto` from an untrusted peer no longer skips the redirect +- A route carrier's `auth_required` and `api_key_required` fall back to the config's global `auth_verifier` +- `GuardBody` skips its body scan for an excluded request and for a route that bypasses `all` or `penetration`; the size cap stays as the memory bound +- Documentation: the skill file no longer describes the crate as a scaffold, the README engine-dependency and status statements are corrected, and the new config surface, client policy, scoped exclusions and agent injection are documented + v1.5.0 (2026-10-09) ------------------- diff --git a/CLAUDE.md b/CLAUDE.md index 1c201ab..294cc39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,8 @@ rocket-guard-rs is the Rocket adapter for the Guard ecosystem. It screens Rocket - **Repository**: https://github.com/Guard-Core/rocket-guard-rs - **Language**: Rust, edition 2024, MSRV 1.92 - **License**: MIT OR Apache-2.0 -- **Version**: 1.1.0 (published to crates.io) -- **Status**: implemented, tested, and published. The engine is a path dependency into a sibling `guard-core-rs` checkout; note the crates.io yank below (see [Engine Dependency](#engine-dependency)). +- **Version**: 1.5.0 (published to crates.io) +- **Status**: implemented, tested, and published. The engine is a path dependency into a sibling `guard-core-rs` checkout (see [Engine Dependency](#engine-dependency)). ## Ecosystem Position @@ -33,6 +33,15 @@ Rocket has no middleware chain that can abort a request: `on_request` in a fairi A route without a guard argument is scanned but never blocked; that is Rocket's own per-route opt-in model. Fail-secure rule: a guard that cannot find a stashed verdict refuses with `500` rather than passing uninspected. The body view is never scanned by the fairing: a truncated scan would be a bypass vector. +## Pipeline and config surface + +`GuardFairing::from_security_config(&config)` (and `from_security_config_with(&config, &WiringOptions)` for the injected telemetry agent) builds the whole pipeline through the facade's `Wiring`, the one `SecurityConfig` mapping the Rust adapters share; the adapter installs each part on its builder and owns only the seams the wiring leaves on `Wiring::config` (`fail_secure`, `trusted_proxies`, `exclude_paths`, `auth_verifier`, `body_read_timeout`). `route_resolution_strict` is not applied (Rocket resolves routes after `on_request`). + +- **Client identity**: resolved once per request in `on_request` from the connecting peer (`Request::remote()`) and `X-Forwarded-For` through the trusted proxies, stashed in a request-local slot and read by every stage, the guards and the response pass. Rocket's `client_ip()` is never used (it trusts `X-Real-IP`). `fail_secure` rejects a peer-less request with the `client_address_unresolved` block. +- **`exclude_paths`**: the engine's `PathExclusions` matcher; an excluded request is scoped (route resolution, bans, the global IP rules, the rate limit), carries the public `GuardExclusionScoped` marker and gets the scoped response pass. A route that bypasses `all` emits `security_bypass` and gets only the modifier. +- **Response pass**: the processor from the wiring (shared ban manager) plus the fairing-level modifier, which runs even without a processor. `return_pattern` body rules buffer the response body in `on_response` (bounded by `body_read_timeout`, a failure answers `500`) and re-emit it intact. +- **Refusals**: Rocket cannot answer from `on_request`, so every block, including the new ones, is a verdict stashed in request-local state, enforced by the guards and rendered by the catchers or the `404` rewrite. + ## Engine Integration `guard-core-engine` exposes exactly one detection entry point, which this adapter calls once per request view: @@ -56,14 +65,13 @@ The method is not scanned: `detect` has no method parameter. Non-UTF-8 header va ## Engine Dependency -- `Cargo.toml` pins `guard-core-engine` and `guard-core-rs` at 4.1.0 with paths into the sibling checkout (`../guard-core-rs/crates/guard-core-engine`, `../guard-core-rs/crates/guard-core-rs`). -- Registry note, stated plainly: the 4.1.0 dists of `guard-core-engine` and `guard-core-rs` are currently yanked on crates.io, so the published 1.1.0 of this crate cannot resolve its engine from the registry alone (a fresh `cargo add rocket-guard-rs` falls back to 1.0.0 with engine 4.0.4). Resolution is restored at the synchronized 4.2.0 train; until then the sibling path dependencies are the working route. +- `Cargo.toml` pins `guard-core-engine` and `guard-core-rs` at 4.3.2 (the floor) with paths into the sibling checkout (`../guard-core-rs/crates/guard-core-engine`, `../guard-core-rs/crates/guard-core-rs`). The unreleased parity work needs the engine's `PathExclusions` and `resolve_client` and the facade's `Wiring`, so it trains with the next engine release. - The engine crate is used directly for `detect`; the facade dependency supplies the pipeline-side modules (events, geo, cloud provider, responses, and the rate-limit/ban stage wiring). The facade re-exports the full engine stage set since 4.1.0. - CI checks out `Guard-Core/guard-core-rs` (branch `master`, moving branch by design, documented in `.github/workflows/ci.yml`) into `../guard-core-rs` before building, mirroring `tower-guard-rs`. Do not replace that with a git dependency without updating the CI comment and this file. ## Development Commands -CI is the source of truth (`.github/workflows/*.yml`); there is no Makefile. +CI is the source of truth (`.github/workflows/*.yml`); the Makefile wraps the same commands over the whole workspace (`make test`, `make lint`). ```bash cargo check --all-targets # type check @@ -121,7 +129,7 @@ rocket-guard-rs/ ## Testing -- `cargo test` runs the unit tests (`src/`), the integration tests (`tests/integration.rs`, Rocket local client), and the doctests. All must pass. +- `cargo test` runs the unit tests (`src/`), the integration tests (`tests/integration.rs`, Rocket local client), the parity tests (`tests/parity.rs` for the scoped exclusions, client resolution, config consumption and response pass, `tests/parity_api.rs` for the wiring entry points), and the doctests. All must pass, and CI enforces 100% line coverage (`cargo llvm-cov`). - Coverage must include: benign passthrough, threats blocked via `BlockGuard` and via `GuardBody`, engine panic to `500` (the `#[cfg(test)]` detector seam), the 404-to-403 rewrite, catcher non-collision, body cap `413`, and per-route opt-in (a route without a guard argument is not blocked). - Payloads must come from the spec 4.1.0 conformance corpus (`guard-core-rs/conformance/guard-core-spec-4.1.0/cases/`) so they are guaranteed threats, not guesses. @@ -138,7 +146,7 @@ rocket-guard-rs/ 3. **Run the full local gate before committing**: fmt, clippy, test, doc, cargo deny. CI runs all five. 4. **Example apps are part of the workspace.** `examples/simple_app` and `examples/advanced_app` build with a plain `cargo build` from the repo root; when you change the adapter's public API or response shapes, update the examples and their READMEs (and re-run the live smoke assertions) in the same change. 5. **Conventional commits** (`feat:`, `fix:`, `docs:`, `ci:`), matching history. No AI attribution in commit messages. -6. **Document status honestly.** This crate is published at 1.1.0, but its engine dependency is currently unresolvable from the registry alone (4.1.0 yanked); say so rather than implying a plain `cargo add` works. crates.io publishing is manual and owner-gated; the release workflow only gates the tag. +6. **Document status honestly.** Describe what the crate does today, not a roadmap, and keep the README, the skill file and the changelog in step with the code. crates.io publishing is manual and owner-gated; the release workflow only gates the tag. 7. **Keep the engine surface claims honest.** The engine ships the detect pipeline plus the in-memory rate limiter, ban store, geo country rules, cloud provider checks, and distributed store traits (Redis backends are wired by the consumer, not bundled). Do not document capabilities the engine does not expose. ## Related Projects diff --git a/Cargo.toml b/Cargo.toml index aaaacc8..e5fc8bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ categories = ["web-programming"] name = "rocket_guard_rs" [dependencies] -# guard-core-engine is published to crates.io (4.3.2); the path points at the +# guard-core-engine is published to crates.io (the 4.3 line); the path points at the # engine crate inside a sibling guard-core-rs checkout so local builds and CI # (see .github/workflows/ci.yml) exercise the engine from source. The version # pin stays in lockstep with the published engine release. diff --git a/README.md b/README.md index 74589c3..5522477 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ Two-phase note (framework shape, reference-matching order): Rocket's `on_request | Situation | Status | Body | |---|---|---| +| No peer address and no resolvable client under `fail_secure` | `403 Forbidden` | `Client address could not be determined` | | The IP gate denies the client IP | `403 Forbidden` | `Forbidden` | | A live ban on the client IP | `403 Forbidden` | `IP address banned` | | Rate limit crossed | `429 Too Many Requests` (+ `Retry-After: `) | `Too many requests` | @@ -265,6 +266,20 @@ let fairing = rocket_guard_rs::GuardFairing::with_defaults() - Both stages honor the `exempt_ips` contract: whitelisted and exempt IPs are never rate limited, never banned, and never counted; unattributed requests (no client IP) skip the stage but are still detection-screened. - The limiter and the ban store pair are shared with the managed engine state, and the engine handles are cheaply clonable, so out-of-band handles (admin unban endpoints, stats) work alongside the installed fairing. +## Client resolution + +The Rocket peer address is the connecting peer. The identity every stage runs under (gate, bans, rate limit, geo, cloud, events, the response pass) is resolved from it with the reference rules: `X-Forwarded-For` counts only behind a trusted proxy (`trusted_proxies`, `trusted_proxy_depth`), and an IPv4-mapped IPv6 peer is canonicalized. Rocket's own `Request::client_ip()` is never consulted, because it honors the `X-Real-IP` header from any peer. A request with no peer address and no resolvable client answers `403 Client address could not be determined` under `fail_secure`, before any check, and the `on_block` hook sees check name `client_address_unresolved`. With `fail_secure = false` it continues unattributed. A bare fairing defaults to no proxies and `fail_secure = false`, so existing hosts that supply no peer keep passing; `.with_client_policy(ClientPolicy { .. })` installs a policy and `from_security_config` installs the config's (`fail_secure` defaults to `true`). The WebSocket guard takes the same policy through `WebSocketGuardConfig::with_client_policy`. The HTTPS stage matches `trusted_proxies` against the connecting peer and takes the transport's own scheme, never a header's claim. + +## Excluded paths + +`exclude_paths` uses the reference matcher: `/static` covers `/static` and `/static/app.js` and never `/staticx`, and percent-encoding and dot segments are resolved before matching. An excluded request is scoped, not bypassed. It carries the `GuardExclusionScoped` marker (read it as an `Option` request guard) and runs route resolution, the ban check, the global IP lists, country and cloud rules, and the rate limit. Emergency mode, HTTPS enforcement, request logging, headers and auth, validators, the scan (the `GuardBody` body scan included, its size cap stays as the memory bound), the user-agent filter, custom checks and the return rules are skipped. The response still gets metrics, security headers, CORS and the modifier. The CORS preflight short-circuit runs before the exclusion decision. A route carrier that bypasses `all` checks skips the pipeline: the `security_bypass` event fires and only the modifier runs on the response (under `passive_mode` the request continues through the pipeline). Rocket cannot answer from `on_request`, so these refusals ride the same verdict, guard and catcher path as every other block. + +## Config, agent and dynamic rules + +`GuardFairing::from_security_config(&config)` builds everything through the facade's `Wiring`, the one `SecurityConfig` mapping the Rust adapters share (the field-by-field table is in the `guard_core_rs::wiring` module docs). Geo country rules, cloud providers (with the per-request range refresh), `endpoint_rate_limits`, the rate-limit auto-ban, `custom_request_check`, `auth_verifier` (the fallback of a route's verifiers), `custom_response_modifier`, `on_error`, `cors_expose_headers`, `trusted_proxies` and the rest all land on the fairing. The response processor shares the ban manager with the ban stage, so a return-rule `ban` is enforced by the next request. + +The telemetry agent is injected: build a `TelemetryHandler` from the `agent_*` fields with guard-agent-rs's `GuardAgentTelemetry` and call `GuardFairing::from_security_config_with(&config, &WiringOptions { agent: Some(agent), logfire_client: None })`. The agent feeds the event bus, the metrics collector, `agent_stats()` and the dynamic rules. Wiring starts nothing: call `fairing.telemetry()` and its `start()` / `stop()`, and `fairing.spawn_dynamic_rules()` starts the poll loop (drop the handle to stop it). Body `return_pattern` rules buffer the response body (bounded in time by `body_read_timeout`, a read error or timeout answers `500`) and re-emit it intact, so do not combine them with endless streaming responses. + ## Body cap Rocket streams request bodies to data guards, and a fairing can only peek at the first 512 bytes (`Data::peek` is capped at `PEEK_BYTES`). Scanning a truncated prefix as if it were the body would be a bypass vector, so the body view is scanned by `GuardBody`, which owns the data stream. @@ -280,17 +295,17 @@ A body larger than the cap is rejected with `413` rather than forwarded unscanne ## Status route -`status::GuardStatus` in managed state plus the `status::guard_status` route handler is the `add_status_route` mirror (fastapi-guard `guard/status.py` + `HandlerInitializer.get_initialization_status`): `.manage(GuardStatus::new().with_cloud_table(table)).mount(status::DEFAULT_STATUS_PATH, routes![guard_status])` mounts `GET /_guard/status`, serving the cloud-provider status table (`{"ready":..., "last_refreshed":, "entries":N}` per provider from the live `CloudIpTable`) and the geo-ip component (`null` without a handler, `{"configured":true}` with the lookup trait only, the `{ready, last_refreshed, entries}` health snapshot with the `IpInfoManager` lifecycle manager). The maintenance trio lives on `GuardFairing`: `reset()` drops the rate-limit windows, `refresh_cloud_ip_ranges()` schedules one single-flight refresh through `with_cloud_refresh_scheduler`, and `agent_stats()` answers the reference property's no-agent shape. +`status::GuardStatus` in managed state plus the `status::guard_status` route handler is the `add_status_route` mirror (fastapi-guard `guard/status.py` + `HandlerInitializer.get_initialization_status`): `.manage(GuardStatus::new().with_cloud_table(table)).mount(status::DEFAULT_STATUS_PATH, routes![guard_status])` mounts `GET /_guard/status`, serving the cloud-provider status table (`{"ready":..., "last_refreshed":, "entries":N}` per provider from the live `CloudIpTable`) and the geo-ip component (`null` without a handler, `{"configured":true}` with the lookup trait only, the `{ready, last_refreshed, entries}` health snapshot with the `IpInfoManager` lifecycle manager). The maintenance trio lives on `GuardFairing`: `reset()` drops the rate-limit windows, `refresh_cloud_ip_ranges()` schedules one single-flight refresh through `with_cloud_refresh_scheduler`, and `agent_stats()` reports whether a telemetry sink is wired and whether its start degraded (see [Config, agent and dynamic rules](#config-agent-and-dynamic-rules)). ## WebSocket guard (rocket_ws) Rocket 0.5's WebSocket surface is the official [`rocket_ws`](https://crates.io/crates/rocket_ws) crate, and `websocket::WebSocketGuard` guards it, ported from fastapi-guard's `guard/websocket.py` (`make_guard_websocket`): install the guard with `.manage(make_guard_websocket(config))` and declare it on the `rocket_ws` routes it protects. A blocked handshake is rejected with `403 Forbidden` pre-accept, carrying the close shape on `x-guard-websocket-close` / `x-guard-websocket-close-reason` (Starlette's `WebSocketException` denial, translated like the axum/actix siblings). The close codes are the reference's: `1008 Policy Violation` for every policy denial (IP banned, IP not allowed, rate limit exceeded, unknown client address under fail-secure, suspicious activity) and `1013 Try Again Later` for an engine malfunction (a panic in the check sequence, contained). The sequence: fail-secure unknown address, the ban arm, the `is_ip_allowed` gate + country arms (a whitelist match skips countries), the `ws` rate limit (skipped for whitelisted IPs), then the path/query/header penetration scan - an upgrade carries no body. -Frames after the upgrade are guarded too: `WebSocketGuard::scan_frame(client_ip, &message)` scans one text or binary message through the engine, feeds the shared violation store on a threat, and reports the close shape the channel answers with (`WebSocketGuard::close_frame` + `stream_error`); control frames pass untouched. `WebSocketGuardConfig::with_violation_feed(ViolationFeed::new(counters, ban_config))` installs the shared-suspicious-counts store (the reference's shared `suspicious_request_counts`): a crossed threshold bans the address through the shared `IpBanManager`, and the next handshake's ban arm then rejects it - frame threats close the current connection with the suspicious-activity close exactly like the reference's single detection arm. Build the config from the handles the app already holds with `WebSocketGuardConfig::new(default_config())` + the `with_ip_gate` / `with_ip_banning` / `with_rate_limiting` / `with_country_rules` / `with_violation_feed` / `with_fail_secure` builders. The fairing itself passes upgrades through like any request; the guard is the per-route surface Rocket's model requires. +Frames after the upgrade are guarded too: `WebSocketGuard::scan_frame(client_ip, &message)` scans one text or binary message through the engine, feeds the shared violation store on a threat, and reports the close shape the channel answers with (`WebSocketGuard::close_frame` + `stream_error`); control frames pass untouched. `WebSocketGuardConfig::with_violation_feed(ViolationFeed::new(counters, ban_config))` installs the shared-suspicious-counts store (the reference's shared `suspicious_request_counts`): a crossed threshold bans the address through the shared `IpBanManager`, and the next handshake's ban arm then rejects it - frame threats close the current connection with the suspicious-activity close exactly like the reference's single detection arm. Build the config from the handles the app already holds with `WebSocketGuardConfig::new(default_config())` + the `with_ip_gate` / `with_ip_banning` / `with_rate_limiting` / `with_country_rules` / `with_violation_feed` / `with_fail_secure` / `with_client_policy` builders. The fairing itself passes upgrades through like any request; the guard is the per-route surface Rocket's model requires. ## Engine dependency -The Cargo.toml pins `guard-core-engine` and `guard-core-rs` at 4.2.0 and carries paths pointing at the engine and facade crates inside a sibling `guard-core-rs` checkout so local builds and CI compile them from source. Registry note, stated plainly: the 4.1.0 dists were yanked (the version-accuracy fix for the family tag mistake), so 1.1.0 could not resolve its engine from the registry alone; the synchronized 4.2.0 train restores resolution (`rocket-guard-rs` 1.2.0 over `guard-core-engine`/`guard-core-rs` 4.2.0). CI checks out `Guard-Core/guard-core-rs` (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)), mirroring the sibling adapter pattern in `tower-guard-rs` and `actix-guard-rs`. +The Cargo.toml pins `guard-core-engine` and `guard-core-rs` at 4.3.2 (the floor) and carries paths pointing at the engine and facade crates inside a sibling `guard-core-rs` checkout so local builds and CI compile them from source. The unreleased parity work needs the engine's `PathExclusions` and `resolve_client` and the facade's `Wiring`, so it trains with the next engine release. CI checks out `Guard-Core/guard-core-rs` (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)), mirroring the sibling adapter pattern in `tower-guard-rs` and `actix-guard-rs`. Both halves of the sibling checkout are used: `guard-core-engine` for the detection entry point and the engine-side stages, and the `guard-core-rs` facade for the stage layers (the `guard_core_rs::*` stage types the fairing installs). @@ -300,7 +315,10 @@ Every reference check the engine ships is installable on `GuardFairing`, and the ## Not wired on purpose -- **WebSocket guard (fairing half)**: the fairing scans an upgrade's path, query, and header views like any request, but the handshake rejection and the per-frame guard live in the `websocket::WebSocketGuard` route surface, not the fairing; a `rocket_ws` route without the guard argument is not WS-guarded. +- **WebSocket guard (fairing half)**: the fairing scans an upgrade's path, query, and header views like any request, but the handshake rejection and the per-frame guard live in the `websocket::WebSocketGuard` route surface (described above), not the fairing; a `rocket_ws` route without the guard argument is not WS-guarded. +- **`route_resolution_strict`**: Rocket resolves routes after the fairing's `on_request`, so the adapter cannot tell an unresolved route apart. The knob stays on the config. +- **Request-body read timeout**: Rocket's own limits own that seam; `body_read_timeout` bounds the response-body read the return rules need. +- **The `redis` backend**: the adapter does not enable `guard-core-rs`'s `redis` feature. Enable it in your own `Cargo.toml` and `enable_redis` plus `redis_url` wire the shared stores; otherwise limits and bans stay per process. ## Development diff --git a/docs/api.md b/docs/api.md index b3ade26..99277eb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -60,9 +60,12 @@ The gate runs in `on_request` before the metadata scan, on the request's client IP: a blacklisted IP - or an IP a non-empty `whitelist` matches neither directly nor through `exempt_ips` - is refused with `403 Forbidden` (the `Forbidden` body), both through the guards and through the `404` rewrite -for unrouted paths. A request whose client IP is unknown (Rocket's local test -client, for example) is not attributed: the gate does not run and the scan -runs unconditionally. +for unrouted paths. The client IP is the resolved client (the connecting peer, +or the forwarded client behind a trusted proxy, see +`GuardFairing::with_client_policy`; Rocket's own `client_ip()` is not used +because it trusts `X-Real-IP`). A request whose client IP is unknown is not +attributed: the gate does not run and the scan runs unconditionally (a +fairing built with `from_security_config` rejects it under `fail_secure`). **exempt_ips vs whitelist.** `exempt_ips` is noise reduction for known-friendly automation (monitoring probes, VPN egress, a partner's diff --git a/src/.agents/skills/rocket-guard-rs/SKILL.md b/src/.agents/skills/rocket-guard-rs/SKILL.md index e1723b2..be879e1 100644 --- a/src/.agents/skills/rocket-guard-rs/SKILL.md +++ b/src/.agents/skills/rocket-guard-rs/SKILL.md @@ -1,70 +1,80 @@ --- name: rocket-guard-rs -description: Use when working in rocket-guard-rs (github.com/Guard-Core/rocket-guard-rs), the Rocket Fairing adapter for the guard-core-rs detection engine: editing this crate, planning or reviewing the Fairing implementation, wiring guard-core-rs into Rocket, or answering questions about the repository's status. The repo is currently a 14-line cargo new scaffold with zero dependencies (not even rocket), no engine integration, and no CI that compiles code; everything here must be described as roadmap, not reality. +description: Use when working in rocket-guard-rs (github.com/Guard-Core/rocket-guard-rs), the Rocket Fairing adapter for the guard-core-rs detection engine: editing the fairing, guards, WebSocket guard or status route, wiring guard-core-rs into a Rocket application, or answering questions about what the crate does. It is implemented, tested and published; the engine is a path dependency into a sibling guard-core-rs checkout. --- # rocket-guard-rs -Reserved namespace for the Rocket adapter of the Guard ecosystem. Currently a scaffold: `src/lib.rs` is the stock 14-line `cargo new` stub, `Cargo.toml` has no dependencies, and no CI compiles anything. +The Rocket adapter of the Guard ecosystem. `GuardFairing` screens Rocket 0.5 traffic through the guard-core-rs engine, and the request guards `BlockGuard` and `GuardBody` enforce the verdict per route. It holds framework glue only; every detection decision comes from the engine. ## Quick Reference -- Status: implemented. Version 0.0.1, edition 2024, MIT, not published. -- Engine: [guard-core-rs](https://github.com/Guard-Core/guard-core-rs) (itself pre-1.0). -- This adapter holds framework glue only; all security logic belongs in the engine. +- Status: implemented, tested, published to crates.io. Edition 2024, MSRV 1.92, MIT OR Apache-2.0. +- Engine: [guard-core-rs](https://github.com/Guard-Core/guard-core-rs), consumed as a path dependency into a sibling checkout at `../guard-core-rs` (the facade supplies the stages and the `Wiring`). +- Entry points: `GuardFairing::new` / `with_defaults` (builder API) and `GuardFairing::from_security_config` / `from_security_config_with` (the whole `SecurityConfig` through the facade's `Wiring`). ## Installation -Not published to crates.io and not usable. To work on the source: +```bash +cargo add rocket-guard-rs +``` + +To work on the source, clone the repo next to a `guard-core-rs` checkout: ```bash git clone https://github.com/Guard-Core/rocket-guard-rs +git clone https://github.com/Guard-Core/guard-core-rs cd rocket-guard-rs ``` -## Setup - -- Any recent stable Rust (1.85+, edition 2024). No rust-toolchain.toml, no pre-commit config. -- No Makefile and no CI: commands below are the working baseline, and the contribution checklist inside `.github/workflows/greetings.yml` states the intended bar (fmt, clippy `-D warnings`, tests, docs build) that nothing enforces yet. +## How it works -## Status +Rocket has no middleware chain that can abort a request, so the work is split in two: -What exists: one 14-line stub with an `add()` function and one unit test, automation workflows (greetings, labeler, stale, summary, sync-labels), MIT license, README marked "Implemented (v0.1.0)." +1. `GuardFairing` (`Kind::Ignite | Kind::Request | Kind::Response`): `on_ignite` manages the engine and registers the `400`/`403`/`413`/`429`/`500` catchers (never over an application-claimed status), `on_request` resolves the client, runs the pipeline and stashes the verdict in request-local state, `on_response` renders the `301` HTTPS redirect, rewrites a `404` to the guarded refusal, emits the metrics and runs the response pass. +2. `BlockGuard` (routes without a body) and `GuardBody` (routes with one) enforce the stashed verdict before the handler runs. A route without a guard argument is scanned but never blocked, and a guard that finds no verdict refuses with `500` (fail-secure). -What does not exist: any rocket or guard-core-rs dependency, any Fairing code, any configuration type, any real tests, any CI that compiles the crate. Do not describe this crate as functional, integrated, or published. +Pipeline order inside `on_request` follows the reference: client resolution, the CORS preflight short-circuit, the exclusion decision, route resolution, the all-checks-bypass route, then (for a normal request) usage rules, route IP and country rules, the global IP gate, emergency mode, HTTPS, request logging, headers and auth, referrer, validators, time window, the metadata scan, bans, geo, cloud, user agent, the rate-limit tiers and `custom_request`. The body view is scanned later, in `GuardBody`. -## Intended Integration +## Config surface -Roadmap, not reality: +- `from_security_config(&config)` builds everything through `guard_core_rs::wiring::Wiring` (the field table is in that module's docs): geo country rules, cloud providers with the per-request refresh, `endpoint_rate_limits`, the rate-limit auto-ban, `custom_request_check`, `auth_verifier` (fallback of a route's verifiers), the response modifier and `on_error`, `cors_expose_headers`, trusted proxies, the telemetry sink and event bus, dynamic rules. +- `from_security_config_with(&config, &WiringOptions { agent, logfire_client })` injects the telemetry agent through the facade's `TelemetryHandler` (build one from the `agent_*` fields with guard-agent-rs's `GuardAgentTelemetry`). Wiring starts nothing: the host calls `telemetry().start()` and `spawn_dynamic_rules()`. +- Client policy: `with_client_policy(ClientPolicy)`. The identity is the connecting peer plus `X-Forwarded-For` through trusted proxies; Rocket's `client_ip()` (which trusts `X-Real-IP`) is never used. A bare fairing defaults to `fail_secure = false`, `from_security_config` installs the config's (`true` by default, a peer-less request answers `403 Client address could not be determined`). +- `exclude_paths` uses the engine matcher and is scoped, not bypassed: bans, the global IP rules and the rate limit still apply, the request carries `GuardExclusionScoped`, and the response gets metrics, headers, CORS and the modifier. A route that bypasses `all` emits `security_bypass` and gets only the modifier. +- Not applied: `route_resolution_strict` (Rocket resolves routes after `on_request`), the request-body read timeout (Rocket's limits own it), the `redis` backend (enable the facade's `redis` feature yourself). -1. Depend on `guard-core-rs` (facade re-exporting `compiler`, `preprocessor`, `semantic`) and `rocket`. -2. Implement Rocket's `Fairing` trait with an `on_request` callback that inspects incoming requests before routing/handlers run; a Fairing is the idiomatic Rocket extension point for cross-cutting request checks. -3. Per request: extract method, path, headers, client IP, and body; call the engine synchronously (it is CPU-bound, no I/O, no tokio, safe to call inside Rocket's async context); deny with the family shape on a block verdict (`400` detection block, `403` gate/ban, `429` throttled with `Retry-After`). -4. Rate limiting, Redis, IP intelligence, and event dispatch are out of scope: they are later sections of the reference spec and not in the engine at 0.0.1. -5. Configuration waits for a config surface in guard-core-rs (reference spec section 02, not yet ported). +## Other surfaces -Engine honesty constraint: the engine lacks the 4.x pattern-table scan stage, so any integration today is partial; design notes must say so. +- `status::GuardStatus` and `status::guard_status`: the `GET /_guard/status` route (managed state plus a mounted handler). +- `websocket::WebSocketGuard` and `make_guard_websocket`: the `rocket_ws` handshake guard and the per-frame `scan_frame`, with the same client policy through `WebSocketGuardConfig::with_client_policy`. ## Development Commands ```bash -cargo build -cargo test +cargo check --all-targets cargo fmt --all -- --check cargo clippy --all-targets -- -D warnings +cargo build -p guard-core-engine --bins # builds the pattern-probe child the scan tests need +cargo test +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +cargo llvm-cov --lcov --output-path lcov.info && python3 .github/scripts/enforce_line_coverage.py lcov.info # 100% lines ``` +`make test` and `make lint` wrap the same commands over the workspace (the adapter plus `examples/`). + ## Footguns - **Do not add security logic here**: detection belongs in guard-core-rs; this crate is glue only. -- **Do not build on the stub**: `add()` is `cargo new` boilerplate to be replaced, not an API to extend. -- **Do not claim integration or parity**: the engine has no pattern-table scan stage and no config/handler sections yet; never describe this adapter as protecting anything. -- **No CI exists**: nothing validates changes; run the commands above manually. -- **Edition 2024** requires Rust 1.85+; older toolchains fail to build the stub. +- **Every refusal is a verdict**: a fairing cannot answer from `on_request`, so new blocks must stash a verdict and let the guards, catchers or the `404` rewrite render it. Do not invent another mechanism. +- **Never use `Request::client_ip()`**: it honors the `X-Real-IP` header from any peer. Use the resolved client from `scan::client_ip`. +- **Rocket's Shield adds `X-Content-Type-Options`**: assert the guard's security headers through a header only the guard sets (for example `Referrer-Policy`). +- **Fail-secure stays**: any new failure path maps to `500` or a documented rejection, never an uninspected passthrough. +- **CI enforces 100% line coverage**: every new line needs a test, no waivers. ## Related Projects -- [guard-core-rs](https://github.com/Guard-Core/guard-core-rs): the engine (pre-1.0, work in progress). +- [guard-core-rs](https://github.com/Guard-Core/guard-core-rs): the engine. - Sibling adapters: [tower-guard-rs](https://github.com/Guard-Core/tower-guard-rs), [axum-guard-rs](https://github.com/Guard-Core/axum-guard-rs), [actix-guard-rs](https://github.com/Guard-Core/actix-guard-rs). -- [guard-core](https://github.com/Guard-Core/guard-core): Python reference implementation (spec 4.0.2). -- [fastapi-guard](https://github.com/Guard-Core/fastapi-guard): most mature ecosystem adapter; reference for feature coverage. +- [guard-core](https://github.com/Guard-Core/guard-core): Python reference implementation and spec owner. +- [fastapi-guard](https://github.com/Guard-Core/fastapi-guard): the most mature adapter, the reference for feature coverage. diff --git a/src/fairing.rs b/src/fairing.rs index 5c9b19e..9bd91cf 100644 --- a/src/fairing.rs +++ b/src/fairing.rs @@ -2,7 +2,7 @@ use crate::response; use crate::scan::{ - GuardEngine, Metadata, Verdict, metadata_threat_verdict, metadata_verdict, + GuardEngine, Metadata, Pass, Verdict, metadata_threat_verdict, metadata_verdict, record_gate_decision, stage_decision, }; use guard_core_engine::detection_exclusions::DetectionExclusionConfig; @@ -10,6 +10,7 @@ use guard_core_engine::distributed::{BanStore, SlidingWindowStore}; use guard_core_engine::geo::GeoIpHandler; use guard_core_engine::ip_ban::{IpBanConfig, IpBanManager, ViolationCounters}; use guard_core_engine::ip_gate::IpGateVerdict; +use guard_core_engine::path_matching::PathExclusions; use guard_core_engine::rate_limit::{RateLimitConfig, RateLimiter}; use guard_core_rs::cloud_provider::CloudProviderStage; use guard_core_rs::custom_checks::CustomChecksStage; @@ -19,12 +20,15 @@ use guard_core_rs::geo::GeoStage; use guard_core_rs::headers_auth::HeadersAuthStage; use guard_core_rs::https_enforcement::HttpsEnforcementStage; use guard_core_rs::process_response::{RequestBits, ResponseBits, ResponseProcessor}; -use guard_core_rs::request_logging::{RequestLoggingStage, RequestLoggingStageConfig}; +use guard_core_rs::request_logging::RequestLoggingStage; use guard_core_rs::responses::{CustomErrorResponses, OnBlockHook}; use guard_core_rs::route_gates::{ReferrerStage, TimeWindowStage}; use guard_core_rs::tower::RouteRateResolver; use guard_core_rs::tower::{ObservabilityConfig, RateLimitStage, RateLimitStageConfig}; use guard_core_rs::user_agent::UserAgentStage; +use guard_core_rs::wiring::{ + ClientPolicy, CloudRefresh, DynamicRules, DynamicRulesHandle, Wiring, WiringOptions, +}; use rocket::Data; use rocket::fairing::{Fairing, Info, Kind}; use rocket::http::Status; @@ -33,6 +37,7 @@ use rocket::response::Response; use rocket::{Build, Rocket}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::Arc; +use std::time::Duration; /// Screens every request through the Guard engine before routing, and /// registers the catchers that render the refusals. @@ -143,9 +148,21 @@ pub struct GuardFairing { /// (`collect_request_metrics`: request count, response time, error /// rate); `None` emits nothing. metrics: Option, - /// The reference `exclude_paths`: request paths that bypass the whole - /// pipeline (the docs/static carve-out). - exclude_paths: Vec, + /// The reference `exclude_paths` matcher: an excluded request is + /// scoped (bans, global IP rules, rate limit), never fully bypassed. + exclusions: PathExclusions, + /// The client resolution policy (trusted proxies, depth, `fail_secure`). + client: ClientPolicy, + /// The global `auth_verifier`: the fallback of a route's verifiers. + auth_verifier: Option, + /// The response-body read bound (`body_read_timeout`). + body_read_timeout: Option, + /// The telemetry fan-out `agent_stats` reports on. + telemetry: Option>, + /// The dynamic-rule driver (host-started). + dynamic_rules: Option, + /// The per-request cloud-range refresh gate (`CloudIpRefreshCheck`). + cloud_refresh_gate: Option, /// The reference `RouteConfigResolver` (`(method, path) -> /// Option>`): the per-route carrier the pipeline /// consumes. An `Arc` stashed with @@ -198,7 +215,17 @@ impl GuardFairing { user_agent: None, response_processor: None, metrics: None, - exclude_paths: Vec::new(), + exclusions: PathExclusions::new(Vec::::new()), + client: ClientPolicy { + trusted_proxies: Vec::new(), + trusted_proxy_depth: 1, + fail_secure: false, + }, + auth_verifier: None, + body_read_timeout: None, + telemetry: None, + dynamic_rules: None, + cloud_refresh_gate: None, route_configs: None, scan_fn: guard_core_engine::detection_exclusions::scan_request, cloud_refresh: None, @@ -211,17 +238,118 @@ impl GuardFairing { Self::new(crate::default_config()) } - /// The request paths that bypass the whole pipeline (the reference - /// `exclude_paths` carve-out, exact path match). + /// The normalized `exclude_paths` entries (percent-decoded, dot + /// segments collapsed). A request path equal to an entry or below it on + /// a `/` boundary runs the scoped pipeline. #[must_use] pub fn exclude_paths(&self) -> &[String] { - &self.exclude_paths + self.exclusions.normalized() } - /// Set the `exclude_paths` carve-out. + /// Set the `exclude_paths` carve-out with the reference matcher: + /// `/static` excludes `/static` and `/static/app.js` (never + /// `/staticx`), percent-encoding and dot segments are resolved first, + /// and an undecodable path is never excluded. An excluded request is + /// not exempt from the pipeline: it runs the scoped pipeline (route + /// resolution, bans, the global IP rules and the rate limit) and its + /// response still gets the security headers, CORS and the modifier. #[must_use] pub fn with_exclude_paths(mut self, paths: Vec) -> Self { - self.exclude_paths = paths; + self.exclusions = PathExclusions::new(paths); + self + } + + /// Install an already-built [`PathExclusions`] matcher. + #[must_use] + pub fn with_path_exclusions(mut self, exclusions: PathExclusions) -> Self { + self.exclusions = exclusions; + self + } + + /// Install the client resolution policy: the trusted proxies, the + /// proxy depth and the missing-address `fail_secure` rule. A bare + /// fairing defaults to no proxies, depth 1 and `fail_secure = false` (a + /// request without a peer address continues unattributed); + /// [`GuardFairing::from_security_config`] installs the config's values + /// (`fail_secure` defaults to `true`, which answers `403 Client address + /// could not be determined`). + #[must_use] + pub fn with_client_policy(mut self, client: ClientPolicy) -> Self { + self.client = client; + self + } + + /// The installed client resolution policy. + #[must_use] + pub const fn client_policy(&self) -> &ClientPolicy { + &self.client + } + + /// Install the telemetry fan-out [`GuardFairing::agent_stats`] reports + /// on. The host owns its lifecycle: call + /// [`CompositeAgentHandler::start`](guard_core_rs::composite::CompositeAgentHandler::start) + /// at startup and `stop` on shutdown. + #[must_use] + pub fn with_telemetry( + mut self, + telemetry: Arc, + ) -> Self { + self.telemetry = Some(telemetry); + self + } + + /// The installed telemetry fan-out, if any. + #[must_use] + pub const fn telemetry(&self) -> Option<&Arc> { + self.telemetry.as_ref() + } + + /// Install the dynamic-rule driver (built by [`Wiring`]). + #[must_use] + pub fn with_dynamic_rules(mut self, dynamic_rules: DynamicRules) -> Self { + self.dynamic_rules = Some(dynamic_rules); + self + } + + /// Start the dynamic-rule poll loop (`enable_dynamic_rules` with an + /// agent): the thread polls until the returned handle is dropped. + /// `None` when no loop is configured. Wiring never starts it, the host + /// decides when. + #[must_use] + pub fn spawn_dynamic_rules(&self) -> Option { + self.dynamic_rules.as_ref().map(DynamicRules::spawn) + } + + /// Install the per-request cloud-range refresh (the reference + /// `CloudIpRefreshCheck`): a request schedules a refresh when more than + /// `cloud_ip_refresh_interval` seconds passed since the last one. The + /// same scheduler and table back + /// [`GuardFairing::refresh_cloud_ip_ranges`]. + #[must_use] + pub fn with_cloud_refresh(mut self, refresh: CloudRefresh) -> Self { + self.cloud_refresh = Some((Arc::clone(&refresh.scheduler), Arc::clone(&refresh.table))); + self.cloud_refresh_gate = Some(refresh); + self + } + + /// Install the global `auth_verifier`: the fallback for a route + /// carrier's `auth_required` / `api_key_required` verifiers. + #[must_use] + pub fn with_auth_verifier( + mut self, + verifier: guard_core_engine::headers_auth::AuthVerifier, + ) -> Self { + self.auth_verifier = Some(verifier); + self + } + + /// Bound the response-body read the `return_pattern` body rules need + /// (`body_read_timeout`). A body that does not arrive in time answers + /// the fail-secure `500`, because the consumed stream cannot be + /// replayed. + #[must_use] + pub fn with_body_read_timeout(mut self, timeout: Duration) -> Self { + self.body_read_timeout = Some(timeout); self } @@ -246,240 +374,135 @@ impl GuardFairing { self } - /// Build the fairing from the unified `SecurityConfig` - /// (the reference configuration surface): every field the fairing - /// consumes maps onto the wired stage or knob it owns, in one place, - /// with the reference semantics - the same consumption the tower - /// adapter ships under `GuardLayer::from_security_config`. + /// Build the fairing from the unified `SecurityConfig` (the reference + /// configuration surface) through the facade's [`Wiring`], the one + /// mapping every Rust adapter shares: each stage, store and hook the + /// config describes is installed on the fairing. See the + /// [`guard_core_rs::wiring`] module docs for where every field lands. /// - /// The stages that need a host-provided collaborator (the geo handler, - /// the distributed stores, the event bus, the custom checks, the - /// time-window and referrer resolvers) stay opt-in through their own - /// builders: the config carries no such object. + /// Fields the adapter reads itself: `fail_secure`, `trusted_proxies` and + /// `trusted_proxy_depth` (the client policy), `exclude_paths`, + /// `auth_verifier` (the fallback of a route's verifiers) and + /// `body_read_timeout` (the response-body read). `route_resolution_strict` + /// is not applied: Rocket resolves routes after the fairing's + /// `on_request`, so the adapter cannot tell an unresolved route apart. + /// The `agent_*` transport knobs configure the agent the host injects, + /// see [`GuardFairing::from_security_config_with`]. /// /// # Errors /// /// [`crate::GuardConfigError`] when an engine constructor rejects a - /// value (an invalid IP/CIDR list entry, a zero rate-limit knob, or a - /// ReDoS-unsafe blocked user-agent pattern). - #[allow(clippy::too_many_lines)] + /// value (an invalid IP/CIDR list entry, a zero rate-limit knob, a + /// ReDoS-unsafe blocked user-agent pattern, an unknown cloud provider, + /// country rules without a resolver, or a Redis misconfiguration). pub fn from_security_config( config: &guard_core_engine::security_config::SecurityConfig, ) -> Result { - let mut fairing = Self::new(guard_core_engine::detect::DetectConfig { - max_content_length: config.detection_max_content_length, - max_full_scan_bytes: config.detection_max_body_inspect_bytes, - preserve_attack_patterns: config.detection_preserve_attack_patterns, - semantic_threshold: config.detection_semantic_threshold, - threat_score_threshold: config.detection_threat_score_threshold, - binary_min_run_length: config.detection_binary_min_run_length, - max_scan_values: config.detection_max_scan_values, - max_scan_chars: config.detection_max_scan_chars, - max_json_depth: config.detection_max_json_depth, - }) - .with_passive_mode(config.passive_mode) - .with_penetration_detection(config.enable_penetration_detection) - .with_exclude_paths(config.exclude_paths.clone()); + Self::from_security_config_with(config, &WiringOptions::default()) + } - if config.whitelist.is_some() - || !config.blacklist.is_empty() - || !config.exempt_ips.is_empty() - { - fairing = fairing.with_ip_gate(guard_core_engine::ip_gate::IpGateConfig::new( - config.whitelist.clone().unwrap_or_default(), - config.blacklist.iter().cloned(), - config.exempt_ips.iter().cloned(), - )?); - } + /// [`GuardFairing::from_security_config`] with the collaborators the + /// config cannot carry: the telemetry agent (any + /// [`TelemetryHandler`](guard_core_rs::composite::TelemetryHandler); + /// build one from the `agent_*` fields with guard-agent-rs's + /// `GuardAgentTelemetry`) and the Logfire client. The agent feeds the + /// event bus, the metrics collector, `agent_stats` and the dynamic + /// rules. + /// + /// # Errors + /// + /// The same [`crate::GuardConfigError`]s as + /// [`GuardFairing::from_security_config`]. + pub fn from_security_config_with( + config: &guard_core_engine::security_config::SecurityConfig, + options: &WiringOptions, + ) -> Result { + Ok(Self::from_wiring(Wiring::from_security_config_with( + config, options, + )?)) + } - if config.enable_rate_limiting { - let limiter = RateLimiter::new(RateLimitConfig { - enable_rate_limiting: true, - rate_limit: config.rate_limit, - rate_limit_window: config.rate_limit_window, - ..RateLimitConfig::default() - })?; + /// Install every part of a built [`Wiring`]. The parts are public, so a + /// host can swap one (a store, the cloud scheduler) before installing. + #[must_use] + pub fn from_wiring(wiring: Wiring) -> Self { + let Wiring { + config, + detect, + detection_exclusions, + passive_mode, + penetration_detection, + exclusions, + client, + ip_gate, + rate_limiter, + ip_ban, + distributed, + https, + emergency, + user_agent, + geo, + geo_handler, + cloud, + cloud_refresh, + headers_auth, + custom_checks, + request_logging, + observability, + custom_error_responses, + on_block, + response_processor, + telemetry, + events, + metrics, + dynamic_rules, + .. + } = wiring; + let mut fairing = Self::new(detect) + .with_passive_mode(passive_mode) + .with_penetration_detection(penetration_detection) + .with_path_exclusions(exclusions) + .with_client_policy(client) + .with_detection_exclusions(detection_exclusions) + .with_https_enforcement(https) + .with_custom_error_responses(custom_error_responses); + fairing.auth_verifier.clone_from(&config.auth_verifier); + fairing.body_read_timeout = Duration::try_from_secs_f64(config.body_read_timeout).ok(); + fairing.on_block = on_block; + fairing.observability = observability; + fairing.geo_handler = geo_handler; + fairing.events = events; + fairing.metrics = metrics; + fairing.telemetry = telemetry; + fairing.dynamic_rules = dynamic_rules; + fairing.ip_gate = ip_gate; + fairing.emergency_mode = emergency; + fairing.user_agent = user_agent; + fairing.geo_blocking = geo; + fairing.cloud_provider = cloud; + fairing.headers_auth = headers_auth; + fairing.custom_checks = custom_checks; + fairing.request_logging = request_logging; + fairing.response_processor = response_processor.map(Arc::new); + if let Some(refresh) = cloud_refresh { + fairing = fairing.with_cloud_refresh(refresh); + } + if let Some(limiter) = rate_limiter { fairing = fairing.with_rate_limiting(limiter); } - - if config.enable_ip_banning { - fairing = fairing.with_ip_banning(IpBanManager::new(), config.ip_ban_config()); + if let Some((manager, ban_config)) = ip_ban { + fairing = fairing.with_ip_banning(manager, ban_config); } - - // Check 3: the global HTTPS arm; `X-Forwarded-Proto` trust rides - // the same knobs the reference reads them from. - fairing = fairing.with_https_enforcement( - HttpsEnforcementStage::builder( - guard_core_rs::https_enforcement::HttpsEnforcementStageConfig { - enforce_https: config.enforce_https, - trust_x_forwarded_proto: config.trust_x_forwarded_proto, - passive_mode: config.passive_mode, - }, - ) - .build()?, - ); - - if config.emergency_mode || !config.emergency_whitelist.is_empty() { - fairing = fairing.with_emergency_mode( - EmergencyModeStage::builder( - guard_core_rs::emergency_mode::EmergencyModeStageConfig { - emergency_mode: config.emergency_mode, - passive_mode: config.passive_mode, - }, + if let Some(distributed) = distributed { + fairing = fairing + .with_distributed_store( + distributed.window_store, + &distributed.prefix, + distributed.fail_open, ) - .emergency_whitelist(config.emergency_whitelist.iter().cloned()) - .build()?, - ); - } - - if !config.custom_error_responses.is_empty() { - fairing = fairing.with_custom_error_responses( - config - .custom_error_responses - .iter() - .map(|(status, body)| (*status, body.clone())) - .collect(), - ); - } - - if let Some(hook) = config.on_block.clone() { - fairing = fairing.with_on_block(hook); - } - - // The spread below keeps the literal compiling when the engine adds a - // field; clippy calls it needless while the field list is complete. - #[allow(clippy::needless_update)] - if !config.excluded_detection_headers.is_empty() - || !config.excluded_semantic_detection_headers.is_empty() - || !config.excluded_detection_params.is_empty() - || !config.excluded_detection_body_fields.is_empty() - || !config.enabled_detection_categories.is_empty() - || (config.detection_max_scan_seconds - - guard_core_engine::scan_budget::DEFAULT_MAX_SCAN_SECONDS) - .abs() - > f64::EPSILON - { - fairing = fairing.with_detection_exclusions(DetectionExclusionConfig { - excluded_detection_headers: config - .excluded_detection_headers - .iter() - .cloned() - .collect(), - excluded_semantic_detection_headers: config - .excluded_semantic_detection_headers - .iter() - .cloned() - .collect(), - excluded_detection_params: config - .excluded_detection_params - .iter() - .cloned() - .collect(), - excluded_detection_body_fields: config - .excluded_detection_body_fields - .iter() - .cloned() - .collect(), - enabled_detection_categories: (!config.enabled_detection_categories.is_empty()) - .then(|| { - config - .enabled_detection_categories - .iter() - .cloned() - .collect() - }), - detection_scan_body: Some(config.detection_scan_body), - max_scan_seconds: Some(config.detection_max_scan_seconds), - ..DetectionExclusionConfig::default() - }); + .with_distributed_ban_store(distributed.ban_store); } - - if let Some(level) = config.log_request_level { - // The reference construction gate: the request-logging check - // exists only when `log_request_level` is set. - fairing = - fairing.with_request_logging(RequestLoggingStage::new(RequestLoggingStageConfig { - log_request_level: Some(crate::map_log_level(level)), - muted_check_logs: Some(config.muted_check_logs.iter().cloned().collect()), - sensitive: guard_core_rs::redact::SensitiveNames::new( - Some(&config.log_sensitive_headers.iter().cloned().collect()), - Some(&config.log_sensitive_params.iter().cloned().collect()), - Some(&config.log_sensitive_body_fields.iter().cloned().collect()), - ), - })); - } - - if let Some(level) = config.log_suspicious_level { - fairing = fairing.with_observability(ObservabilityConfig { - log_suspicious_level: Some(crate::map_log_level(level)), - log_request_level: config.log_request_level.map(crate::map_log_level), - log_country_check_level: config.log_country_check_level.map(crate::map_log_level), - muted_check_logs: Some(config.muted_check_logs.iter().cloned().collect()), - sensitive: guard_core_rs::redact::SensitiveNames::new( - Some(&config.log_sensitive_headers.iter().cloned().collect()), - Some(&config.log_sensitive_params.iter().cloned().collect()), - Some(&config.log_sensitive_body_fields.iter().cloned().collect()), - ), - }); - } else if let Some(country_level) = config.log_country_check_level { - // Country verdicts compose even without a suspicious level. - fairing = fairing.with_observability(ObservabilityConfig { - log_suspicious_level: None, - log_request_level: config.log_request_level.map(crate::map_log_level), - log_country_check_level: Some(crate::map_log_level(country_level)), - muted_check_logs: Some(config.muted_check_logs.iter().cloned().collect()), - sensitive: guard_core_rs::redact::SensitiveNames::new( - Some(&config.log_sensitive_headers.iter().cloned().collect()), - Some(&config.log_sensitive_params.iter().cloned().collect()), - Some(&config.log_sensitive_body_fields.iter().cloned().collect()), - ), - }); - } - - if !config.blocked_user_agents.is_empty() { - fairing = fairing.with_user_agent( - UserAgentStage::builder(guard_core_rs::user_agent::UserAgentStageConfig { - // The error arm takes its own line: the coverage - // mapping attributes the `?` return to the function - // exit, so an inline `?` here renders count 0 forever. - blocked_user_agents: guard_core_engine::user_agent::UserAgentFilter::new( - config.blocked_user_agents.iter().cloned(), - ) - .map_err(crate::GuardConfigError::from)?, - ip_ban: config.ip_ban_config(), - passive_mode: config.passive_mode, - }) - .build()?, - ); - } - - let wants_headers = config.security_headers.enabled; - if wants_headers || config.enable_cors || !config.global_behavior_rules.is_empty() { - let cors = config - .enable_cors - .then(|| guard_core_engine::cors::CorsConfig { - enabled: true, - allow_origins: config.cors_allow_origins.clone(), - allow_methods: config.cors_allow_methods.clone(), - allow_headers: config.cors_allow_headers.clone(), - allow_credentials: config.cors_allow_credentials, - max_age: config.cors_max_age, - }); - fairing = fairing.with_response_processor(ResponseProcessor::new( - wants_headers.then_some(config.security_headers.clone()), - cors, - config.global_behavior_rules.clone(), - Arc::new(std::sync::Mutex::new( - guard_core_engine::behavior::BehaviorTracker::new(), - )), - IpBanManager::new(), - config.behavior_scan_response_body, - config.behavior_max_response_body_inspect_bytes, - config.passive_mode, - )); - } - - Ok(fairing) + fairing } /// Install the global IP gate: a `whitelist`/`blacklist`/`exempt_ips` @@ -976,16 +999,21 @@ impl GuardFairing { } /// The reference `agent_stats` (fastapi-guard `guard/middleware.py` - /// property) in its no-agent shape: `{"enabled": false, "degraded": - /// false}`. The adapter owns no agent slot (the engine-to-agent seam - /// lives in `guard-core-rs` / `guard-agent-rs`), so the enabled arm - /// has no surface here yet. + /// property): whether a telemetry sink is wired (an injected agent, or + /// the otel and Logfire sinks) and whether its start degraded. Without + /// a sink it answers `{"enabled": false, "degraded": false}`. #[must_use] - pub const fn agent_stats(&self) -> AgentStats { - AgentStats { - enabled: false, - degraded: false, - } + pub fn agent_stats(&self) -> AgentStats { + self.telemetry.as_ref().map_or( + AgentStats { + enabled: false, + degraded: false, + }, + |telemetry| AgentStats { + enabled: true, + degraded: telemetry.degraded(), + }, + ) } /// Install the blocked user-agent stage (check 14): a `User-Agent` @@ -1005,9 +1033,11 @@ impl GuardFairing { /// samples - `request_count`, `response_time` (seconds from the /// request reaching the fairing until the response is produced, like /// the reference stopping at response start) and, for a `>= 400` - /// status, `error_rate`. Guard refusals, the CORS preflight and - /// redirect answers, and `exclude_paths` bypasses emit nothing. The - /// collector owns the `agent_enable_metrics` gate and the transport. + /// status, `error_rate`. An `exclude_paths` request that passes its + /// scoped pipeline is counted too (the reference behavior). Guard + /// refusals, the CORS preflight and redirect answers, and + /// all-checks-bypass routes emit nothing. The collector owns the + /// `agent_enable_metrics` gate and the transport. #[must_use] pub fn with_metrics(mut self, metrics: crate::MetricsCollector) -> Self { self.metrics = Some(metrics); @@ -1143,14 +1173,10 @@ impl Fairing for GuardFairing { } async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) { - // The response-time clock starts here; the `exclude_paths` bypass - // never starts it, so it never emits. - let excluded = self - .exclude_paths - .iter() - .any(|path| path == request.uri().path().as_str()); - if self.metrics.is_some() && !excluded { - request.local_cache(|| RequestStart(Some(std::time::Instant::now()))); + // The response-time clock starts here. An all-checks-bypass route + // never emits (the response pass skips the metrics for it). + if self.metrics.is_some() { + request_start(request); } let verdict = self.evaluate(request); @@ -1204,79 +1230,135 @@ impl Fairing for GuardFairing { } } + let pass = crate::scan::pass(request); + // The reference `collect_request_metrics`: only the forwarded lane // (the request passed the pipeline and no route guard refused it) // emits, once the response is produced (the response time stops at - // response start). + // response start). An all-checks-bypass route emits nothing. if let Some(metrics) = &self.metrics + && pass != Pass::ModifierOnly && metadata_verdict(request) == Some(Verdict::Clean) && crate::scan::enforced_verdict(request).is_none() - && let Some(started) = request.local_cache(|| RequestStart(None)).0 { metrics.collect_request_metrics( request.uri().path().as_str(), request.method().as_str(), - Some(started.elapsed().as_secs_f64()), + Some(request_start(request).elapsed().as_secs_f64()), response.status().code, ); } - // The response-side pass (behavioral return rules + security - // headers + CORS) on every response the fairing touches. + self.response_pass(request, response, pass).await; + } +} + +impl GuardFairing { + /// The response-side pass (behavioral return rules + security headers + + /// CORS + the modifier) on every response the fairing touches. The + /// modifier runs even without a processor. + async fn response_pass<'r>( + &self, + request: &'r Request<'_>, + response: &mut Response<'r>, + pass: Pass, + ) { + if self.response_processor.is_none() && self.response_modifier.is_none() { + return; + } + let client_ip = client_ip_string(crate::scan::client_ip(request)); + let mut prefix: Option> = None; + if pass == Pass::Full + && let Some(processor) = &self.response_processor + && processor.needs_response_body() + { + match self.buffer_response_body(response).await { + Some(bytes) => { + let max = processor.max_inspect_bytes().min(bytes.len()); + prefix = Some(bytes[..max].to_vec()); + } + // The consumed stream cannot be replayed: fail secure. + None => *response = response::verdict_response_plain(Verdict::Failed), + } + } + let mut bits = ResponseBits { + status: response.status().code, + body: None, + headers: std::collections::BTreeMap::new(), + }; + let request_bits = RequestBits { + method: request.method().as_str().to_owned(), + url_path: request.uri().path().as_str().to_owned(), + client_ip: client_ip.clone(), + origin: request.headers().get_one("origin").map(str::to_owned), + }; if let Some(processor) = &self.response_processor { - let mut bits = ResponseBits { - status: response.status().code, - body: None, - headers: std::collections::BTreeMap::new(), - }; - let request_bits = RequestBits { - method: request.method().as_str().to_owned(), - url_path: request.uri().path().as_str().to_owned(), - client_ip: client_ip_string(request.client_ip()), - origin: request.headers().get_one("origin").map(str::to_owned), - }; - let _action = - processor.process(&request_bits, &mut bits, None, std::time::SystemTime::now()); - self.emit_headers_applied_event(processor, request); - - // The reference `custom_response_modifier`: the callback runs - // LAST over the response view. A panicking callback restores - // the unmodified view (the reference's except arm) and - // reports through the `on_error` hook. - if let Some(modifier) = &self.response_modifier { - let unmodified = bits.clone(); - let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - modifier(&mut bits); - })); - if outcome.is_err() { - bits = unmodified; - if let Some(hook) = &self.on_error { - hook( - "custom_response_modifier", - "the response modifier panicked; returning unmodified response", - &[("path".to_owned(), request.uri().path().as_str().to_owned())], - ); - } + match pass { + Pass::Full => { + let _action = processor.process( + &request_bits, + &mut bits, + prefix.as_deref(), + std::time::SystemTime::now(), + ); + } + Pass::Scoped => { + let _action = processor.process_scoped( + &request_bits, + &mut bits, + std::time::SystemTime::now(), + ); } + Pass::ModifierOnly => processor.apply_modifier(&request_bits, &mut bits), } - - if bits.status != response.status().code - && let Some(code) = rocket::http::Status::from_code(bits.status) - { - response.set_status(code); + if pass != Pass::ModifierOnly { + self.emit_headers_applied_event(processor, request, &client_ip); } - for (name, value) in bits.headers { - response.set_raw_header(name, value); + } + + // The reference `custom_response_modifier`: the layer-level callback + // runs LAST over the response view. A panicking callback restores + // the unmodified view (the reference's except arm) and reports + // through the `on_error` hook. + if let Some(modifier) = &self.response_modifier { + let unmodified = bits.clone(); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + modifier(&mut bits); + })); + if outcome.is_err() { + bits = unmodified; + if let Some(hook) = &self.on_error { + hook( + "custom_response_modifier", + "the response modifier panicked; returning unmodified response", + &[("path".to_owned(), request.uri().path().as_str().to_owned())], + ); + } } } + + if bits.status != response.status().code + && let Some(code) = rocket::http::Status::from_code(bits.status) + { + response.set_status(code); + } + for (name, value) in bits.headers { + response.set_raw_header(name, value); + } } } -/// Request-local slot holding the instant the request reached the fairing -/// (`None` when the request never starts the clock: no collector installed -/// or the `exclude_paths` bypass). +/// Request-local slot holding the instant the request reached the fairing. #[derive(Debug, Clone, Copy)] -struct RequestStart(Option); +struct RequestStart(std::time::Instant); + +/// The instant the request reached the fairing (the first call, in +/// `on_request`, starts the clock; later calls read it). +fn request_start(request: &Request<'_>) -> std::time::Instant { + request + .local_cache(|| RequestStart(std::time::Instant::now())) + .0 +} /// The `RequestBits.client_ip` mapping: the connection's IP when it carries /// one, the empty string otherwise (a request served over a unix-socket @@ -1302,7 +1384,12 @@ impl GuardFairing { /// headers without firing - the reference `create_error_response` /// lane, where the event sits behind the request path. A disabled /// config and an absent bus each stay silent. - fn emit_headers_applied_event(&self, processor: &ResponseProcessor, request: &Request<'_>) { + fn emit_headers_applied_event( + &self, + processor: &ResponseProcessor, + request: &Request<'_>, + client_ip: &str, + ) { if metadata_verdict(request) != Some(Verdict::Clean) || crate::scan::enforced_verdict(request).is_some() { @@ -1321,11 +1408,192 @@ impl GuardFairing { set.len(), set.contains_key("Content-Security-Policy"), set.contains_key("Strict-Transport-Security"), - &client_ip_string(request.client_ip()), + client_ip, ), ); } + /// Read the forwarded response body for the `return_pattern` body rules + /// and re-emit it intact. `None` on a read error or a + /// `body_read_timeout` expiry: the consumed stream cannot be replayed, + /// so the caller answers the fail-secure `500`. + async fn buffer_response_body(&self, response: &mut Response<'_>) -> Option> { + let read = response.body_mut().to_bytes(); + let bytes = match self.body_read_timeout { + Some(limit) => rocket::tokio::time::timeout(limit, read) + .await + .ok() + .and_then(Result::ok), + None => read.await.ok(), + }?; + response.set_sized_body(bytes.len(), std::io::Cursor::new(bytes.clone())); + Some(bytes) + } + + /// The `403` a peer-less request answers under `fail_secure` (the + /// reference `handle_passthrough`), through the normal block path: the + /// guard enforces it, the response pass renders on it, and the + /// `on_block` hook fires with `client_address_unresolved`. + fn unresolved_client_block(request: &Request<'_>, engine: &GuardEngine) -> Verdict { + let sensitive = engine + .observability + .as_ref() + .map(|observability| observability.sensitive.clone()) + .unwrap_or_default(); + let observation = crate::scan::request_observation(request); + let payload = guard_core_rs::responses::build_block_payload( + guard_core_engine::ip_extraction::UNRESOLVABLE_CLIENT_ADDRESS_CHECK_NAME, + guard_core_engine::ip_extraction::UNRESOLVABLE_CLIENT_ADDRESS_MESSAGE, + "", + false, + guard_core_engine::ip_extraction::UNKNOWN_CLIENT_IDENTITY, + request.uri().path().as_str(), + observation.method.as_deref().unwrap_or(""), + Some(guard_core_engine::ip_extraction::UNRESOLVABLE_CLIENT_ADDRESS_STATUS), + &sensitive, + ); + guard_core_rs::responses::fire_block_hook(engine.on_block.as_ref(), &payload); + crate::scan::stage_block( + request, + guard_core_engine::ip_extraction::UNRESOLVABLE_CLIENT_ADDRESS_STATUS, + guard_core_engine::ip_extraction::UNRESOLVABLE_CLIENT_ADDRESS_MESSAGE, + ) + } + + /// The `security_bypass` event of an all-checks-bypass route (the + /// reference `handle_security_bypass`). + fn emit_security_bypass_event( + &self, + request: &Request<'_>, + route: Option<&guard_core_engine::route_config::RouteConfig>, + client_ip: Option, + ) { + let Some(bus) = &self.events else { + return; + }; + let mut event = guard_core_rs::events::SecurityEvent::new( + guard_core_rs::event_types::EVENT_SECURITY_BYPASS, + &client_ip_string(client_ip), + "all_checks_bypassed", + "Route configured to bypass all security checks", + guard_core_rs::events::MIDDLEWARE_HANDLER_NAME, + ); + let sensitive = self + .observability + .as_ref() + .map(|observability| observability.sensitive.clone()) + .unwrap_or_default(); + event.endpoint = Some(guard_core_rs::redact::redact_url_for_display( + request.uri().path().as_str(), + &sensitive, + )); + event.method = Some(request.method().as_str().to_owned()); + event.user_agent = request.headers().get_one("user-agent").map(str::to_owned); + let checks: Vec = route + .into_iter() + .flat_map(|route| route.bypassed_checks.iter()) + .map(|check| serde_json::Value::String(check.clone())) + .collect(); + event.metadata.insert( + "bypassed_checks".to_owned(), + serde_json::Value::Array(checks), + ); + bus.send_event(&event); + } + + /// The global IP gate over the resolved client: `Some(verdict)` when it + /// blocks, `None` when it passes (recording the gate's skip state for + /// the stages that read it) or is not configured. + fn enforce_ip_gate( + &self, + engine: &GuardEngine, + request: &Request<'_>, + client_ip: Option, + ) -> Option { + let gate = self.ip_gate.as_ref()?; + let ip = client_ip?; + match gate.evaluate(ip) { + IpGateVerdict::Denied(denial) => { + // The reference `ip_filter` block path: passive mode logs + // the crossing and forwards (no verdict is stashed and no + // gate decision recorded, so the request proceeds exactly + // like an unattributed one), the `on_block` hook fires once + // with the reference payload keys, and the custom-error + // body override wins over the family default. + if self.passive_mode { + return None; + } + let ip_string = 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) { + crate::scan::record_block_body(request, Some(custom.clone())); + } + if let Some(observability) = &engine.observability { + let observation = crate::scan::request_observation(request); + let payload = guard_core_rs::responses::build_block_payload( + "ip_security", + &format!("IP address blocked: {ip_string}"), + denial.reason(), + false, + &ip_string, + observation.url.as_deref().unwrap_or("/"), + observation.method.as_deref().unwrap_or(""), + Some(403), + &observability.sensitive, + ); + guard_core_rs::responses::fire_block_hook(engine.on_block.as_ref(), &payload); + } + Some(Verdict::IpBlocked) + } + IpGateVerdict::Allowed(decision) => { + record_gate_decision(request, decision); + None + } + } + } + + /// The scoped pipeline of an `exclude_paths` request (the reference + /// `enforced_on_excluded_paths` checks): the ban check, the GLOBAL IP + /// gate, country and cloud rules (no route lists, no auto-ban + /// escalation, no route-level `ip` bypass), then the rate-limit tiers. + /// Emergency mode, HTTPS, logging, headers and auth, validators, the + /// scan, the user-agent filter, custom checks and the return rules are + /// skipped. + fn evaluate_scoped( + &self, + engine: &GuardEngine, + request: &Request<'_>, + client_ip: Option, + ) -> Verdict { + if let Some(verdict) = crate::scan::bans_decision(engine, request) { + return verdict; + } + if let Some(verdict) = self.enforce_ip_gate(engine, request, client_ip) { + return verdict; + } + let gate = crate::scan::gate_decision(request); + if let Some(stage) = &self.geo_blocking + && let Some(decision) = stage.decide(client_ip, gate) + { + return crate::scan::stage_block( + request, + decision.answer.status.as_u16(), + stage_answer_body(&decision.answer), + ); + } + if let Some(stage) = &self.cloud_provider + && let Some(decision) = stage.decide(client_ip, gate) + { + return crate::scan::stage_block( + request, + decision.answer.status.as_u16(), + stage_answer_body(&decision.answer), + ); + } + stage_decision(engine, request, None).map_or(Verdict::Clean, |(verdict, _)| verdict) + } + /// The request's verdict: the IP gate first (a denied client IP is the /// verdict, no scan needed), then the reference pipeline's stages in /// order (emergency mode, HTTPS enforcement, request logging, required @@ -1346,14 +1614,17 @@ impl GuardFairing { .unwrap_or(&self.engine); let _ = &self.engine; - // The reference `exclude_paths` carve-out runs first: the - // docs/static paths bypass the whole pipeline (exact path match), - // detection included. The response-side pass still renders (the - // fairing's `on_response`). + // The client identity every stage runs under: the connecting peer, + // `X-Forwarded-For` honored only through the trusted proxies + // (Rocket's own `client_ip()` would trust the `X-Real-IP` header + // from anyone, so it is never consulted). let path = request.uri().path().as_str(); - if self.exclude_paths.iter().any(|excluded| excluded == path) { - return Verdict::Clean; - } + let forwarded: Vec<&str> = request.headers().get("x-forwarded-for").collect(); + let forwarded = (!forwarded.is_empty()).then(|| forwarded.join(", ")); + let peer = request.remote().map(|remote| remote.ip()); + let resolution = self.client.resolve(peer, forwarded.as_deref()); + let client_ip = resolution.ip; + crate::scan::stash_client(request, client_ip); // The reference's CORS preflight short-circuit: an OPTIONS request // carrying `access-control-request-method` answers from the @@ -1403,6 +1674,18 @@ impl GuardFairing { } } + // The reference `handle_passthrough`: an `exclude_paths` entry + // marks the request scoped (it keeps running the scoped pipeline + // below, never a whole-pipeline bypass); any other request with no + // resolvable client address is rejected under `fail_secure`, + // before every check. + let scoped = self.exclusions.is_excluded(path); + if scoped { + crate::scan::stash_pass(request, Pass::Scoped); + } else if resolution.reject { + return Self::unresolved_client_block(request, engine); + } + // The reference `RouteConfigResolver`: the stashed carrier (the // app attached one with `set_route_config`) wins over the // installed resolver. The resolved carrier rides the request-local @@ -1420,6 +1703,26 @@ impl GuardFairing { if let Some(Err(_)) = route.map(guard_core_engine::route_config::RouteConfig::rate_limits) { return Verdict::Failed; } + + // The reference `handle_security_bypass`: a route that bypasses + // `all` emits the `security_bypass` event, runs no check and gets + // only the response modifier (no metrics, headers or return + // rules). Passive mode continues through the pipeline. + if route.is_some_and(|route| route.bypassed_checks.contains("all")) { + self.emit_security_bypass_event(request, route, client_ip); + if !self.passive_mode { + crate::scan::stash_pass(request, Pass::ModifierOnly); + return Verdict::Clean; + } + } + + // The scoped pipeline of an excluded path (the reference + // `enforced_on_excluded_paths` checks): the ban check, the GLOBAL + // ip rules, then the rate-limit tiers. Everything else is skipped. + if scoped { + return self.evaluate_scoped(engine, request, client_ip); + } + if let Some(size) = route .and_then(|route| route.max_request_size) .and_then(|size| usize::try_from(size).ok()) @@ -1445,7 +1748,7 @@ impl GuardFairing { ); let actions = processor.process_usage_rules( &endpoint_id, - &client_ip_string(request.client_ip()), + &client_ip_string(client_ip), route_rules, std::time::SystemTime::now(), ); @@ -1477,7 +1780,7 @@ impl GuardFairing { route.ip_blacklist.iter().flatten().map(String::as_str), ) { Ok(gate) => { - let verdict = request.client_ip().map_or( + let verdict = client_ip.map_or( guard_core_engine::ip_gate::RouteIpVerdict::Unrestricted, |addr| gate.evaluate(addr), ); @@ -1510,7 +1813,7 @@ impl GuardFairing { .map(String::as_str), route.blocked_countries.iter().flatten().map(String::as_str), ); - if let (Some(addr), Some(handler)) = (request.client_ip(), self.geo_handler.as_ref()) { + 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, @@ -1525,56 +1828,16 @@ impl GuardFairing { if !bypassed("ip") && !skip_global_ip_lists - && let Some(gate) = &self.ip_gate - && let Some(ip) = request.client_ip() + && let Some(verdict) = self.enforce_ip_gate(engine, request, client_ip) { - match gate.evaluate(ip) { - IpGateVerdict::Denied(denial) => { - // The reference `ip_filter` block path: passive mode - // logs the crossing and forwards (no verdict is - // stashed and no gate decision recorded, so the - // request proceeds exactly like an unattributed one), - // the `on_block` hook fires once with the reference - // payload keys, and the custom-error body override - // wins over the family default. - if !self.passive_mode { - let ip_string = ip.to_string(); - // The custom-error body override wins over the - // family default; with no override the catcher's - // verdict arm renders the family default itself. - if let Some(custom) = engine.custom_error_responses.get(&403) { - crate::scan::record_block_body(request, Some(custom.clone())); - } - if let Some(observability) = &engine.observability { - let observation = crate::scan::request_observation(request); - let payload = guard_core_rs::responses::build_block_payload( - "ip_security", - &format!("IP address blocked: {ip_string}"), - denial.reason(), - false, - &ip_string, - observation.url.as_deref().unwrap_or("/"), - observation.method.as_deref().unwrap_or(""), - Some(403), - &observability.sensitive, - ); - guard_core_rs::responses::fire_block_hook( - engine.on_block.as_ref(), - &payload, - ); - } - return Verdict::IpBlocked; - } - } - IpGateVerdict::Allowed(decision) => record_gate_decision(request, decision), - } + return verdict; } // Check 2: emergency mode (503 outside the whitelist). The // reference pipeline never consults the bypass set here: the // global stage is not route-bypassable. if let Some(stage) = &self.emergency_mode { - let ip = request.client_ip(); + let ip = client_ip; let ip_string = ip.map_or_else(String::new, |addr| addr.to_string()); if let Some(answer) = stage.decide( ip.is_some().then_some(ip_string.as_str()), @@ -1596,6 +1859,11 @@ impl GuardFairing { .get_one("host") .unwrap_or_default() .to_owned(); + // The transport's own scheme (never a header's claim) and the + // connecting peer: the HTTPS stage trusts `X-Forwarded-Proto` + // only from a trusted proxy. + let scheme = ["http", "https"][usize::from(request.rocket().config().tls_enabled())]; + let peer_host = peer.map(|addr| addr.to_string()); let path = request.uri().path().as_str().to_owned(); let query = request .uri() @@ -1615,16 +1883,22 @@ impl GuardFairing { if let Some(route) = route { stage.decide_route( &path, - "http", - None, + scheme, + peer_host.as_deref(), forwarded.as_deref(), &https_url, Some(route.require_https), ) } else { - stage.decide(&path, "http", None, forwarded.as_deref(), &https_url) + stage.decide( + &path, + scheme, + peer_host.as_deref(), + forwarded.as_deref(), + &https_url, + ) } - } else if route_require_https && !self.passive_mode { + } else if route_require_https && scheme != "https" && !self.passive_mode { Some(guard_core_rs::https_enforcement::HttpsRedirectAnswer { status: 301, location: https_url.clone(), @@ -1641,7 +1915,7 @@ impl GuardFairing { // Check 4: request logging (compose-only, never blocks; the // composed line is the host's to emit). if let Some(stage) = &self.request_logging { - let ip = request.client_ip(); + let ip = client_ip; let ip_string = ip.map_or_else(String::new, |addr| addr.to_string()); let mut url = request.uri().path().as_str().to_owned(); if let Some(query) = request.uri().query() { @@ -1657,7 +1931,7 @@ impl GuardFairing { } // The request facts the route gates read. - let ip = request.client_ip(); + let ip = client_ip; let ip_string = ip.map_or_else(String::new, |addr| addr.to_string()); let path = request.uri().path().as_str(); let method = request.method().as_str(); @@ -1688,8 +1962,14 @@ impl GuardFairing { }; if rules.has_rules() { let verifiers = guard_core_engine::headers_auth::RouteVerifiers { - auth: route.auth_verifier.clone(), - api_key: route.api_key_verifier.clone(), + auth: route + .auth_verifier + .clone() + .or_else(|| self.auth_verifier.clone()), + api_key: route + .api_key_verifier + .clone() + .or_else(|| self.auth_verifier.clone()), }; let pairs: Vec<(String, String)> = request .headers() @@ -1909,6 +2189,11 @@ impl GuardFairing { // the global one for the request (an empty route list falls back // to the global config); an invalid route selector fails secure // (the reference validates at decoration time). + // The reference `CloudIpRefreshCheck`: schedule a range refresh + // when one is due. + if let Some(refresh) = &self.cloud_refresh_gate { + let _ = refresh.refresh_if_due(); + } if !bypassed("clouds") { let route_selectors = route .filter(|route| !route.block_cloud_providers.is_empty()) @@ -2457,7 +2742,13 @@ mod tests { // An OPTIONS without the request-method header is not a preflight: // nothing is stashed, the pipeline runs, and the unrouted GET-only // path answers the router's own 404 (the route-inventory shield). - let response = client.options("/hello").dispatch().await; + // (`from_security_config` rejects a peer-less request under + // `fail_secure`, so the request carries a peer.) + let response = client + .options("/hello") + .remote(peer([203, 0, 113, 9])) + .dispatch() + .await; assert_eq!(response.status(), Status::NotFound); } @@ -4715,7 +5006,7 @@ mod stateful_tests { } #[tokio::test] - async fn no_metrics_without_a_collector_or_when_disabled_blocked_or_excluded() { + async fn no_metrics_without_a_collector_or_when_disabled_or_blocked() { // No collector installed: nothing to emit, the request still passes. assert_eq!( dispatch_status(GuardFairing::with_defaults(), "/hello").await, @@ -4734,13 +5025,20 @@ mod stateful_tests { assert_ne!(dispatch_status(fairing, ATTACK_PATH).await, Status::Ok); assert!(seen.lock().expect("sink").is_empty()); - // The `exclude_paths` carve-out bypasses the whole pipeline. + // An `exclude_paths` request is scoped, not bypassed: the response + // still emits the request metrics (the reference behavior). let (collector, seen) = recording_metrics(true); let fairing = GuardFairing::with_defaults() .with_exclude_paths(vec!["/hello".to_owned()]) .with_metrics(collector); assert_eq!(dispatch_status(fairing, "/hello").await, Status::Ok); - assert!(seen.lock().expect("sink").is_empty()); + assert_eq!( + metric_summary(&seen), + vec![ + ("request_count".to_owned(), None), + ("response_time".to_owned(), Some("200".to_owned())), + ] + ); } #[tokio::test] @@ -4856,9 +5154,17 @@ mod stateful_tests { #[tokio::test] async fn the_country_level_alone_installs_observability_and_composes() { + // Country rules need a resolver: the wiring fails closed without one. + struct Unlocated; + impl guard_core_engine::geo::GeoIpHandler for Unlocated { + fn get_country(&self, _ip: std::net::IpAddr) -> Option { + None + } + } let config = SecurityConfig { log_suspicious_level: None, blocked_countries: [String::from("RU")].into_iter().collect(), + geo_ip_handler: Some(Arc::new(Unlocated)), ..SecurityConfig::default() }; let fairing = GuardFairing::from_security_config(&config).expect("valid"); @@ -4866,18 +5172,10 @@ mod stateful_tests { assert_eq!(observability.log_suspicious_level, None); // A dispatch through the geo position composes the verdict line - // (the geo stage is a host-provided collaborator). + // (the unlocated client passes the country block list). let path = "/hello?q=benign"; let (status, _, _) = full_status( - GuardFairing::from_security_config(&config) - .expect("valid") - .with_geo_blocking(guard_core_rs::geo::GeoStage::new( - guard_core_rs::geo::GeoStageConfig { - gate: guard_core_engine::geo::parse_country_lists([] as [&str; 0], ["RU"]), - handler: None, - passive_mode: false, - }, - )), + GuardFairing::from_security_config(&config).expect("valid"), path, "203.0.113.9", ) @@ -4975,7 +5273,7 @@ mod stateful_tests { } #[tokio::test] - async fn from_security_config_exclude_paths_bypass_the_pipeline() { + async fn from_security_config_exclude_paths_scope_the_pipeline() { let config = SecurityConfig { blacklist: vec![String::from("203.0.113.9")], exclude_paths: vec![String::from("/docs")], @@ -4983,13 +5281,20 @@ mod stateful_tests { }; let client = tracked_config(GuardFairing::from_security_config(&config).expect("valid")).await; - // An excluded path bypasses every check, gate included: the - // blacklisted IP forwards on /docs. + // An excluded path is scoped, not bypassed: the global IP gate + // still applies, so the blacklisted IP is denied on /docs ... let response = client .get("/docs") .remote(peer_ip([203, 0, 113, 9])) .dispatch() .await; + assert_eq!(response.status(), Status::Forbidden); + // ... while any other client forwards on it. + let response = client + .get("/docs") + .remote(peer_ip([203, 0, 113, 10])) + .dispatch() + .await; assert_eq!(response.status(), Status::Ok); assert_eq!(response.into_string().await.as_deref(), Some("docs")); // Any other path takes the gate denial. @@ -5264,15 +5569,21 @@ mod stateful_tests { #[test] fn from_security_config_empty_category_set_skips_the_exclusion_block() { - // The reference's empty `enabled_detection_categories` frozenset: - // an explicitly empty set disables every category, and the - // detection-exclusion block is not installed at all. + // An empty `enabled_detection_categories` set maps to `None` (every + // category stays enabled). The wiring always installs the + // detection-exclusion block (the scan reads the body toggle and the + // scan budget from it), so the block exists with no category set. let config = SecurityConfig { enabled_detection_categories: std::collections::BTreeSet::new(), ..crate::SecurityConfig::default() }; let fairing = GuardFairing::from_security_config(&config).expect("valid config"); - assert!(fairing.detection_exclusions.is_none()); + assert!( + fairing + .detection_exclusions + .as_ref() + .is_some_and(|exclusions| exclusions.enabled_detection_categories.is_none()) + ); } #[test] @@ -5300,7 +5611,8 @@ mod stateful_tests { .await; assert_eq!(response.status(), Status::Ok); // The forwarded response still carries the security-header set: the - // carve-out bypasses the request-side checks, not the response pass. + // carve-out is scoped on the request side, not exempt from the + // response pass. assert_eq!( response.headers().get_one("x-content-type-options"), Some("nosniff") diff --git a/src/guards.rs b/src/guards.rs index 413607c..50b8a0c 100644 --- a/src/guards.rs +++ b/src/guards.rs @@ -60,6 +60,37 @@ impl<'r> FromRequest<'r> for BlockGuard { } } +/// The marker a request on an `exclude_paths` entry carries (the reference +/// `request.state.guard_exclusion_scoped`): the request runs the scoped +/// pipeline (route resolution, bans, the global IP rules, the rate limit) +/// and every other check is skipped, the body scan of [`GuardBody`] +/// included. Read it as an optional request guard: +/// +/// ``` +/// use rocket::get; +/// use rocket_guard_rs::GuardExclusionScoped; +/// +/// #[get("/docs")] +/// fn docs(scoped: Option) -> &'static str { +/// if scoped.is_some() { "scoped" } else { "normal" } +/// } +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GuardExclusionScoped; + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for GuardExclusionScoped { + type Error = std::convert::Infallible; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + if crate::scan::pass(request) == crate::scan::Pass::Scoped { + Outcome::Success(Self) + } else { + Outcome::Forward(Status::NotFound) + } + } +} + /// The scanned request body, usable as a data guard. /// /// Buffering, scanning, and handing the body to the handler are fused into @@ -154,9 +185,17 @@ impl<'r> FromData<'r> for GuardBody { Err(_) => return refused(request, Verdict::Failed, Status::InternalServerError), }; + // An excluded request (scoped pipeline) and a route that bypasses + // `all` or `penetration` skip the body scan; the size cap above + // stays as the memory bound. + let skip_scan = crate::scan::pass(request) != crate::scan::Pass::Full + || crate::scan::carrier_bypasses( + crate::scan::route_carrier(request).as_deref(), + "penetration", + ); let verdict = catch_unwind(AssertUnwindSafe(|| { let metadata = metadata_verdict(request).unwrap_or(Verdict::Clean); - if metadata != Verdict::Clean { + if metadata != Verdict::Clean || skip_scan { return metadata; } // The body view, fed through the stage's split detection feed diff --git a/src/lib.rs b/src/lib.rs index 7c8108c..a2e5787 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -149,20 +149,74 @@ //! ## The unified configuration surface //! //! [`GuardFairing::from_security_config`] builds the whole wired pipeline -//! from the engine's [`SecurityConfig`] (the reference 129-field -//! configuration surface) in one fail-closed call: the detection budgets, -//! the IP lists onto the gate, the rate-limit and ban groups, -//! `enforce_https`, `emergency_mode` + its whitelist, -//! `custom_error_responses`/`on_block`, the detection-exclusion group, the -//! observability group, the ReDoS-validated `blocked_user_agents`, and the -//! security-headers/CORS/behavior response pass. The reference -//! `exclude_paths` carve-out rides along as a first-class builder consumed -//! first in the pipeline (an excluded path bypasses every request-side -//! check, gate included). Invalid values fail closed through the typed -//! [`GuardConfigError`]. The stages that need a host-provided collaborator -//! (the geo handler, the distributed stores, the event bus, the custom -//! checks, the time-window and referrer resolvers) stay opt-in through -//! their own builders. +//! from the engine's [`SecurityConfig`] (the reference configuration +//! surface) through the facade's [`Wiring`], the one mapping every Rust +//! adapter shares (the field-by-field table is in the +//! [`guard_core_rs::wiring`] module docs). Geo country rules and +//! `geo_ip_handler`, `block_cloud_providers` (with the per-request range +//! refresh), `endpoint_rate_limits`, the rate-limit auto-ban, +//! `custom_request_check`, `auth_verifier` (the fallback of a route's +//! verifiers), `custom_response_modifier`, `on_error`, `cors_expose_headers`, +//! the telemetry sink, the event bus and the dynamic rules all land on the +//! fairing. Invalid values fail closed through the typed +//! [`GuardConfigError`]. [`GuardFairing::from_security_config_with`] takes +//! the collaborators a config cannot carry: the telemetry agent (any +//! [`TelemetryHandler`], for example guard-agent-rs's `GuardAgentTelemetry` +//! built from the `agent_*` fields) and the Logfire client. Wiring starts +//! nothing: [`GuardFairing::telemetry`] exposes the fan-out for the host to +//! `start()` / `stop()`, and [`GuardFairing::spawn_dynamic_rules`] starts the +//! dynamic-rule poll loop. `route_resolution_strict` is not applied (Rocket +//! resolves routes after `on_request`) and stays on the config. +//! +//! ## Client resolution +//! +//! The identity every stage runs under (gate, bans, rate limit, geo, cloud, +//! events, the response pass) is resolved from the connecting peer with the +//! reference rules: `X-Forwarded-For` counts only behind a trusted proxy +//! (`trusted_proxies`, `trusted_proxy_depth`) and an IPv4-mapped IPv6 peer +//! is canonicalized. Rocket's own `Request::client_ip()` is never used: it +//! honors the `X-Real-IP` header from any peer. 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) before any check, and continues unattributed +//! under `fail_secure = false`. A bare fairing defaults to no proxies and +//! `fail_secure = false`; [`GuardFairing::with_client_policy`] installs a +//! [`ClientPolicy`] and `from_security_config` installs the config's +//! (`fail_secure` defaults to `true`). The HTTPS stage matches +//! `trusted_proxies` against the connecting peer and takes the transport's +//! own scheme, so a forged `X-Forwarded-Proto` does not skip the redirect. +//! +//! ## Excluded paths and bypassed routes +//! +//! `exclude_paths` uses the reference matcher: `/static` covers `/static` +//! and `/static/app.js` (never `/staticx`) and percent-encoding and dot +//! segments are resolved before matching. An excluded request is scoped, +//! not bypassed: it carries [`GuardExclusionScoped`] and runs route +//! resolution, the ban check, the global IP lists, country and cloud rules +//! and the rate limit. Emergency mode, HTTPS enforcement, request logging, +//! headers and auth, validators, the scan (the [`GuardBody`] body scan +//! included, its size cap stays as the memory bound), the user-agent +//! filter, custom checks and the return rules are skipped. The response +//! still gets the metrics, security headers, CORS and the modifier. The CORS +//! preflight short-circuit runs before the exclusion decision. A route +//! carrier that bypasses `all` checks emits the `security_bypass` event, +//! runs no check and gets only the response modifier (under `passive_mode` +//! it continues through the pipeline). Rocket cannot answer from +//! `on_request`, so every refusal here rides the same verdict, guard and +//! catcher path as any other block. +//! +//! ## The response pass +//! +//! The modifier of the processor (from the config) and the fairing's +//! ([`GuardFairing::with_custom_response_modifier`]) each run once, and the +//! fairing's runs even without a processor. Body `return_pattern` rules +//! read the response body: when a rule needs it +//! (`behavior_scan_response_body`) the response body is buffered in full +//! (bounded in time by `body_read_timeout`; a read error or timeout answers +//! the fail-secure `500`) and re-emitted intact, and the first +//! `behavior_max_response_body_inspect_bytes` bytes are what the rules read. +//! Status-only rules never buffer; do not combine body rules with endless +//! streaming responses. //! //! These bodies follow the ecosystem's plain-text convention (the bare //! message, `text/plain; charset=utf-8`, same as the Python family) @@ -185,7 +239,7 @@ pub mod websocket; pub use crate::fairing::AgentStats; pub use crate::fairing::GuardFairing; -pub use crate::guards::{BlockGuard, GuardBody, GuardBodyError}; +pub use crate::guards::{BlockGuard, GuardBody, GuardBodyError, GuardExclusionScoped}; pub use crate::response::{ ACTIVITY_BANNED_MESSAGE, BANNED_MESSAGE, BLOCKED_MESSAGE, FAILURE_MESSAGE, FORBIDDEN_MESSAGE, OVERSIZE_MESSAGE, RATE_LIMITED_MESSAGE, guard_catchers, @@ -201,6 +255,7 @@ pub use guard_core_engine::detection_exclusions::{ }; pub use guard_core_engine::distributed::{BanStore, SlidingWindowStore}; pub use guard_core_engine::geo::GeoIpHandler; +pub use guard_core_engine::headers_auth::AuthVerifier; pub use guard_core_engine::ip_ban::{ BanError, BanRecord, Clock, IpBanConfig, IpBanConfigError, IpBanManager, ResolvedBan, ThreatBanEntry, ViolationCounters, @@ -208,6 +263,7 @@ pub use guard_core_engine::ip_ban::{ pub use guard_core_engine::ip_gate::{ IpGateConfig, IpGateDecision, IpGateDenial, IpGateError, IpGateVerdict, }; +pub use guard_core_engine::path_matching::PathExclusions; pub use guard_core_engine::rate_limit::{ RateLimitConfig, RateLimitConfigError, RateLimitDecision, RateLimitEntry, RateLimitTier, RateLimiter, RouteRateLimits, TierDecision, @@ -217,12 +273,17 @@ pub use guard_core_engine::security_config::{ BufferOverflowPolicy, LogFormat, LogLevel, SecurityConfig, SecurityConfigError, }; pub use guard_core_engine::security_headers::SecurityHeadersConfig; +pub use guard_core_rs::composite::{CompositeAgentHandler, TelemetryHandler}; pub use guard_core_rs::events::SecurityEventBus; pub use guard_core_rs::metrics::MetricsCollector; pub use guard_core_rs::responses::{BlockPayload, CustomErrorResponses, OnBlockHook}; pub use guard_core_rs::tower::{ ObservabilityConfig, RateLimitStage, RateLimitStageConfig, RequestObservation, StageResponse, }; +pub use guard_core_rs::wiring::{ + ClientPolicy, CloudRefresh, Distributed, DynamicRules, DynamicRulesHandle, Wiring, WiringError, + WiringOptions, +}; /// The multi-surface scan entry point stored in the engine. /// @@ -302,6 +363,13 @@ pub enum GuardConfigError { UserAgent(guard_core_engine::user_agent::UserAgentConfigError), /// An invalid auto-ban knob group. Ban(IpBanConfigError), + /// A `block_cloud_providers` selector the engine does not know. + Cloud(guard_core_engine::cloud_provider::CloudConfigError), + /// `enable_redis` without a `redis_url`, or the backend refused it. + Redis(String), + /// Country rules without a resolver, or the `GeoIP` database could not + /// be set up. + Geo(String), } impl std::fmt::Display for GuardConfigError { @@ -311,6 +379,9 @@ impl std::fmt::Display for GuardConfigError { Self::RateLimit(error) => write!(f, "rate limit: {error}"), Self::UserAgent(error) => write!(f, "blocked user agent: {error}"), Self::Ban(error) => write!(f, "ip ban: {error}"), + Self::Cloud(error) => write!(f, "block_cloud_providers: {error}"), + Self::Redis(message) => write!(f, "redis: {message}"), + Self::Geo(message) => write!(f, "geo: {message}"), } } } @@ -322,6 +393,8 @@ impl std::error::Error for GuardConfigError { Self::RateLimit(error) => Some(error), Self::UserAgent(error) => Some(error), Self::Ban(error) => Some(error), + Self::Cloud(error) => Some(error), + Self::Redis(_) | Self::Geo(_) => None, } } } @@ -350,15 +423,17 @@ impl From for GuardConfigError { } } -/// The engine log level mapped onto the logging facade's enum (the -/// reference literals are the same strings). -pub(crate) fn map_log_level(level: LogLevel) -> guard_core_rs::logging::LogLevel { - match level { - LogLevel::Info => guard_core_rs::logging::LogLevel::Info, - LogLevel::Debug => guard_core_rs::logging::LogLevel::Debug, - LogLevel::Warning => guard_core_rs::logging::LogLevel::Warning, - LogLevel::Error => guard_core_rs::logging::LogLevel::Error, - LogLevel::Critical => guard_core_rs::logging::LogLevel::Critical, +impl From for GuardConfigError { + fn from(error: WiringError) -> Self { + match error { + WiringError::IpGate(error) => Self::IpGate(error), + WiringError::RateLimit(error) => Self::RateLimit(error), + WiringError::UserAgent(error) => Self::UserAgent(error), + WiringError::Ban(error) => Self::Ban(error), + WiringError::Cloud(error) => Self::Cloud(error), + WiringError::Redis(message) => Self::Redis(message), + WiringError::Geo(message) => Self::Geo(message), + } } } @@ -419,30 +494,6 @@ mod tests { assert!(std::error::Error::source(&ban).is_some()); } - #[test] - fn map_log_level_covers_every_reference_level() { - assert!(matches!( - map_log_level(LogLevel::Info), - guard_core_rs::logging::LogLevel::Info - )); - assert!(matches!( - map_log_level(LogLevel::Debug), - guard_core_rs::logging::LogLevel::Debug - )); - assert!(matches!( - map_log_level(LogLevel::Warning), - guard_core_rs::logging::LogLevel::Warning - )); - assert!(matches!( - map_log_level(LogLevel::Error), - guard_core_rs::logging::LogLevel::Error - )); - assert!(matches!( - map_log_level(LogLevel::Critical), - guard_core_rs::logging::LogLevel::Critical - )); - } - #[test] fn from_security_config_exclude_paths_round_trip_through_the_accessor() { let config = SecurityConfig { @@ -460,6 +511,45 @@ mod tests { assert_eq!(fairing.exclude_paths(), ["/docs", "/static"]); } + #[test] + fn every_wiring_error_maps_onto_its_config_error() { + use guard_core_engine::cloud_provider::parse_cloud_selectors; + let user_agent = guard_core_engine::user_agent::UserAgentFilter::new(["(a+)+$"]) + .err() + .map(WiringError::UserAgent); + let ip_gate = IpGateConfig::new(["not-an-ip"], [] as [&str; 0], [] as [&str; 0]) + .err() + .map(WiringError::IpGate); + let rate = RateLimiter::new(RateLimitConfig { + rate_limit: 0, + ..RateLimitConfig::default() + }) + .err() + .map(WiringError::RateLimit); + let ban = Some(WiringError::Ban(IpBanConfigError::NonPositive { + field: "auto_ban_threshold", + })); + let cloud = parse_cloud_selectors(["NotACloud"]) + .err() + .map(WiringError::Cloud); + let converted: Vec = [user_agent, ip_gate, rate, ban, cloud] + .into_iter() + .flatten() + .chain([ + WiringError::Redis("r".to_owned()), + WiringError::Geo("g".to_owned()), + ]) + .map(GuardConfigError::from) + .collect(); + assert!(matches!(converted[0], GuardConfigError::UserAgent(_))); + assert!(matches!(converted[1], GuardConfigError::IpGate(_))); + assert!(matches!(converted[2], GuardConfigError::RateLimit(_))); + assert!(matches!(converted[3], GuardConfigError::Ban(_))); + assert!(matches!(converted[4], GuardConfigError::Cloud(_))); + assert!(matches!(converted[5], GuardConfigError::Redis(_))); + assert!(matches!(converted[6], GuardConfigError::Geo(_))); + } + #[test] fn ban_state_debug_renders_the_manager_and_config() { let entries: Vec<(String, ThreatBanEntry)> = Vec::new(); diff --git a/src/scan.rs b/src/scan.rs index 14e4b18..e3f3fb0 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -327,6 +327,59 @@ pub(crate) fn record_enforced(request: &Request<'_>, verdict: Verdict) { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct GateDecision(pub(crate) Option); +/// The resolved client identity of this request (trusted-proxy aware), +/// stashed by the fairing before any check runs; every stage, guard and the +/// response pass read the same identity. +pub(crate) struct ResolvedClient(pub(crate) Option); + +/// Stash the resolved client for the stages, the guards and the response +/// pass. +pub(crate) fn stash_client(request: &Request<'_>, ip: Option) { + request.local_cache(|| ResolvedClient(ip)); +} + +/// The resolved client. Without the fairing's resolution (a guard used +/// alone) it is the connecting peer: Rocket's own `client_ip()` would honor +/// the unauthenticated `X-Real-IP` header, so it is never consulted. +pub(crate) fn client_ip(request: &Request<'_>) -> Option { + request + .local_cache(|| ResolvedClient(request.remote().map(|peer| peer.ip()))) + .0 +} + +/// Which response-side pass the request gets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Pass { + /// Return rules, security headers, CORS, the modifier. + Full, + /// An `exclude_paths` request: no return rules, the rest as `Full`. + Scoped, + /// An all-checks-bypass route: only the modifier. + ModifierOnly, +} + +/// Request-local slot for the response pass kind. +pub(crate) struct PassSlot(pub(crate) std::sync::Mutex); + +/// Record the response pass kind (the last write wins: an all-checks-bypass +/// route overrides the scoped marker). +pub(crate) fn stash_pass(request: &Request<'_>, pass: Pass) { + *request + .local_cache(|| PassSlot(std::sync::Mutex::new(Pass::Full))) + .0 + .lock() + .expect("pass slot") = pass; +} + +/// The request's response pass kind. +pub(crate) fn pass(request: &Request<'_>) -> Pass { + *request + .local_cache(|| PassSlot(std::sync::Mutex::new(Pass::Full))) + .0 + .lock() + .expect("pass slot") +} + /// The gate's skip state for this request, if the gate ran and passed. pub(crate) fn gate_decision(request: &Request<'_>) -> Option { request.local_cache(|| GateDecision(None)).0 @@ -420,7 +473,7 @@ pub(crate) fn stage_decision( }); let tiers = route_rate_limits(request); let decision = stage.decide_tiers_observed( - request.client_ip(), + client_ip(request), Some(request.uri().path().as_str()), tiers.as_ref(), gate_decision(request), @@ -442,7 +495,7 @@ pub(crate) fn bans_decision(engine: &GuardEngine, request: &Request<'_>) -> Opti } let stage = engine.stage.as_ref()?; let decision = - stage.decide_bans_observed(request.client_ip(), Some(&request_observation(request)))?; + stage.decide_bans_observed(client_ip(request), Some(&request_observation(request)))?; let (verdict, custom) = verdict_from_stage(&decision); record_block_body(request, custom); Some(verdict) @@ -487,8 +540,7 @@ pub(crate) fn metadata_threat_verdict( ); record_block_body(request, Some(body)); if let Some(observability) = &engine.observability { - let ip = request - .client_ip() + let ip = client_ip(request) .map(|ip| ip.to_string()) .unwrap_or_default(); let observation = request_observation(request); @@ -529,7 +581,7 @@ pub(crate) fn enforce_body_finding( let gate = gate_decision(request); let whitelisted = gate.is_some_and(|gate| gate.is_whitelisted); let crossed = stage.feed_finding( - request.client_ip(), + client_ip(request), whitelisted, Some(&finding), Some(&request_observation(request)), diff --git a/src/websocket.rs b/src/websocket.rs index b3aae94..a845e70 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -99,12 +99,14 @@ //! } //! ``` //! -//! The client IP resolves through Rocket's `Request::client_ip()` (the -//! connection peer, or whatever the host resolves it to). `scan_frame`'s -//! `client_ip` argument takes the same identity - capture it from the -//! request (a `Result` handler can read -//! `rocket::Request::client_ip()` through a request guard of its own, or -//! pass `None` to skip the stateful arms). +//! The client IP resolves like the HTTP fairing's: the connecting peer, with +//! `X-Forwarded-For` honored only through the trusted proxies of the +//! [`ClientPolicy`] installed with +//! [`WebSocketGuardConfig::with_client_policy`] (Rocket's own +//! `Request::client_ip()` trusts the `X-Real-IP` header from anyone, so it is +//! never consulted). `scan_frame`'s `client_ip` argument takes the same +//! identity - capture it from the handshake, or pass `None` to skip the +//! stateful arms. use guard_core_engine::detect::DetectConfig; use guard_core_engine::detection_exclusions::{ @@ -115,6 +117,7 @@ use guard_core_engine::geo::{CountryGate, GeoIpHandler, check_countries}; use guard_core_engine::ip_ban::{IpBanConfig, IpBanManager, ViolationCounters}; use guard_core_engine::ip_gate::{IpGateConfig, IpGateVerdict}; use guard_core_engine::rate_limit::RateLimiter; +use guard_core_rs::wiring::ClientPolicy; use rocket::Request; use rocket::http::{HeaderMap, Status}; use rocket::request::{FromRequest, Outcome}; @@ -252,7 +255,7 @@ pub struct WebSocketGuardConfig { rate_limiter: Option, country_rules: Option<(CountryGate, Arc)>, violation_feed: Option, - fail_secure: bool, + client: ClientPolicy, exclusions: ResolvedExclusions, } @@ -267,7 +270,7 @@ impl core::fmt::Debug for WebSocketGuardConfig { .field("ban_manager", &self.ban_manager) .field("rate_limiter", &self.rate_limiter) .field("violation_feed", &self.violation_feed) - .field("fail_secure", &self.fail_secure) + .field("client", &self.client) .field("exclusions", &self.exclusions) .finish_non_exhaustive() } @@ -285,7 +288,11 @@ impl WebSocketGuardConfig { rate_limiter: None, country_rules: None, violation_feed: None, - fail_secure: false, + client: ClientPolicy { + trusted_proxies: Vec::new(), + trusted_proxy_depth: 1, + fail_secure: false, + }, exclusions: resolve_exclusions(None, None), } } @@ -337,7 +344,17 @@ impl WebSocketGuardConfig { /// determined (the reference `fail_secure` arm). #[must_use] pub const fn with_fail_secure(mut self, fail_secure: bool) -> Self { - self.fail_secure = fail_secure; + self.client.fail_secure = fail_secure; + self + } + + /// Install the client resolution policy (trusted proxies, proxy depth, + /// `fail_secure`), the same policy the HTTP fairing resolves the + /// upgrade request's client with: behind a trusted proxy the forwarded + /// client, not the proxy, is banned, gated and rate limited. + #[must_use] + pub fn with_client_policy(mut self, client: ClientPolicy) -> Self { + self.client = client; self } @@ -374,7 +391,7 @@ pub fn run_websocket_checks( query_params: &[(String, String)], headers: &[(String, String)], ) -> Result<(), WebSocketCloseReason> { - if client_ip.is_none() && config.fail_secure { + if client_ip.is_none() && config.client.fail_secure { return Err(WS_CLOSE_CLIENT_ADDRESS_UNKNOWN); } @@ -591,7 +608,16 @@ impl WebSocketGuard { /// [`GeoIpHandler`], a foreign handle) maps to /// [`WS_CLOSE_SECURITY_CHECK_FAILED`]. pub fn upgrade_verdict(&self, request: &Request<'_>) -> Result<(), WebSocketCloseReason> { - let client_ip = request.client_ip(); + let forwarded: Vec<&str> = request.headers().get("x-forwarded-for").collect(); + let forwarded = (!forwarded.is_empty()).then(|| forwarded.join(", ")); + let client_ip = self + .config + .client + .resolve( + request.remote().map(|remote| remote.ip()), + forwarded.as_deref(), + ) + .ip; let url_path = request.uri().path().as_str().to_owned(); let query_params = parse_query_pairs(request.uri().query().map_or("", |query| query.as_str())); diff --git a/tests/parity.rs b/tests/parity.rs new file mode 100644 index 0000000..33312df --- /dev/null +++ b/tests/parity.rs @@ -0,0 +1,919 @@ +//! Reference-parity behavior of the public API: the scoped `exclude_paths` +//! semantics, client resolution, the `from_security_config` consumption, and +//! the response pass. Every test drives a real Rocket application through the +//! public surface only. + +use guard_core_engine::behavior::BehaviorRule; +use guard_core_engine::geo::GeoIpHandler; +use guard_core_engine::route_config::RouteConfig; +use guard_core_engine::security_config::SecurityConfig; +use rocket::get; +use rocket::http::{Header, Status}; +use rocket::local::asynchronous::{Client, LocalRequest, LocalResponse}; +use rocket::routes; +use rocket_guard_rs::{BlockGuard, GuardFairing, IpBanConfig, IpBanManager, default_config}; +use std::collections::BTreeMap; +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +// Rocket's own Shield fairing adds `X-Content-Type-Options`, so the guard's +// security headers are asserted through `Referrer-Policy`, which only the +// guard's response pass renders. +const ATTACK_QUERY: &str = "q=%3Cscript%3Ealert(1)%3C/script%3E"; + +struct Out { + status: Status, + body: String, + headers: Vec<(String, String)>, +} + +async fn out(response: LocalResponse<'_>) -> Out { + let status = response.status(); + let headers = response + .headers() + .iter() + .map(|header| { + ( + header.name().as_str().to_lowercase(), + header.value().to_owned(), + ) + }) + .collect(); + let body = response.into_string().await.unwrap_or_default(); + Out { + status, + body, + headers, + } +} + +fn header(out: &Out, name: &str) -> Option { + out.headers + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.clone()) +} + +#[get("/<_path..>")] +#[allow(clippy::needless_pass_by_value)] // route parameters are owned by design +fn catch_all(_path: PathBuf, _guard: BlockGuard) -> &'static str { + "ok" +} + +#[get("/<_path..>", rank = 1)] +#[allow(clippy::needless_pass_by_value)] // route parameters are owned by design +fn secret_body(_path: PathBuf, _guard: BlockGuard) -> &'static str { + "the secret token is 42" +} + +async fn serve(fairing: GuardFairing) -> Client { + Client::untracked( + rocket::build() + .attach(fairing) + .mount("/", routes![catch_all]), + ) + .await + .expect("valid rocket") +} + +async fn serve_secret(fairing: GuardFairing) -> Client { + Client::untracked( + rocket::build() + .attach(fairing) + .mount("/", routes![secret_body]), + ) + .await + .expect("valid rocket") +} + +fn peer(ip: &str) -> SocketAddr { + SocketAddr::new(ip.parse::().expect("test ip"), 45_000) +} + +fn get_from<'c>(client: &'c Client, ip: &str, uri: &str) -> LocalRequest<'c> { + client.get(uri.to_owned()).remote(peer(ip)) +} + +fn from_config(config: &SecurityConfig) -> GuardFairing { + GuardFairing::from_security_config(config).expect("valid config") +} + +struct FixedCountry(&'static str); + +impl GeoIpHandler for FixedCountry { + fn get_country(&self, _ip: IpAddr) -> Option { + Some(self.0.to_owned()) + } +} + +fn return_rule(pattern: &str, threshold: u32) -> BehaviorRule { + BehaviorRule { + rule_type: "return_pattern".to_owned(), + threshold, + window: 60, + pattern: pattern.to_owned(), + action: "ban".to_owned(), + ban_duration: Some(60), + correlate_with_detection: false, + } +} + +// --- exclude_paths: the reference matcher, scoped not bypassed --- + +#[tokio::test] +async fn the_default_static_exclusion_covers_nested_assets_on_a_segment_boundary() { + let client = serve(from_config(&SecurityConfig::default())).await; + let nested = out(get_from( + &client, + "203.0.113.9", + &format!("/static/app.js?{ATTACK_QUERY}"), + ) + .dispatch() + .await) + .await; + assert_eq!(nested.status, Status::Ok, "/static covers /static/app.js"); + let sibling = out(get_from( + &client, + "203.0.113.9", + &format!("/staticx/app.js?{ATTACK_QUERY}"), + ) + .dispatch() + .await) + .await; + assert_eq!( + sibling.status, + Status::BadRequest, + "/staticx is not under /static" + ); +} + +#[tokio::test] +async fn an_encoded_traversal_out_of_an_excluded_prefix_is_not_excluded() { + let client = serve(from_config(&SecurityConfig::default())).await; + let escaped = out(get_from( + &client, + "203.0.113.9", + &format!("/static/%2e%2e/private?{ATTACK_QUERY}"), + ) + .dispatch() + .await) + .await; + assert_eq!(escaped.status, Status::BadRequest); +} + +#[tokio::test] +async fn an_excluded_path_still_answers_a_live_ban() { + let manager = IpBanManager::new(); + manager + .ban_ip("203.0.113.9".parse().expect("ip"), 600, "test ban") + .expect("ban"); + let fairing = GuardFairing::with_defaults() + .with_exclude_paths(vec!["/docs".to_owned()]) + .with_ip_banning( + manager, + IpBanConfig::new( + true, + 5, + 3600, + Vec::<(String, rocket_guard_rs::ThreatBanEntry)>::new(), + ) + .expect("valid config"), + ); + let client = serve(fairing).await; + let banned = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(banned.status, Status::Forbidden); + assert_eq!(banned.body, rocket_guard_rs::BANNED_MESSAGE); + let other = out(get_from(&client, "203.0.113.10", "/docs").dispatch().await).await; + assert_eq!(other.status, Status::Ok); +} + +#[tokio::test] +async fn an_excluded_path_still_enforces_the_global_ip_lists() { + let config = SecurityConfig { + blacklist: vec!["203.0.113.9".to_owned()], + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let blocked = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(blocked.status, Status::Forbidden); + assert_eq!(blocked.body, rocket_guard_rs::FORBIDDEN_MESSAGE); + let allowed = out(get_from(&client, "203.0.113.10", "/docs").dispatch().await).await; + assert_eq!(allowed.status, Status::Ok); +} + +#[tokio::test] +async fn an_excluded_path_still_hits_the_rate_limit() { + let config = SecurityConfig { + rate_limit: 2, + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + for _ in 0..2 { + let passed = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(passed.status, Status::Ok); + } + let limited = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(limited.status, Status::TooManyRequests); + assert_eq!(header(&limited, "retry-after").as_deref(), Some("60")); +} + +#[tokio::test] +async fn an_excluded_path_still_applies_the_global_country_rules() { + let config = SecurityConfig { + blocked_countries: ["US".to_owned()].into_iter().collect(), + geo_ip_handler: Some(Arc::new(FixedCountry("US"))), + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let blocked = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(blocked.status, Status::Forbidden); +} + +#[tokio::test] +async fn an_excluded_path_ignores_the_route_ip_restrictions() { + let fairing = GuardFairing::with_defaults() + .with_exclude_paths(vec!["/docs".to_owned()]) + .with_route_configs(Arc::new(|_method, _path| { + Some(Arc::new(RouteConfig { + ip_blacklist: Some(vec!["203.0.113.9".to_owned()]), + ..RouteConfig::default() + })) + })); + let client = serve(fairing).await; + let excluded = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(excluded.status, Status::Ok, "route lists do not apply"); + let guarded = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(guarded.status, Status::Forbidden); +} + +#[tokio::test] +async fn an_excluded_path_skips_https_enforcement() { + let config = SecurityConfig { + enforce_https: true, + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let excluded = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(excluded.status, Status::Ok); + let other = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(other.status, Status::MovedPermanently); +} + +#[tokio::test] +async fn an_excluded_response_still_gets_the_security_headers() { + let client = serve(from_config(&SecurityConfig::default())).await; + let excluded = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(excluded.status, Status::Ok); + assert_eq!( + header(&excluded, "referrer-policy").as_deref(), + Some("strict-origin-when-cross-origin") + ); +} + +fn recording_collector() -> (rocket_guard_rs::MetricsCollector, Arc>>) { + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + let collector = guard_core_rs::metrics::MetricsCollector::new(true).with_handler(Arc::new( + move |metric: &guard_core_rs::metrics::SecurityMetric| { + sink.lock().expect("sink").push(metric.metric_type.clone()); + }, + )); + (collector, seen) +} + +#[tokio::test] +async fn an_excluded_response_emits_the_request_metrics() { + let (collector, seen) = recording_collector(); + let client = serve(from_config(&SecurityConfig::default()).with_metrics(collector)).await; + let excluded = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(excluded.status, Status::Ok); + let recorded = seen.lock().expect("sink").clone(); + assert!( + recorded.iter().any(|kind| kind == "request_count"), + "the excluded request is counted: {recorded:?}" + ); +} + +#[tokio::test] +async fn an_excluded_request_does_not_feed_the_return_rules() { + // A rule trips once its count exceeds the threshold (2): the third + // tracked response trips it and the fourth request is banned. + let config = SecurityConfig { + global_behavior_rules: vec![return_rule("status:200", 2)], + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + // The excluded request is withheld from the behavioral tracker: were it + // counted, the rule would trip one request earlier. + let excluded = out(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(excluded.status, Status::Ok); + for _ in 0..3 { + let tracked = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!( + tracked.status, + Status::Ok, + "the excluded hit did not count toward the rule" + ); + } + let banned = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(banned.status, Status::Forbidden); +} + +// --- client resolution --- + +#[tokio::test] +async fn a_missing_client_address_is_rejected_under_fail_secure() { + let client = serve(from_config(&SecurityConfig::default())).await; + let rejected = out(client.get("/api").dispatch().await).await; + assert_eq!(rejected.status, Status::Forbidden); + assert_eq!(rejected.body, "Client address could not be determined"); + assert_eq!( + header(&rejected, "referrer-policy").as_deref(), + Some("strict-origin-when-cross-origin"), + "the rejection rides the response pass" + ); +} + +#[tokio::test] +async fn the_unresolved_client_rejection_fires_the_block_hook() { + let seen: Arc>> = + Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + let config = SecurityConfig { + on_block: Some(Arc::new(move |payload| { + sink.lock().expect("sink").push(payload.clone()); + })), + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let rejected = out(client.get("/api").dispatch().await).await; + assert_eq!(rejected.status, Status::Forbidden); + let payloads = seen.lock().expect("sink").clone(); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].check_name, "client_address_unresolved"); + assert_eq!(payloads[0].reason, "Client address could not be determined"); + assert_eq!(payloads[0].status_code, Some(403)); +} + +#[tokio::test] +async fn a_missing_client_address_continues_unattributed_without_fail_secure() { + let config = SecurityConfig { + fail_secure: false, + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let passed = out(client.get("/api").dispatch().await).await; + assert_eq!(passed.status, Status::Ok); +} + +#[tokio::test] +async fn a_bare_fairing_without_a_peer_keeps_passing() { + let client = serve(GuardFairing::new(default_config())).await; + let passed = out(client.get("/api").dispatch().await).await; + assert_eq!(passed.status, Status::Ok); +} + +#[tokio::test] +async fn an_excluded_path_without_a_peer_continues_scoped() { + let client = serve(from_config(&SecurityConfig::default())).await; + let excluded = out(client.get("/static/app.js").dispatch().await).await; + assert_eq!(excluded.status, Status::Ok); + let rejected = out(client.get("/api").dispatch().await).await; + assert_eq!(rejected.status, Status::Forbidden); +} + +fn behind_a_proxy() -> SecurityConfig { + SecurityConfig { + trusted_proxies: vec!["10.0.0.0/8".to_owned()], + blacklist: vec!["203.0.113.7".to_owned()], + ..SecurityConfig::default() + } +} + +#[tokio::test] +async fn a_trusted_proxy_forwards_the_real_client_identity() { + let client = serve(from_config(&behind_a_proxy())).await; + let blocked = out(get_from(&client, "10.0.0.1", "/api") + .header(Header::new("x-forwarded-for", "203.0.113.7")) + .dispatch() + .await) + .await; + assert_eq!( + blocked.status, + Status::Forbidden, + "the forwarded client is the identity the gate sees" + ); + let allowed = out(get_from(&client, "10.0.0.1", "/api") + .header(Header::new("x-forwarded-for", "198.51.100.4")) + .dispatch() + .await) + .await; + assert_eq!(allowed.status, Status::Ok); +} + +#[tokio::test] +async fn an_untrusted_peer_cannot_spoof_the_forwarded_identity() { + let config = SecurityConfig { + blacklist: vec!["203.0.113.9".to_owned()], + ..behind_a_proxy() + }; + let client = serve(from_config(&config)).await; + // The peer is not a trusted proxy: its own address is the identity, + // whatever X-Forwarded-For claims. + let spoofed = out(get_from(&client, "203.0.113.9", "/api") + .header(Header::new("x-forwarded-for", "198.51.100.4")) + .dispatch() + .await) + .await; + assert_eq!(spoofed.status, Status::Forbidden); + let innocent = out(get_from(&client, "198.51.100.99", "/api") + .header(Header::new("x-forwarded-for", "203.0.113.7")) + .dispatch() + .await) + .await; + assert_eq!(innocent.status, Status::Ok); +} + +#[tokio::test] +async fn a_spoofed_x_real_ip_header_does_not_change_the_identity() { + // Rocket's own `client_ip()` honors `X-Real-IP` unconditionally; the + // guard resolves from the connecting peer and the trusted proxies only. + let config = SecurityConfig { + blacklist: vec!["203.0.113.9".to_owned()], + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let spoofed = out(get_from(&client, "203.0.113.9", "/api") + .header(Header::new("x-real-ip", "198.51.100.4")) + .dispatch() + .await) + .await; + assert_eq!(spoofed.status, Status::Forbidden); +} + +#[tokio::test] +async fn the_rate_limit_keys_on_the_forwarded_client_not_the_proxy() { + let config = SecurityConfig { + trusted_proxies: vec!["10.0.0.0/8".to_owned()], + rate_limit: 1, + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let first = out(get_from(&client, "10.0.0.1", "/api") + .header(Header::new("x-forwarded-for", "198.51.100.4")) + .dispatch() + .await) + .await; + assert_eq!(first.status, Status::Ok); + let other_client = out(get_from(&client, "10.0.0.1", "/api") + .header(Header::new("x-forwarded-for", "198.51.100.5")) + .dispatch() + .await) + .await; + assert_eq!( + other_client.status, + Status::Ok, + "a different forwarded client has its own window" + ); + let same_client = out(get_from(&client, "10.0.0.1", "/api") + .header(Header::new("x-forwarded-for", "198.51.100.4")) + .dispatch() + .await) + .await; + assert_eq!(same_client.status, Status::TooManyRequests); +} + +#[tokio::test] +async fn an_ipv4_mapped_peer_matches_the_v4_list_entry() { + let config = SecurityConfig { + blacklist: vec!["203.0.113.9".to_owned()], + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let blocked = out(client + .get("/api") + .remote("[::ffff:203.0.113.9]:45000".parse().expect("addr")) + .dispatch() + .await) + .await; + assert_eq!(blocked.status, Status::Forbidden); +} + +#[tokio::test] +async fn a_forged_forwarded_proto_from_an_untrusted_peer_is_still_redirected() { + let config = SecurityConfig { + enforce_https: true, + trust_x_forwarded_proto: true, + trusted_proxies: vec!["10.0.0.0/8".to_owned()], + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let forged = out(get_from(&client, "198.51.100.4", "/api") + .header(Header::new("x-forwarded-proto", "https")) + .dispatch() + .await) + .await; + assert_eq!(forged.status, Status::MovedPermanently); + let trusted = out(get_from(&client, "10.0.0.1", "/api") + .header(Header::new("x-forwarded-proto", "https")) + .dispatch() + .await) + .await; + assert_eq!(trusted.status, Status::Ok); +} + +#[tokio::test] +async fn a_whitelisted_ip_is_exempt_from_the_user_agent_block() { + let config = SecurityConfig { + whitelist: Some(vec!["203.0.113.9".to_owned()]), + blocked_user_agents: vec!["badbot".to_owned()], + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let whitelisted = out(get_from(&client, "203.0.113.9", "/api") + .header(Header::new("user-agent", "badbot/1.0")) + .dispatch() + .await) + .await; + assert_eq!(whitelisted.status, Status::Ok); +} + +// --- from_security_config: every stage reachable --- + +#[tokio::test] +async fn the_country_rules_reach_the_geo_stage() { + let config = SecurityConfig { + blocked_countries: ["US".to_owned()].into_iter().collect(), + geo_ip_handler: Some(Arc::new(FixedCountry("US"))), + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let blocked = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(blocked.status, Status::Forbidden); + let other = SecurityConfig { + blocked_countries: ["US".to_owned()].into_iter().collect(), + geo_ip_handler: Some(Arc::new(FixedCountry("DE"))), + ..SecurityConfig::default() + }; + let client = serve(from_config(&other)).await; + let allowed = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(allowed.status, Status::Ok); +} + +#[tokio::test] +async fn the_endpoint_rate_limits_reach_the_limiter() { + let mut endpoint_rate_limits = BTreeMap::new(); + endpoint_rate_limits.insert("/login".to_owned(), (1_u32, 60_u64)); + let config = SecurityConfig { + endpoint_rate_limits, + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let first = out(get_from(&client, "203.0.113.9", "/login").dispatch().await).await; + assert_eq!(first.status, Status::Ok); + let second = out(get_from(&client, "203.0.113.9", "/login").dispatch().await).await; + assert_eq!(second.status, Status::TooManyRequests); + let elsewhere = out(get_from(&client, "203.0.113.9", "/other").dispatch().await).await; + assert_eq!(elsewhere.status, Status::Ok); +} + +#[tokio::test] +async fn the_rate_limit_auto_ban_reaches_the_limiter() { + let config = SecurityConfig { + rate_limit: 1, + enable_rate_limit_auto_ban: true, + auto_ban_threshold: 1, + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let first = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(first.status, Status::Ok); + let crossing = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(crossing.status, Status::TooManyRequests); + let banned = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(banned.status, Status::Forbidden); + assert_eq!(banned.body, rocket_guard_rs::BANNED_MESSAGE); +} + +#[tokio::test] +async fn the_custom_request_check_reaches_the_pipeline() { + let config = SecurityConfig { + custom_request_check: Some(Arc::new(|ctx| { + (ctx.path == "/teapot").then_some(guard_core_engine::custom_checks::CustomResponse { + status: Some(418), + body: None, + }) + })), + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let blocked = out(get_from(&client, "203.0.113.9", "/teapot").dispatch().await).await; + assert_eq!(blocked.status.code, 418); + let passed = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(passed.status, Status::Ok); +} + +#[tokio::test] +async fn the_global_auth_verifier_backs_the_route_authentication() { + let config = SecurityConfig { + auth_verifier: Some(Arc::new(|credential| credential == "good")), + ..SecurityConfig::default() + }; + let fairing = from_config(&config).with_route_configs(Arc::new(|_method, _path| { + Some(Arc::new(RouteConfig { + auth_required: Some("bearer".to_owned()), + ..RouteConfig::default() + })) + })); + let client = serve(fairing).await; + let good = out(get_from(&client, "203.0.113.9", "/api") + .header(Header::new("authorization", "Bearer good")) + .dispatch() + .await) + .await; + assert_eq!(good.status, Status::Ok); + let bad = out(get_from(&client, "203.0.113.9", "/api") + .header(Header::new("authorization", "Bearer nope")) + .dispatch() + .await) + .await; + assert_eq!(bad.status, Status::Unauthorized); +} + +#[tokio::test] +async fn the_cors_expose_headers_reach_the_response() { + let config = SecurityConfig { + enable_cors: true, + cors_allow_origins: vec!["https://app.example.com".to_owned()], + cors_expose_headers: vec!["X-Trace".to_owned()], + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let answered = out(get_from(&client, "203.0.113.9", "/api") + .header(Header::new("origin", "https://app.example.com")) + .dispatch() + .await) + .await; + assert_eq!(answered.status, Status::Ok); + assert_eq!( + header(&answered, "access-control-expose-headers").as_deref(), + Some("X-Trace") + ); + assert_eq!(header(&answered, "vary").as_deref(), Some("Origin")); +} + +#[tokio::test] +async fn the_config_modifier_runs_once_and_the_config_error_hook_reports() { + let calls = Arc::new(Mutex::new(0_u32)); + let counter = Arc::clone(&calls); + let config = SecurityConfig { + custom_response_modifier: Some(Arc::new(move |bits| { + *counter.lock().expect("counter") += 1; + bits.headers + .insert("x-modified".to_owned(), "yes".to_owned()); + })), + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let answered = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(header(&answered, "x-modified").as_deref(), Some("yes")); + assert_eq!(*calls.lock().expect("counter"), 1, "applied exactly once"); + + let reported: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&reported); + let panicking = SecurityConfig { + custom_response_modifier: Some(Arc::new(|_bits| panic!("modifier exploded"))), + on_error: Some(Arc::new(move |stage, _error, _context| { + sink.lock().expect("sink").push(stage.to_owned()); + })), + ..SecurityConfig::default() + }; + let client = serve(from_config(&panicking)).await; + let survived = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(survived.status, Status::Ok); + assert_eq!( + reported.lock().expect("sink").as_slice(), + ["custom_response_modifier"] + ); +} + +#[tokio::test] +async fn a_return_rule_ban_is_enforced_by_the_ban_stage() { + let config = SecurityConfig { + global_behavior_rules: vec![return_rule("status:200", 0)], + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + let first = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(first.status, Status::Ok); + let banned = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(banned.status, Status::Forbidden); +} + +// --- the response pass --- + +#[tokio::test] +async fn the_modifier_runs_without_a_response_processor() { + let fairing = + GuardFairing::new(default_config()).with_custom_response_modifier(Arc::new(|bits| { + bits.headers + .insert("x-modified".to_owned(), "yes".to_owned()); + })); + let client = serve(fairing).await; + let answered = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(answered.status, Status::Ok); + assert_eq!(header(&answered, "x-modified").as_deref(), Some("yes")); +} + +#[tokio::test] +async fn a_return_rule_matches_the_response_body_and_the_body_is_re_emitted() { + let config = SecurityConfig { + global_behavior_rules: vec![return_rule("secret", 0)], + behavior_scan_response_body: true, + ..SecurityConfig::default() + }; + let client = serve_secret(from_config(&config)).await; + let first = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(first.status, Status::Ok); + assert_eq!( + first.body, "the secret token is 42", + "the full body survives" + ); + let banned = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(banned.status, Status::Forbidden); +} + +#[tokio::test] +async fn a_body_rule_does_not_match_a_clean_body() { + let config = SecurityConfig { + global_behavior_rules: vec![return_rule("secret", 0)], + behavior_scan_response_body: true, + ..SecurityConfig::default() + }; + let client = serve(from_config(&config)).await; + for _ in 0..3 { + let passed = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(passed.status, Status::Ok); + assert_eq!(passed.body, "ok"); + } +} + +// --- all-checks-bypass routes --- + +fn all_bypass_routes() -> rocket_guard_rs::RouteConfigResolver { + Arc::new(|_method, path| { + (path == "/open").then(|| { + Arc::new(RouteConfig { + bypassed_checks: ["all".to_owned()].into_iter().collect(), + ..RouteConfig::default() + }) + }) + }) +} + +fn recording_bus() -> ( + Arc, + Arc>>, +) { + let seen: Arc>> = + Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + let bus = guard_core_rs::events::SecurityEventBus::new(true).on_event(Arc::new( + move |event: &guard_core_rs::events::SecurityEvent| { + sink.lock().expect("sink").push(event.clone()); + }, + )); + (Arc::new(bus), seen) +} + +#[tokio::test] +async fn an_all_bypass_route_skips_the_pipeline_emits_the_event_and_runs_only_the_modifier() { + let (bus, seen) = recording_bus(); + let (collector, metrics_seen) = recording_collector(); + let config = SecurityConfig { + custom_response_modifier: Some(Arc::new(|bits| { + bits.headers + .insert("x-modified".to_owned(), "yes".to_owned()); + })), + ..SecurityConfig::default() + }; + let fairing = from_config(&config) + .with_route_configs(all_bypass_routes()) + .with_event_bus(bus) + .with_metrics(collector); + let client = serve(fairing).await; + let bypassed = out( + get_from(&client, "203.0.113.9", &format!("/open?{ATTACK_QUERY}")) + .dispatch() + .await, + ) + .await; + assert_eq!(bypassed.status, Status::Ok, "the attack is not scanned"); + assert_eq!(header(&bypassed, "x-modified").as_deref(), Some("yes")); + assert_eq!( + header(&bypassed, "referrer-policy"), + None, + "no security headers on a fully bypassed route" + ); + assert!( + metrics_seen.lock().expect("sink").is_empty(), + "no metrics on a fully bypassed route" + ); + let events = seen.lock().expect("sink").clone(); + let bypass = events + .iter() + .find(|event| event.event_type == "security_bypass") + .expect("the security_bypass event fired"); + assert_eq!(bypass.action_taken, "all_checks_bypassed"); + assert_eq!( + bypass.reason, + "Route configured to bypass all security checks" + ); + let checked = out( + get_from(&client, "203.0.113.9", &format!("/guarded?{ATTACK_QUERY}")) + .dispatch() + .await, + ) + .await; + assert_eq!(checked.status, Status::BadRequest); +} + +#[tokio::test] +async fn an_all_bypass_route_continues_through_the_pipeline_in_passive_mode() { + let (bus, seen) = recording_bus(); + let config = SecurityConfig { + passive_mode: true, + ..SecurityConfig::default() + }; + let fairing = from_config(&config) + .with_route_configs(all_bypass_routes()) + .with_event_bus(bus); + let client = serve(fairing).await; + let passive = out(get_from(&client, "203.0.113.9", "/open").dispatch().await).await; + assert_eq!(passive.status, Status::Ok); + assert_eq!( + header(&passive, "referrer-policy").as_deref(), + Some("strict-origin-when-cross-origin"), + "passive mode keeps the response pass" + ); + assert!( + seen.lock() + .expect("sink") + .iter() + .any(|event| event.event_type == "security_bypass"), + "the event still fires under passive mode" + ); +} + +#[tokio::test] +async fn an_all_bypass_route_without_an_event_bus_still_forwards() { + let fairing = GuardFairing::with_defaults().with_route_configs(all_bypass_routes()); + let client = serve(fairing).await; + let bypassed = out( + get_from(&client, "203.0.113.9", &format!("/open?{ATTACK_QUERY}")) + .dispatch() + .await, + ) + .await; + assert_eq!(bypassed.status, Status::Ok); +} + +#[tokio::test] +async fn a_bare_fairing_without_a_read_timeout_buffers_the_body_for_the_rules() { + let processor = guard_core_rs::process_response::ResponseProcessor::new( + None, + None, + vec![return_rule("secret", 0)], + Arc::new(Mutex::new( + guard_core_engine::behavior::BehaviorTracker::new(), + )), + IpBanManager::new(), + true, + guard_core_engine::behavior::DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES, + false, + ); + let fairing = GuardFairing::with_defaults().with_response_processor(processor); + let client = serve_secret(fairing).await; + let answered = out(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(answered.status, Status::Ok); + assert_eq!(answered.body, "the secret token is 42"); +} + +#[tokio::test] +async fn every_clone_of_the_fairing_shares_one_set_of_stores() { + let config = SecurityConfig { + rate_limit: 1, + ..SecurityConfig::default() + }; + let fairing = from_config(&config); + let first_app = serve(fairing.clone()).await; + let second_app = serve(fairing).await; + let first = out(get_from(&first_app, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(first.status, Status::Ok); + let second = out(get_from(&second_app, "203.0.113.9", "/api") + .dispatch() + .await) + .await; + assert_eq!(second.status, Status::TooManyRequests); +} diff --git a/tests/parity_api.rs b/tests/parity_api.rs new file mode 100644 index 0000000..a436290 --- /dev/null +++ b/tests/parity_api.rs @@ -0,0 +1,597 @@ +//! The new public surface of the parity work: the client policy, the +//! exclusion marker, the wiring entry points (agent, dynamic rules, cloud +//! refresh, distributed stores) and the WebSocket client policy. + +use guard_core_engine::behavior::BehaviorRule; +use guard_core_engine::distributed::{BanStore, SlidingWindowStore, StoreError}; +use guard_core_engine::route_config::RouteConfig; +use guard_core_engine::security_config::SecurityConfig; +use guard_core_rs::composite::{TelemetryError, TelemetryHandler}; +use guard_core_rs::events::SecurityEvent; +use guard_core_rs::metrics::SecurityMetric; +use rocket::get; +use rocket::http::{Header, Status}; +use rocket::local::asynchronous::{Client, LocalRequest, LocalResponse}; +use rocket::routes; +use rocket_guard_rs::websocket::{WebSocketGuardConfig, make_guard_websocket}; +use rocket_guard_rs::{ + AgentStats, BlockGuard, ClientPolicy, Distributed, GuardConfigError, GuardExclusionScoped, + GuardFairing, IpBanManager, PathExclusions, WebSocketGuard, Wiring, WiringOptions, + default_config, +}; +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +async fn text(response: LocalResponse<'_>) -> (Status, String) { + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) +} + +#[get("/<_path..>")] +#[allow(clippy::needless_pass_by_value)] // route parameters are owned by design +fn marker( + _path: PathBuf, + scoped: Option, + _guard: BlockGuard, +) -> &'static str { + if scoped.is_some() { "scoped" } else { "normal" } +} + +async fn serve(fairing: GuardFairing) -> Client { + Client::untracked(rocket::build().attach(fairing).mount("/", routes![marker])) + .await + .expect("valid rocket") +} + +fn peer(ip: &str) -> SocketAddr { + SocketAddr::new(ip.parse::().expect("test ip"), 45_000) +} + +fn get_from<'c>(client: &'c Client, ip: &str, uri: &str) -> LocalRequest<'c> { + client.get(uri.to_owned()).remote(peer(ip)) +} + +#[tokio::test] +async fn an_excluded_request_carries_the_scoped_marker() { + let fairing = + GuardFairing::from_security_config(&SecurityConfig::default()).expect("valid config"); + let client = serve(fairing).await; + let (_, scoped) = text(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(scoped, "scoped"); + let (_, normal) = text(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(normal, "normal"); +} + +#[test] +fn the_exclusions_accessor_reports_the_normalized_matcher() { + let fairing = GuardFairing::with_defaults() + .with_exclude_paths(vec!["/docs/".to_owned(), "/a/../static".to_owned()]); + assert_eq!(fairing.exclude_paths(), ["/docs", "/static"]); + let fairing = + GuardFairing::with_defaults().with_path_exclusions(PathExclusions::new(["/health"])); + assert_eq!(fairing.exclude_paths(), ["/health"]); +} + +#[tokio::test] +async fn a_bare_fairing_adopts_the_client_policy_it_is_given() { + let policy = ClientPolicy { + trusted_proxies: vec!["10.0.0.0/8".to_owned()], + trusted_proxy_depth: 1, + fail_secure: true, + }; + let fairing = GuardFairing::new(default_config()) + .with_ip_gate( + rocket_guard_rs::IpGateConfig::new([] as [&str; 0], ["203.0.113.7"], [] as [&str; 0]) + .expect("valid lists"), + ) + .with_client_policy(policy.clone()); + assert_eq!(fairing.client_policy(), &policy); + let client = serve(fairing).await; + let (blocked, _) = text( + get_from(&client, "10.0.0.1", "/api") + .header(Header::new("x-forwarded-for", "203.0.113.7")) + .dispatch() + .await, + ) + .await; + assert_eq!(blocked, Status::Forbidden); + let (rejected, body) = text(client.get("/api").dispatch().await).await; + assert_eq!(rejected, Status::Forbidden); + assert_eq!(body, "Client address could not be determined"); +} + +#[test] +fn an_unknown_cloud_provider_selector_fails_closed() { + let config = SecurityConfig { + block_cloud_providers: Some(["NotACloud".to_owned()].into_iter().collect()), + ..SecurityConfig::default() + }; + let error = GuardFairing::from_security_config(&config).expect_err("rejected"); + assert!(matches!(error, GuardConfigError::Cloud(_)), "{error}"); + assert!(error.to_string().contains("block_cloud_providers")); + assert!(std::error::Error::source(&error).is_some()); +} + +#[test] +fn country_rules_without_a_resolver_fail_closed() { + let config = SecurityConfig { + blocked_countries: ["US".to_owned()].into_iter().collect(), + ..SecurityConfig::default() + }; + let error = GuardFairing::from_security_config(&config).expect_err("rejected"); + assert!(matches!(error, GuardConfigError::Geo(_)), "{error}"); + assert!(error.to_string().starts_with("geo:")); + assert!(std::error::Error::source(&error).is_none()); +} + +#[test] +fn a_redis_error_renders_its_message() { + let error = GuardConfigError::Redis("boom".to_owned()); + assert_eq!(error.to_string(), "redis: boom"); + assert!(std::error::Error::source(&error).is_none()); +} + +// --- the agent seam --- + +#[derive(Default)] +struct Sink { + events: AtomicUsize, + metrics: AtomicUsize, + polls: AtomicUsize, + fail_start: bool, +} + +impl TelemetryHandler for Sink { + fn handler_name(&self) -> &'static str { + "Sink" + } + fn start(&self) -> Result<(), TelemetryError> { + if self.fail_start { + Err(TelemetryError("start failed".to_owned())) + } else { + Ok(()) + } + } + fn stop(&self) -> Result<(), TelemetryError> { + Ok(()) + } + fn send_event(&self, _event: &SecurityEvent) -> Result<(), TelemetryError> { + self.events.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn send_metric(&self, _metric: &SecurityMetric) -> Result<(), TelemetryError> { + self.metrics.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn get_dynamic_rules(&self) -> Result, TelemetryError> { + self.polls.fetch_add(1, Ordering::SeqCst); + Ok(None) + } + fn health_check(&self) -> bool { + true + } +} + +fn agent_config() -> SecurityConfig { + SecurityConfig { + enable_agent: true, + agent_enable_events: true, + agent_enable_metrics: true, + rate_limit: 1, + ..SecurityConfig::default() + } +} + +#[test] +fn agent_stats_is_disabled_without_an_agent() { + let fairing = + GuardFairing::from_security_config(&SecurityConfig::default()).expect("valid config"); + assert_eq!( + fairing.agent_stats(), + AgentStats { + enabled: false, + degraded: false + } + ); + assert!(fairing.telemetry().is_none()); + assert!(fairing.spawn_dynamic_rules().is_none()); +} + +#[tokio::test] +async fn the_injected_agent_receives_the_events_and_metrics() { + let sink = Arc::new(Sink::default()); + let fairing = GuardFairing::from_security_config_with( + &agent_config(), + &WiringOptions { + agent: Some(Arc::clone(&sink) as Arc), + logfire_client: None, + }, + ) + .expect("valid config"); + assert_eq!( + fairing.agent_stats(), + AgentStats { + enabled: true, + degraded: false + } + ); + let client = serve(fairing).await; + let (first, _) = text(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(first, Status::Ok); + let (second, _) = text(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(second, Status::TooManyRequests); + assert!( + sink.metrics.load(Ordering::SeqCst) > 0, + "the forwarded request reached the agent as a metric" + ); + assert!( + sink.events.load(Ordering::SeqCst) > 0, + "the rate-limit crossing reached the agent as an event" + ); +} + +#[test] +fn a_degraded_agent_start_shows_in_the_stats() { + let sink = Arc::new(Sink { + fail_start: true, + ..Sink::default() + }); + let fairing = GuardFairing::from_security_config_with( + &agent_config(), + &WiringOptions { + agent: Some(sink as Arc), + logfire_client: None, + }, + ) + .expect("valid config"); + fairing.telemetry().expect("telemetry").start(); + assert_eq!( + fairing.agent_stats(), + AgentStats { + enabled: true, + degraded: true + } + ); +} + +#[test] +fn the_dynamic_rule_poll_loop_is_host_started() { + let sink = Arc::new(Sink::default()); + let config = SecurityConfig { + enable_dynamic_rules: true, + dynamic_rule_interval: 1, + ..agent_config() + }; + let fairing = GuardFairing::from_security_config_with( + &config, + &WiringOptions { + agent: Some(Arc::clone(&sink) as Arc), + logfire_client: None, + }, + ) + .expect("valid config"); + assert_eq!( + sink.polls.load(Ordering::SeqCst), + 0, + "wiring never spawns the loop" + ); + let handle = fairing.spawn_dynamic_rules().expect("a poll loop"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while sink.polls.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + sink.polls.load(Ordering::SeqCst) > 0, + "the loop polled the agent" + ); + drop(handle); +} + +// --- wiring parts: cloud, distributed --- + +#[tokio::test] +async fn the_cloud_stage_and_its_refresh_are_wired_from_the_config() { + let config = SecurityConfig { + block_cloud_providers: Some(["AWS".to_owned()].into_iter().collect()), + ..SecurityConfig::default() + }; + let mut wiring = Wiring::from_security_config(&config).expect("wired"); + let refresh = wiring.cloud_refresh.as_mut().expect("a refresh seam"); + refresh + .table + .set_provider_ranges("AWS", vec![("198.51.100.0/24".to_owned(), None)]) + .expect("ranges"); + // Keep the background refresh off the network (and from clearing the + // ranges a failed fetch would): the scheduler has no providers. + let scheduler = + guard_core_rs::geo_lifecycle::CloudRefreshScheduler::new().with_providers(Vec::new()); + refresh.scheduler = Arc::new(scheduler); + let fairing = GuardFairing::from_wiring(wiring); + let client = serve(fairing.clone()).await; + let (blocked, body) = text(get_from(&client, "198.51.100.5", "/api").dispatch().await).await; + assert_eq!(blocked, Status::Forbidden); + assert_eq!(body, "Cloud provider IP not allowed"); + let (allowed, _) = text(get_from(&client, "203.0.113.9", "/api").dispatch().await).await; + assert_eq!(allowed, Status::Ok); + // The global cloud rules also apply on an excluded path. + let (scoped, scoped_body) = + text(get_from(&client, "198.51.100.77", "/docs").dispatch().await).await; + assert_eq!(scoped, Status::Forbidden); + assert_eq!(scoped_body, "Cloud provider IP not allowed"); + // The maintenance call drives the same wired scheduler. + let _scheduled = fairing.refresh_cloud_ip_ranges(); +} + +struct MemoryWindowStore(Mutex>>); + +impl SlidingWindowStore for MemoryWindowStore { + fn record_hit(&self, key: &str, now: f64, window: u64) -> Result { + let mut windows = self.0.lock().expect("windows"); + let hits = windows.entry(key.to_owned()).or_default(); + hits.retain(|hit| now - hit < f64::from(u32::try_from(window).unwrap_or(u32::MAX))); + hits.push(now); + Ok(hits.len() as u64) + } +} + +struct MemoryBanStore(Mutex>); + +impl BanStore for MemoryBanStore { + fn set_ban(&self, key: &str, expiry: f64, _ttl_seconds: u64) -> Result<(), StoreError> { + self.0.lock().expect("bans").insert(key.to_owned(), expiry); + Ok(()) + } + fn get_ban(&self, key: &str) -> Result, StoreError> { + Ok(self.0.lock().expect("bans").get(key).copied()) + } + fn delete_ban(&self, key: &str) -> Result<(), StoreError> { + self.0.lock().expect("bans").remove(key); + Ok(()) + } +} + +#[tokio::test] +async fn the_wired_distributed_stores_carry_the_rate_limit_and_the_ban() { + let config = SecurityConfig { + rate_limit: 1, + enable_rate_limit_auto_ban: true, + auto_ban_threshold: 1, + ..SecurityConfig::default() + }; + let ban_store = Arc::new(MemoryBanStore(Mutex::new(HashMap::new()))); + let mut wiring = Wiring::from_security_config(&config).expect("wired"); + wiring.distributed = Some(Distributed { + window_store: Arc::new(MemoryWindowStore(Mutex::new(HashMap::new()))), + ban_store: Arc::clone(&ban_store) as Arc, + prefix: "guard_core:".to_owned(), + fail_open: false, + }); + let client = serve(GuardFairing::from_wiring(wiring)).await; + let (first, _) = text(get_from(&client, "192.0.2.77", "/api").dispatch().await).await; + assert_eq!(first, Status::Ok); + let (second, _) = text(get_from(&client, "192.0.2.77", "/api").dispatch().await).await; + assert_eq!(second, Status::TooManyRequests); + assert!( + ban_store + .0 + .lock() + .expect("bans") + .contains_key("guard_core:banned_ips:192.0.2.77"), + "the auto-ban wrote through the wired ban store" + ); +} + +// --- the response body timeout --- + +struct Hang; + +impl rocket::tokio::io::AsyncRead for Hang { + fn poll_read( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + _buf: &mut rocket::tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } +} + +struct Failing; + +impl rocket::tokio::io::AsyncRead for Failing { + fn poll_read( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + _buf: &mut rocket::tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Err(std::io::Error::other("stream broke"))) + } +} + +/// A streamed (unsized) response body over any reader. +struct Streamed(R); + +impl<'r, R: rocket::tokio::io::AsyncRead + Send + 'static> rocket::response::Responder<'r, 'static> + for Streamed +{ + fn respond_to(self, _request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> { + rocket::response::Response::build() + .streamed_body(self.0) + .ok() + } +} + +#[get("/hang")] +fn hang(_guard: BlockGuard) -> Streamed { + Streamed(Hang) +} + +#[get("/broken")] +fn broken(_guard: BlockGuard) -> Streamed { + Streamed(Failing) +} + +#[get("/chunks")] +fn chunks(_guard: BlockGuard) -> Streamed> { + Streamed(std::io::Cursor::new("chunkchunk")) +} + +fn secret_rule(threshold: u32, pattern: &str) -> SecurityConfig { + SecurityConfig { + global_behavior_rules: vec![BehaviorRule { + rule_type: "return_pattern".to_owned(), + threshold, + window: 60, + pattern: pattern.to_owned(), + action: "ban".to_owned(), + ban_duration: Some(60), + correlate_with_detection: false, + }], + behavior_scan_response_body: true, + ..SecurityConfig::default() + } +} + +async fn serve_streams(fairing: GuardFairing) -> Client { + Client::untracked( + rocket::build() + .attach(fairing) + .mount("/", routes![hang, broken, chunks]), + ) + .await + .expect("valid rocket") +} + +#[tokio::test] +async fn a_stalled_response_body_times_out_fail_secure() { + let fairing = GuardFairing::from_security_config(&secret_rule(1, "secret")) + .expect("valid config") + .with_body_read_timeout(Duration::from_millis(100)); + let client = serve_streams(fairing).await; + let (status, body) = text(get_from(&client, "203.0.113.9", "/hang").dispatch().await).await; + assert_eq!(status, Status::InternalServerError); + assert_eq!(body, rocket_guard_rs::FAILURE_MESSAGE); +} + +#[tokio::test] +async fn a_response_body_read_error_fails_secure_when_a_body_rule_needs_it() { + let fairing = GuardFairing::from_security_config(&secret_rule(1, "secret")).expect("valid"); + let client = serve_streams(fairing).await; + let (status, body) = text(get_from(&client, "203.0.113.9", "/broken").dispatch().await).await; + assert_eq!(status, Status::InternalServerError); + assert_eq!(body, rocket_guard_rs::FAILURE_MESSAGE); +} + +#[tokio::test] +async fn a_streamed_response_with_a_status_only_rule_is_not_buffered() { + // A status rule never reads the body, so a broken stream is not read by + // the pass (it would otherwise answer the fail-secure 500). + let fairing = GuardFairing::from_security_config(&secret_rule(5, "status:404")).expect("valid"); + let client = serve_streams(fairing).await; + let (status, body) = text(get_from(&client, "203.0.113.9", "/chunks").dispatch().await).await; + assert_eq!(status, Status::Ok); + assert_eq!(body, "chunkchunk"); +} + +// --- the WebSocket guard resolves the client like the HTTP guard --- + +#[get("/ws")] +fn ws_route(_guard: WebSocketGuard) -> &'static str { + "ok" +} + +#[tokio::test] +async fn the_websocket_guard_resolves_the_forwarded_client() { + let manager = IpBanManager::new(); + manager + .ban_ip("203.0.113.7".parse().expect("ip"), 600, "test ban") + .expect("ban"); + let config = WebSocketGuardConfig::new(default_config()) + .with_ip_banning(manager) + .with_client_policy(ClientPolicy { + trusted_proxies: vec!["10.0.0.0/8".to_owned()], + trusted_proxy_depth: 1, + fail_secure: true, + }); + let client = Client::untracked( + rocket::build() + .manage(make_guard_websocket(config)) + .mount("/", routes![ws_route]), + ) + .await + .expect("valid rocket"); + let banned = get_from(&client, "10.0.0.1", "/ws") + .header(Header::new("x-forwarded-for", "203.0.113.7")) + .dispatch() + .await; + assert_eq!(banned.status(), Status::Forbidden); + let allowed = get_from(&client, "10.0.0.1", "/ws") + .header(Header::new("x-forwarded-for", "198.51.100.4")) + .dispatch() + .await; + assert_eq!(allowed.status(), Status::Ok); + let unresolved = client.get("/ws").dispatch().await; + assert_eq!(unresolved.status(), Status::Forbidden); +} + +#[tokio::test] +async fn the_wiring_parts_install_one_by_one_on_a_bare_fairing() { + let config = SecurityConfig { + enable_dynamic_rules: true, + block_cloud_providers: Some(["AWS".to_owned()].into_iter().collect()), + ..agent_config() + }; + let wiring = Wiring::from_security_config_with( + &config, + &WiringOptions { + agent: Some(Arc::new(Sink::default()) as Arc), + logfire_client: None, + }, + ) + .expect("wired"); + let mut refresh = wiring.cloud_refresh.clone().expect("refresh"); + refresh.scheduler = Arc::new( + guard_core_rs::geo_lifecycle::CloudRefreshScheduler::new().with_providers(Vec::new()), + ); + let fairing = GuardFairing::new(default_config()) + .with_telemetry(Arc::clone(wiring.telemetry.as_ref().expect("telemetry"))) + .with_dynamic_rules(wiring.dynamic_rules.clone().expect("rules")) + .with_cloud_refresh(refresh) + .with_auth_verifier(Arc::new(|credential| credential == "good")) + .with_route_configs(Arc::new(|_method, _path| { + Some(Arc::new(RouteConfig { + auth_required: Some("bearer".to_owned()), + ..RouteConfig::default() + })) + })); + assert!(fairing.agent_stats().enabled); + drop(fairing.spawn_dynamic_rules().expect("a loop")); + let client = serve(fairing).await; + let (good, _) = text( + get_from(&client, "203.0.113.9", "/api") + .header(Header::new("authorization", "Bearer good")) + .dispatch() + .await, + ) + .await; + assert_eq!(good, Status::Ok); +} + +#[tokio::test] +async fn an_invalid_route_tier_on_an_excluded_path_fails_secure() { + let fairing = GuardFairing::with_defaults() + .with_exclude_paths(vec!["/docs".to_owned()]) + .with_route_configs(Arc::new(|_method, _path| { + Some(Arc::new(RouteConfig { + rate_limit: Some(0), + ..RouteConfig::default() + })) + })); + let client = serve(fairing).await; + let (status, body) = text(get_from(&client, "203.0.113.9", "/docs").dispatch().await).await; + assert_eq!(status, Status::InternalServerError); + assert_eq!(body, rocket_guard_rs::FAILURE_MESSAGE); +}