diff --git a/AGENTS.md b/AGENTS.md index 294cc39..a99cc05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,11 +35,12 @@ A route without a guard argument is scanned but never blocked; that is Rocket's ## 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`). +`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`). The config is validated first (`GuardConfigError::Config`). The stages a dynamic rule can change are read per request from a `LiveCell` the fairing builds at `on_ignite` (`Wiring::live`, stored in `GuardEngine::live`); a builder-built fairing keeps static stages. With the `agent` feature the agent is built from the `agent_*` fields when none is injected (`src/agent.rs`, degrade or `GuardConfigError::Agent` per `agent_strict`); the `redis` feature forwards `guard-core-rs/redis`. - **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. +- **Strict routes, request-body timeout and usage rules**: `route_resolution_strict` is decided in `on_response` (`Request::route()` is `None` only when the router matched nothing, so no handler ran; the answer becomes `500 Route resolution failed`, log-only under `passive_mode`). `GuardBody` bounds its read with `body_read_timeout` and answers `408` (Rocket itself peeks 14 bytes before routing, outside this bound). The route usage rules run after the pipeline passed: from `BlockGuard`/`GuardBody` on success, or once in `on_response` for an unguarded route (`scan::usage_rules_verdict`). - **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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 793864e..5b0bd4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,11 @@ The parity release: the reference `exclude_paths`, client and response-pass sema - 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` +- Reference parity: `SecurityConfig::validate` runs at construction (`GuardConfigError::Config`), the stages a dynamic rule can change (geo, cloud, user agent, emergency mode, the penetration toggle, the rate limit and ban knobs) are read from the live config on every request (`LiveCell`, re-exported with `LiveContext` and `LiveStages`), `route_resolution_strict` answers `500 Route resolution failed` for a request that matched no route (decided in `on_response`, log-only under `passive_mode`, emits `route_unresolved` and fires `on_block`, `GuardFairing::with_route_resolution_strict`), `GuardBody` answers `408 Request body read timed out` when the body does not arrive within `body_read_timeout`, and the cargo features `redis` (forwards `guard-core-rs/redis`) and `agent` (builds the guard-agent-rs agent from the `agent_*` fields, `GuardConfigError::Agent`, degraded state in `agent_stats()`) + ### Changed +- The route usage rules (`process_usage_rules`) run after the whole pipeline passed instead of before it: a request the pipeline or a guard refuses is no longer counted, and neither is a request with no client ip. Configs that were invalid but accepted before (for example `auto_ban_threshold = 0`) are now refused by `from_security_config`; `Wiring::build_unvalidated` is the unvalidated seam - `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 diff --git a/CLAUDE.md b/CLAUDE.md index 294cc39..a99cc05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,11 +35,12 @@ A route without a guard argument is scanned but never blocked; that is Rocket's ## 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`). +`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`). The config is validated first (`GuardConfigError::Config`). The stages a dynamic rule can change are read per request from a `LiveCell` the fairing builds at `on_ignite` (`Wiring::live`, stored in `GuardEngine::live`); a builder-built fairing keeps static stages. With the `agent` feature the agent is built from the `agent_*` fields when none is injected (`src/agent.rs`, degrade or `GuardConfigError::Agent` per `agent_strict`); the `redis` feature forwards `guard-core-rs/redis`. - **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. +- **Strict routes, request-body timeout and usage rules**: `route_resolution_strict` is decided in `on_response` (`Request::route()` is `None` only when the router matched nothing, so no handler ran; the answer becomes `500 Route resolution failed`, log-only under `passive_mode`). `GuardBody` bounds its read with `body_read_timeout` and answers `408` (Rocket itself peeks 14 bytes before routing, outside this bound). The route usage rules run after the pipeline passed: from `BlockGuard`/`GuardBody` on success, or once in `on_response` for an unguarded route (`scan::usage_rules_verdict`). - **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 diff --git a/Cargo.lock b/Cargo.lock index 40c22ce..6b945b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "ghash", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -26,6 +61,12 @@ dependencies = [ "libc", ] +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + [[package]] name = "async-stream" version = "0.3.6" @@ -74,18 +115,53 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "binascii" version = "0.1.4" @@ -162,6 +238,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -171,6 +249,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -220,6 +304,42 @@ dependencies = [ "uncased", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -237,6 +357,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -249,6 +379,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -292,7 +428,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", ] [[package]] @@ -359,6 +515,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -372,6 +529,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.18.0" @@ -477,6 +640,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.34" @@ -571,8 +740,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -582,9 +753,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", ] [[package]] @@ -593,6 +775,30 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "guard-agent-rs" +version = "3.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b2d4523c9957066fa1dd92331a95cd8fe9378c797f9ecaf05360c875866586" +dependencies = [ + "aes-gcm", + "async-trait", + "base64 0.23.1", + "chrono", + "flate2", + "guard-core-rs", + "hmac", + "log", + "rand 0.10.3", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.21", + "tokio", + "uuid", +] + [[package]] name = "guard-core-engine" version = "4.3.3" @@ -605,6 +811,7 @@ dependencies = [ "percent-encoding", "rand 0.10.3", "regex", + "rustix", "serde", "serde_json", "sha2", @@ -619,6 +826,7 @@ dependencies = [ "guard-core-engine", "html-escape", "http 1.5.0", + "redis", "regex", "serde_json", "sha2", @@ -641,6 +849,15 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html-escape" version = "0.2.15" @@ -679,6 +896,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -711,7 +951,7 @@ dependencies = [ "futures-core", "futures-util", "http 0.2.12", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -723,6 +963,64 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http 1.5.0", + "hyper 1.12.0", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "hyper 1.12.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -869,6 +1167,21 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c0d789294fb457b791f7797a04478704788f30b407e5d0b01deaa846572d9f" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-terminal" version = "0.4.17" @@ -886,6 +1199,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -960,6 +1332,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "matchers" version = "0.2.0" @@ -1067,6 +1445,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1185,6 +1569,23 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1231,6 +1632,63 @@ dependencies = [ "yansi", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -1293,6 +1751,31 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redis" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9" +dependencies = [ + "arcstr", + "combine", + "itoa", + "percent-encoding", + "ryu", + "socket2 0.6.5", + "url", + "xxhash-rust", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1351,6 +1834,41 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper 1.12.0", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1417,6 +1935,7 @@ name = "rocket-guard-rs" version = "1.5.0" dependencies = [ "chrono", + "guard-agent-rs", "guard-core-engine", "guard-core-rs", "http 1.5.0", @@ -1462,7 +1981,7 @@ dependencies = [ "either", "futures", "http 0.2.12", - "hyper", + "hyper 0.14.32", "indexmap", "log", "memchr", @@ -1489,6 +2008,21 @@ dependencies = [ "tokio-tungstenite", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.5" @@ -1508,6 +2042,7 @@ version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -1517,21 +2052,62 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1543,6 +2119,30 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -1555,6 +2155,35 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1661,6 +2290,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -1763,6 +2402,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -1793,7 +2441,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", ] [[package]] @@ -1807,6 +2464,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "thread_local" version = "1.1.10" @@ -1889,6 +2557,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -1972,8 +2650,31 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", + "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2069,7 +2770,7 @@ dependencies = [ "log", "rand 0.8.8", "sha1", - "thiserror", + "thiserror 1.0.69", "url", "utf-8", ] @@ -2120,6 +2821,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2132,7 +2843,7 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "once_cell", @@ -2166,6 +2877,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2178,11 +2901,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -2206,6 +2939,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -2238,6 +2982,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2256,6 +3029,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows" version = "0.48.0" @@ -2478,6 +3260,12 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index e5fc8bf..e92dd27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,12 +33,34 @@ rocket_ws = { version = "0.1.1", features = ["tokio-tungstenite"] } serde_json = "1" chrono = "0.4" +# The telemetry agent built from the `agent_*` config fields (the reference +# `guard_agent(config.to_agent_config())`): feature `agent`. guard-agent-rs +# depends on the guard-core-rs facade (its `guard-core` feature), never the +# reverse, so the facade cannot own this and each adapter does. The runtime +# handle the agent's async tasks run on comes from the caller's tokio runtime. +guard-agent-rs = { version = "3.2.3", optional = true, default-features = false, features = ["guard-core"] } +tokio = { version = "1", optional = true, default-features = false, features = ["rt"] } + +[features] +default = [] +# Forward the facade's Redis backend: `enable_redis` with a `redis_url` then +# builds the distributed rate-limit and ban stores. +redis = ["guard-core-rs/redis"] +# Build the telemetry agent from `SecurityConfig` (see `GuardFairing::from_security_config`). +agent = ["dep:guard-agent-rs", "dep:tokio"] + [dev-dependencies] # The stage answers (`guard_core_rs::tower::StageResponse`) carry an # `http::StatusCode`; the adapter tests compose them directly. http = "1.3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +# One guard-core-rs for the whole graph: the registry copy guard-agent-rs +# names resolves to the sibling checkout this crate already builds against, +# so the agent bridge and the fairing share the `TelemetryHandler` type. +[patch.crates-io] +guard-core-rs = { path = "../guard-core-rs/crates/guard-core-rs" } + [lints.rust] unsafe_code = "forbid" # cargo-llvm-cov sets cfg(coverage) during coverage builds; declare it so the diff --git a/README.md b/README.md index 5522477..37a2e03 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,9 @@ The Rocket peer address is the connecting peer. The identity every stage runs un `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. +The config is validated at construction (`SecurityConfig::validate`), and the stages a dynamic rule can change (geo, cloud, user agent, emergency mode, the penetration toggle, the rate limit and ban knobs) are read from the live config on every request, so a landed rule takes effect without a rebuild. A fairing built through the builder API (no config) keeps its static stages. Because the live rate-limit stage is built when the application ignites, builder calls made after `from_security_config` still reach it. + +The telemetry agent is injected, or built from the config with the `agent` cargo feature. Without the feature, 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 })`. With `features = ["agent"]` and no injected agent, `enable_agent` plus an `agent_api_key` builds the guard-agent-rs agent from the `agent_*` fields on the caller's tokio runtime; a failure degrades (`agent_stats()` reports `degraded`) unless `agent_strict` is set, which fails with `GuardConfigError::Agent`. 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 @@ -316,9 +318,19 @@ 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 (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. +- **Pre-routing refusal of unmatched requests**: Rocket resolves routes after the request fairings, so `route_resolution_strict` cannot refuse in `on_request`. It is decided in `on_response` instead (see below): a request that matched no route answers `500 Route resolution failed`, and the `404` replaced proves no handler ran. +- **Request-body bound before Rocket routes**: Rocket itself reads the first 14 bytes of every body before routing, so `body_read_timeout` bounds the `GuardBody` read, not that initial peek. + +## Strict route resolution, body timeout and usage rules + +- **`route_resolution_strict`**: Rocket keeps the last route it tried on the request, so `Request::route()` is `None` exactly when the router found no matching route (path, method, format and query all considered). The fairing checks that in `on_response`: with the knob set the answer is replaced with `500 Route resolution failed` (the `route_unresolved` event and the `on_block` hook fire, and `custom_error_responses` for `500` applies); under `passive_mode` it only logs. A route that matched and forwarded (for example a guard that forwards) is not unresolved. `GuardFairing::with_route_resolution_strict` sets it on a builder-built fairing. +- **`body_read_timeout` for the request body**: `GuardBody` bounds its body read with the config's `body_read_timeout` (seconds) and answers `408 Request body read timed out` when the body does not arrive in time. The unread body is never forwarded. +- **Route usage rules** (`process_usage_rules`) run after the whole pipeline passed, not before it: a request the pipeline or a guard refuses is not counted, and neither is an excluded, bypassed or unattributed (no client ip) request. Rocket has no single point just before the handler, so `BlockGuard` and `GuardBody` run the rules when they succeed (after the body scan for `GuardBody`), and a route without a guard argument runs them once in `on_response`. + +## Cargo features + +- **`redis`**: forwards `guard-core-rs/redis`, so `enable_redis` plus `redis_url` build the shared rate-limit and ban stores. Without it limits and bans stay per process. +- **`agent`**: builds the telemetry agent from the `agent_*` config fields (optional `guard-agent-rs` and a tokio runtime handle). See the config section above. ## Development diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..d85cf39 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,261 @@ +//! The telemetry agent built from the `agent_*` config fields (feature +//! `agent`): the reference `guard_agent(config.to_agent_config())` step of +//! the adapter constructor. +//! +//! guard-agent-rs depends on the guard-core-rs facade (its `guard-core` +//! feature supplies the `TelemetryHandler` bridge), so the facade cannot +//! depend on it back; every adapter owns this mapping instead. + +use guard_agent_rs::bridge::GuardAgentTelemetry; +use guard_agent_rs::{AgentConfig, BufferOverflowPolicy as AgentOverflow, GuardAgent}; +use guard_core_engine::security_config::{BufferOverflowPolicy, SecurityConfig}; +use guard_core_rs::composite::TelemetryHandler; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Map the config's `agent_*` fields onto the agent's own config (the +/// reference `SecurityConfig.to_agent_config`): `None` unless `enable_agent` +/// is set with an `agent_api_key`; an unset optional field keeps the agent's +/// default. +pub(crate) fn agent_config(config: &SecurityConfig) -> Option { + let api_key = config + .agent_api_key + .clone() + .filter(|_| config.enable_agent)?; + let mut agent = AgentConfig::new(api_key); + agent.endpoint.clone_from(&config.agent_endpoint); + agent.project_id.clone_from(&config.agent_project_id); + agent.buffer_size = config.agent_buffer_size; + agent.flush_interval = config.agent_flush_interval; + agent.dynamic_rule_interval = config.dynamic_rule_interval; + agent.status_interval = config.agent_status_interval; + agent.enable_events = config.agent_enable_events; + agent.enable_metrics = config.agent_enable_metrics; + agent.timeout = config.agent_timeout; + agent.retry_attempts = config.agent_retry_attempts; + agent + .project_encryption_key + .clone_from(&config.agent_project_encryption_key); + agent.guard_version.clone_from(&config.agent_guard_version); + agent.install_id.clone_from(&config.agent_install_id); + agent + .payload_signing_secret + .clone_from(&config.agent_payload_signing_secret); + if let Some(ratio) = config.agent_high_watermark_ratio { + agent.high_watermark_ratio = ratio; + } + if let Some(flushes) = config.agent_max_concurrent_flushes { + agent.max_concurrent_flushes = usize::try_from(flushes).unwrap_or(usize::MAX); + } + if let Some(policy) = config.agent_buffer_overflow_policy { + agent.buffer_overflow_policy = match policy { + BufferOverflowPolicy::Drop => AgentOverflow::Drop, + BufferOverflowPolicy::Block => AgentOverflow::Block, + BufferOverflowPolicy::Raise => AgentOverflow::Raise, + }; + } + if let Some(factor) = config.agent_backoff_factor { + agent.backoff_factor = factor; + } + if let Some(headers) = &config.agent_sensitive_headers { + agent.sensitive_headers.clone_from(headers); + } + if let Some(size) = config.agent_max_payload_size { + agent.max_payload_size = usize::try_from(size).unwrap_or(usize::MAX); + } + if let Some(enabled) = config.agent_compression_enabled { + agent.compression_enabled = enabled; + } + if let Some(threshold) = config.agent_compression_threshold { + agent.compression_threshold = usize::try_from(threshold).unwrap_or(usize::MAX); + } + if let Some(hook) = config.on_error.clone() { + agent.on_error = Some(Arc::new(move |stage, error| { + hook(&stage.to_string(), &error.to_string(), &BTreeMap::new()); + })); + } + Some(agent) +} + +/// Build and start the agent the config describes, as the telemetry sink the +/// wiring takes. `Ok(None)` when the config asks for none; `Err` carries why +/// the agent could not be built (no tokio runtime to run it on, or the agent +/// rejected the config). +pub(crate) fn build(config: &SecurityConfig) -> Result>, String> { + let Some(agent_config) = agent_config(config) else { + return Ok(None); + }; + let runtime = tokio::runtime::Handle::try_current() + .map_err(|error| format!("the agent needs a tokio runtime: {error}"))?; + let agent = Arc::new(GuardAgent::new(agent_config).map_err(|error| error.to_string())?); + // The start paths block on the runtime, which the request path must not + // do: the agent starts as a task on it (the reference starts it at the + // first request's lazy initialization). + let starting = Arc::clone(&agent); + runtime.spawn(async move { starting.start().await }); + Ok(Some(Arc::new(GuardAgentTelemetry::new(agent, runtime)))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::GuardFairing; + + fn config() -> SecurityConfig { + SecurityConfig { + enable_redis: false, + enable_agent: true, + agent_api_key: Some("a-key-of-at-least-ten".to_owned()), + agent_endpoint: "http://127.0.0.1:1".to_owned(), + agent_install_id: Some("install-1".to_owned()), + ..SecurityConfig::default() + } + } + + #[test] + fn no_agent_without_the_switch_or_the_key() { + let mut off = config(); + off.enable_agent = false; + assert!(agent_config(&off).is_none()); + let mut keyless = config(); + keyless.agent_api_key = None; + assert!(agent_config(&keyless).is_none()); + assert!(matches!(build(&off), Ok(None))); + } + + #[test] + fn every_agent_field_maps_onto_the_agent_config() { + let mut config = config(); + config.agent_project_id = Some("proj".to_owned()); + config.agent_buffer_size = 7; + config.agent_flush_interval = 11; + config.dynamic_rule_interval = 120; + config.agent_status_interval = 90; + config.agent_high_watermark_ratio = Some(0.5); + config.agent_max_concurrent_flushes = Some(3); + config.agent_buffer_overflow_policy = Some(BufferOverflowPolicy::Raise); + config.agent_enable_events = false; + config.agent_enable_metrics = false; + config.agent_timeout = 9; + config.agent_retry_attempts = 2; + config.agent_backoff_factor = Some(1.5); + config.agent_sensitive_headers = Some(vec!["x-secret".to_owned()]); + config.agent_max_payload_size = Some(2048); + config.agent_project_encryption_key = Some("k".to_owned()); + config.agent_guard_version = Some("1.2.3".to_owned()); + config.agent_compression_enabled = Some(false); + config.agent_compression_threshold = Some(4096); + config.agent_payload_signing_secret = Some("s".to_owned()); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + config.on_error = Some(Arc::new(move |stage, message, _context| { + sink.lock() + .expect("sink") + .push(format!("{stage}:{message}")); + })); + let agent = agent_config(&config).expect("agent config"); + assert_eq!(agent.api_key, "a-key-of-at-least-ten"); + assert_eq!(agent.endpoint, "http://127.0.0.1:1"); + assert_eq!(agent.project_id.as_deref(), Some("proj")); + assert_eq!((agent.buffer_size, agent.flush_interval), (7, 11)); + assert_eq!( + (agent.dynamic_rule_interval, agent.status_interval), + (120, 90) + ); + assert!((agent.high_watermark_ratio - 0.5).abs() < f64::EPSILON); + assert_eq!(agent.max_concurrent_flushes, 3); + assert_eq!(agent.buffer_overflow_policy, AgentOverflow::Raise); + assert!(!agent.enable_events && !agent.enable_metrics); + assert_eq!((agent.timeout, agent.retry_attempts), (9, 2)); + assert!((agent.backoff_factor - 1.5).abs() < f64::EPSILON); + assert_eq!(agent.sensitive_headers, vec!["x-secret".to_owned()]); + assert_eq!(agent.max_payload_size, 2048); + assert_eq!(agent.project_encryption_key.as_deref(), Some("k")); + assert_eq!(agent.guard_version.as_deref(), Some("1.2.3")); + assert!(!agent.compression_enabled); + assert_eq!(agent.compression_threshold, 4096); + assert_eq!(agent.install_id.as_deref(), Some("install-1")); + assert_eq!(agent.payload_signing_secret.as_deref(), Some("s")); + for policy in [BufferOverflowPolicy::Drop, BufferOverflowPolicy::Block] { + config.agent_buffer_overflow_policy = Some(policy); + let mapped = agent_config(&config).expect("agent config"); + assert_eq!(mapped.buffer_overflow_policy.as_str(), policy.as_str()); + } + let hook = agent.on_error.expect("hook"); + hook( + guard_agent_rs::ErrorStage::FlushEvents, + &guard_agent_rs::GuardAgentError::BufferFull { capacity: 1 }, + ); + assert_eq!(seen.lock().expect("sink").len(), 1); + } + + #[test] + fn without_a_runtime_the_agent_cannot_be_built() { + let error = build(&config()).err().expect("no runtime"); + assert!(error.contains("tokio runtime"), "{error}"); + } + + #[test] + fn a_failed_agent_degrades_unless_strict() { + let mut lax = config(); + lax.agent_strict = false; + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + lax.on_error = Some(Arc::new(move |stage, _message, _context| { + sink.lock().expect("sink").push(stage.to_owned()); + })); + let layer = GuardFairing::from_security_config(&lax).expect("degrades"); + let stats = layer.agent_stats(); + assert!(!stats.enabled && stats.degraded); + assert_eq!(seen.lock().expect("sink").as_slice(), ["agent_init"]); + + let mut strict = config(); + strict.agent_strict = true; + let error = GuardFairing::from_security_config(&strict).expect_err("strict"); + assert!(matches!(error, crate::GuardConfigError::Agent(_))); + } + + #[tokio::test] + async fn inside_a_runtime_the_config_builds_the_agent() { + let layer = GuardFairing::from_security_config(&config()).expect("layer"); + let stats = layer.agent_stats(); + assert!(stats.enabled && !stats.degraded); + } + + #[tokio::test] + async fn an_agent_the_host_injects_wins_over_the_config() { + struct Sink; + impl TelemetryHandler for Sink { + fn handler_name(&self) -> &'static str { + "Sink" + } + fn health_check(&self) -> bool { + true + } + fn send_event( + &self, + _event: &guard_core_rs::events::SecurityEvent, + ) -> Result<(), guard_core_rs::composite::TelemetryError> { + Ok(()) + } + fn send_metric( + &self, + _metric: &guard_core_rs::metrics::SecurityMetric, + ) -> Result<(), guard_core_rs::composite::TelemetryError> { + Ok(()) + } + fn start(&self) -> Result<(), guard_core_rs::composite::TelemetryError> { + Ok(()) + } + fn stop(&self) -> Result<(), guard_core_rs::composite::TelemetryError> { + Ok(()) + } + } + let options = crate::WiringOptions { + agent: Some(Arc::new(Sink)), + logfire_client: None, + }; + let layer = GuardFairing::from_security_config_with(&config(), &options).expect("layer"); + assert!(layer.agent_stats().enabled); + } +} diff --git a/src/fairing.rs b/src/fairing.rs index 9bd91cf..2ef689f 100644 --- a/src/fairing.rs +++ b/src/fairing.rs @@ -99,6 +99,7 @@ pub struct AgentStats { } #[derive(Clone)] +#[allow(clippy::struct_excessive_bools)] // independent reference switches, no state machine pub struct GuardFairing { engine: GuardEngine, ip_gate: Option, @@ -155,8 +156,20 @@ pub struct GuardFairing { client: ClientPolicy, /// The global `auth_verifier`: the fallback of a route's verifiers. auth_verifier: Option, - /// The response-body read bound (`body_read_timeout`). + /// The `body_read_timeout` bound: the request-body read of + /// [`crate::GuardBody`] (`408`) and the response-body read the + /// `return_pattern` rules need (`500`). body_read_timeout: Option, + /// `route_resolution_strict`: a request that matched no route is + /// answered `500 Route resolution failed` (see `on_response`). + route_resolution_strict: bool, + /// The agent init failed and `agent_strict` was off (the reference + /// `agent_degraded`). + agent_degraded: bool, + /// The live-config source (a fairing built from a `SecurityConfig`); + /// `on_ignite` builds the cell from it so every builder call made after + /// `from_wiring` (event bus, hooks, passive mode) reaches the live stage. + live_context: Option, /// The telemetry fan-out `agent_stats` reports on. telemetry: Option>, /// The dynamic-rule driver (host-started). @@ -223,6 +236,9 @@ impl GuardFairing { }, auth_verifier: None, body_read_timeout: None, + route_resolution_strict: false, + agent_degraded: false, + live_context: None, telemetry: None, dynamic_rules: None, cloud_refresh_gate: None, @@ -343,16 +359,31 @@ impl GuardFairing { 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. + /// Bound the body reads (`body_read_timeout`). A request body + /// [`crate::GuardBody`] does not receive in time is refused `408 Request + /// body read timed out` (never forwarded unread). A response body the + /// `return_pattern` rules need 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 } + /// Set `route_resolution_strict` (the reference `RouteConfigCheck`): a + /// request that matched no route is answered `500 Route resolution + /// failed` (a logged event under `passive_mode`). Rocket resolves routes + /// after the request fairings, so the fairing decides in `on_response`: + /// Rocket keeps the last route it tried on the request, so + /// [`Request::route`] is `None` exactly when the router found no + /// matching route (path, method, format and query all considered), no + /// handler ran, and the answer is a `404` the fairing can replace. + #[must_use] + pub const fn with_route_resolution_strict(mut self, strict: bool) -> Self { + self.route_resolution_strict = strict; + self + } + /// Install the reference `RouteConfigResolver` (the /// [`guard_core_engine::route_config::RouteConfig`] carrier): /// `(method, path) -> Option>`. The resolved route's @@ -382,17 +413,22 @@ impl GuardFairing { /// /// 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`]. + /// `auth_verifier` (the fallback of a route's verifiers), + /// `body_read_timeout` (the [`crate::GuardBody`] request-body read and + /// the response-body read) and `route_resolution_strict` (decided in + /// `on_response`, see [`GuardFairing::with_route_resolution_strict`]). + /// The stages a dynamic rule can change (geo, cloud, user agent, + /// emergency mode, the penetration toggle, the rate limit and ban knobs) + /// are read from the live config on every request. With the `agent` + /// feature the telemetry agent is built from the `agent_*` fields + /// (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, a + /// [`crate::GuardConfigError`] when the config fails + /// `SecurityConfig::validate` (the reference validates at construction) + /// or an engine constructor rejects a 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( @@ -403,11 +439,14 @@ impl GuardFairing { /// [`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. + /// [`TelemetryHandler`](guard_core_rs::composite::TelemetryHandler)) and + /// the Logfire client. The agent feeds the event bus, the metrics + /// collector, `agent_stats` and the dynamic rules. With the `agent` + /// feature and no injected agent, `enable_agent` with an `agent_api_key` + /// builds the guard-agent-rs agent from the `agent_*` fields on the + /// caller's tokio runtime; a failure degrades (`agent_stats` reports it) + /// unless `agent_strict`, which fails with + /// [`crate::GuardConfigError::Agent`]. /// /// # Errors /// @@ -416,12 +455,48 @@ impl GuardFairing { pub fn from_security_config_with( config: &guard_core_engine::security_config::SecurityConfig, options: &WiringOptions, + ) -> Result { + #[cfg(feature = "agent")] + if options.agent.is_none() { + // The reference builds the agent from the config + // (`config.to_agent_config()`); a failure degrades unless + // `agent_strict`. + match crate::agent::build(config) { + Ok(Some(agent)) => { + let mut options = options.clone(); + options.agent = Some(agent); + return Self::wired(config, &options); + } + Ok(None) => {} + Err(message) => { + if let Some(hook) = &config.on_error { + hook("agent_init", &message, &std::collections::BTreeMap::new()); + } + if config.agent_strict { + return Err(crate::GuardConfigError::Agent(message)); + } + return Ok(Self::wired(config, options)?.with_agent_degraded()); + } + } + } + Self::wired(config, options) + } + + fn wired( + config: &guard_core_engine::security_config::SecurityConfig, + options: &WiringOptions, ) -> Result { Ok(Self::from_wiring(Wiring::from_security_config_with( config, options, )?)) } + #[cfg(feature = "agent")] + fn with_agent_degraded(mut self) -> Self { + self.agent_degraded = true; + self + } + /// 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] @@ -456,6 +531,7 @@ impl GuardFairing { events, metrics, dynamic_rules, + live: live_context, .. } = wiring; let mut fairing = Self::new(detect) @@ -467,7 +543,11 @@ impl GuardFairing { .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(); + // An unrepresentable value keeps the reference default (3 s). + fairing.body_read_timeout = Some( + Duration::try_from_secs_f64(config.body_read_timeout).unwrap_or(Duration::from_secs(3)), + ); + fairing.route_resolution_strict = config.route_resolution_strict; fairing.on_block = on_block; fairing.observability = observability; fairing.geo_handler = geo_handler; @@ -502,9 +582,35 @@ impl GuardFairing { ) .with_distributed_ban_store(distributed.ban_store); } + fairing.live_context = Some(live_context); fairing } + /// The live-config cell: the rate-limit stage and the stages a dynamic + /// rule can change are rebuilt from the live config when a rule lands + /// (see `guard_core_rs::live`). Built at `on_ignite`, over the fairing as + /// finally configured, and cloned into the managed engine so every + /// request and guard reads it. `None` for a fairing built through the + /// builder API (no config), whose stages stay static. + fn build_live_cell( + &self, + ) -> Option>> { + let context = self.live_context.as_ref()?; + let snapshot = self.clone(); + let counters = self + .engine + .ban_state + .as_ref() + .map_or_else(ViolationCounters::new, |state| state.counters.clone()); + context + .cell(move |stages| crate::scan::RocketLive { + stages: Arc::clone(stages), + rate_stage: Arc::new(snapshot.build_live_stage(stages, &counters)), + }) + .ok() + .map(Arc::new) + } + /// Install the global IP gate: a `whitelist`/`blacklist`/`exempt_ips` /// config built with /// [`IpGateConfig::new`](guard_core_engine::ip_gate::IpGateConfig::new) @@ -1007,11 +1113,11 @@ impl GuardFairing { self.telemetry.as_ref().map_or( AgentStats { enabled: false, - degraded: false, + degraded: self.agent_degraded, }, |telemetry| AgentStats { enabled: true, - degraded: telemetry.degraded(), + degraded: self.agent_degraded || telemetry.degraded(), }, ) } @@ -1079,17 +1185,50 @@ impl GuardFairing { }, |state| state.config.clone(), ); + self.stage_over( + rate_limit, + ip_ban, + self.engine.rate_limiter.as_deref().cloned(), + self.engine + .ban_state + .as_ref() + .map(|state| (state.manager.clone(), state.counters.clone())), + ) + } + + /// The same stage over the live stages: the limiter and the ban knobs a + /// dynamic rule changed, sharing the recorded windows and the ban store. + fn build_live_stage( + &self, + live: &crate::LiveStages, + counters: &ViolationCounters, + ) -> RateLimitStage { + self.stage_over( + live.rate_limit.clone(), + live.ip_ban.clone(), + Some(live.rate_limiter.clone()), + Some((live.bans.clone(), counters.clone())), + ) + } + + fn stage_over( + &self, + rate_limit: RateLimitConfig, + ip_ban: IpBanConfig, + limiter: Option, + bans: Option<(IpBanManager, ViolationCounters)>, + ) -> RateLimitStage { let mut builder = RateLimitStage::builder(RateLimitStageConfig { rate_limit, ip_ban, passive_mode: self.passive_mode, custom_error_responses: self.custom_error_responses.clone(), }); - if let Some(limiter) = &self.engine.rate_limiter { - builder = builder.limiter(limiter.as_ref().clone()); + if let Some(limiter) = limiter { + builder = builder.limiter(limiter); } - if let Some(state) = &self.engine.ban_state { - builder = builder.ban_manager(state.manager.clone(), state.counters.clone()); + if let Some((manager, counters)) = bans { + builder = builder.ban_manager(manager, counters); } if let Some(resolver) = &self.route_tiers { let resolver = std::sync::Arc::clone(resolver); @@ -1151,6 +1290,9 @@ impl Fairing for GuardFairing { .custom_error_responses .clone_from(&self.custom_error_responses); engine.stage = Some(std::sync::Arc::new(self.build_stage())); + engine.body_read_timeout = self.body_read_timeout; + engine.usage_processor.clone_from(&self.response_processor); + engine.live = self.build_live_cell(); rocket = rocket.manage(engine); } @@ -1205,6 +1347,20 @@ impl Fairing for GuardFairing { *response = rendered; return; } + // The reference `RouteConfigCheck` (first in the pipeline, so it + // wins over every other verdict): `request.route()` is `None` only + // when the router found no matching route, so no handler ran and the + // answer is a `404` this fairing may replace. See + // [`GuardFairing::with_route_resolution_strict`]. + if self.route_resolution_strict + && request.route().is_none() + && let Some(refusal) = self.unresolved_route_response(request) + { + *response = refusal; + let pass = crate::scan::pass(request); + self.response_pass(request, response, pass).await; + return; + } // The HTTPS redirect renders here: the `301` + `Location` shape. // A guard refusal dispatches Rocket's error catchers, and Rocket // allows user catchers only for `400`-`599`, so the `301` always @@ -1232,6 +1388,15 @@ impl Fairing for GuardFairing { let pass = crate::scan::pass(request); + // A route without a guard argument never runs the guard-side usage + // rules; the reference runs them for every request the pipeline + // passed, so they run here (once: a guard that ran them set the flag). + if let Some(engine) = request.rocket().state::() + && crate::scan::enforced_verdict(request).is_none() + { + let _ = crate::scan::usage_rules_verdict(engine, 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 @@ -1460,6 +1625,70 @@ impl GuardFairing { ) } + /// The reference `RouteConfigCheck._handle_unresolved_route`: the + /// `route_unresolved` event, the `on_block` hook, and the `500 Route + /// resolution failed` replacement response unless `passive_mode` (log + /// only, `None`). + fn unresolved_route_response(&self, request: &Request<'_>) -> Option> { + use guard_core_engine::route_config::{ + UNRESOLVED_ROUTE_CHECK_NAME, UNRESOLVED_ROUTE_MESSAGE, UNRESOLVED_ROUTE_REASON, + UNRESOLVED_ROUTE_STATUS, + }; + let ip = client_ip_string(crate::scan::client_ip(request)); + let sensitive = self + .observability + .as_ref() + .map(|observability| observability.sensitive.clone()) + .unwrap_or_default(); + let path = request.uri().path().as_str(); + if let Some(bus) = &self.events { + let mut event = guard_core_rs::events::SecurityEvent::new( + guard_core_rs::event_types::EVENT_ROUTE_UNRESOLVED, + &ip, + if self.passive_mode { + "logged_only" + } else { + "request_blocked" + }, + UNRESOLVED_ROUTE_REASON, + guard_core_rs::events::MIDDLEWARE_HANDLER_NAME, + ); + event.endpoint = Some(guard_core_rs::redact::redact_url_for_display( + path, &sensitive, + )); + event.method = Some(request.method().as_str().to_owned()); + bus.send_event(&event); + } + let observation = crate::scan::request_observation(request); + let payload = guard_core_rs::responses::build_block_payload( + UNRESOLVED_ROUTE_CHECK_NAME, + UNRESOLVED_ROUTE_REASON, + "", + self.passive_mode, + &ip, + observation.url.as_deref().unwrap_or("/"), + observation.method.as_deref().unwrap_or(""), + Some(UNRESOLVED_ROUTE_STATUS), + &sensitive, + ); + guard_core_rs::responses::fire_block_hook(self.on_block.as_ref(), &payload); + if self.passive_mode { + return None; + } + let body = guard_core_rs::responses::resolve_error_body( + &self.custom_error_responses, + UNRESOLVED_ROUTE_STATUS, + UNRESOLVED_ROUTE_MESSAGE, + ); + Some( + Response::build() + .status(Status::InternalServerError) + .header(rocket::http::ContentType::Plain) + .sized_body(body.len(), std::io::Cursor::new(body.into_bytes())) + .finalize(), + ) + } + /// The `security_bypass` event of an all-checks-bypass route (the /// reference `handle_security_bypass`). fn emit_security_bypass_event( @@ -1565,7 +1794,9 @@ impl GuardFairing { engine: &GuardEngine, request: &Request<'_>, client_ip: Option, + live: Option<&Arc>, ) -> Verdict { + let live = Live::of(self, live); if let Some(verdict) = crate::scan::bans_decision(engine, request) { return verdict; } @@ -1573,7 +1804,7 @@ impl GuardFairing { return verdict; } let gate = crate::scan::gate_decision(request); - if let Some(stage) = &self.geo_blocking + if let Some(stage) = live.geo && let Some(decision) = stage.decide(client_ip, gate) { return crate::scan::stage_block( @@ -1582,7 +1813,7 @@ impl GuardFairing { stage_answer_body(&decision.answer), ); } - if let Some(stage) = &self.cloud_provider + if let Some(stage) = live.cloud && let Some(decision) = stage.decide(client_ip, gate) { return crate::scan::stage_block( @@ -1613,6 +1844,9 @@ impl GuardFairing { .state::() .unwrap_or(&self.engine); let _ = &self.engine; + // The stages a dynamic rule can change, read once for this request. + let live_stages = engine.live_now(); + let live = Live::of(self, live_stages.as_ref()); // The client identity every stage runs under: the connecting peer, // `X-Forwarded-For` honored only through the trusted proxies @@ -1720,7 +1954,7 @@ impl GuardFairing { // `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); + return self.evaluate_scoped(engine, request, client_ip, live_stages.as_ref()); } if let Some(size) = route @@ -1730,36 +1964,6 @@ impl GuardFairing { crate::scan::stash_route_body_cap(request, size); } - // The reference `process_usage_rules`: the route's usage and - // frequency behavior rules track the request observation and a - // crossed threshold dispatches the rule's action (a `ban` lands in - // the shared ban manager and renders the banned shape). The - // behavioral processor runs before the check pipeline (the - // reference dispatches it from the middleware's request pass). - let route_rules: &[guard_core_engine::behavior::BehaviorRule] = - route.map_or(&[], |route| &route.behavior_rules); - if !route_rules.is_empty() - && let Some(processor) = &self.response_processor - { - let endpoint_id = format!( - "{}:{}", - request.method().as_str(), - request.uri().path().as_str() - ); - let actions = processor.process_usage_rules( - &endpoint_id, - &client_ip_string(client_ip), - route_rules, - std::time::SystemTime::now(), - ); - if actions - .iter() - .any(guard_core_engine::behavior::BehaviorAction::is_ban) - { - return Verdict::ActivityBanned; - } - } - // The reference `_check_route_ip_access` (inside the `ip_security` // block, before the global lists): the route's `ip_whitelist` / // `ip_blacklist` decide first - a configured whitelist takes over @@ -1836,7 +2040,7 @@ impl GuardFairing { // 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 { + if let Some(stage) = live.emergency { let ip = client_ip; let ip_string = ip.map_or_else(String::new, |addr| addr.to_string()); if let Some(answer) = stage.decide( @@ -2134,7 +2338,7 @@ impl GuardFairing { // toggle, the `detection_enabled` resolution). let metadata = if bypassed("penetration") || !guard_core_engine::detection_exclusions::detection_enabled( - self.penetration_detection_enabled, + live.penetration, route.map(|route| route.enable_suspicious_detection), ) { None @@ -2160,9 +2364,7 @@ impl GuardFairing { // `_log_country_check_result`): the non-block verdicts ride // `log_country_check_level`, blocks ride `log_suspicious_level` // (compose-only, like the request-logging stage). - if let (Some(observability), Some(stage)) = - (self.observability.as_ref(), self.geo_blocking.as_ref()) - { + if let (Some(observability), Some(stage)) = (self.observability.as_ref(), live.geo) { let _ = stage.country_check_log( ip, observability.log_suspicious_level, @@ -2174,7 +2376,7 @@ impl GuardFairing { // country whitelist skips it too (`skip_countries`). if !bypassed("ip") && !skip_global_countries - && let Some(stage) = &self.geo_blocking + && let Some(stage) = live.geo && let Some(decision) = stage.decide(ip, gate) { return crate::scan::stage_block( @@ -2204,7 +2406,7 @@ impl GuardFairing { }); match route_selectors { Some(Ok(selectors)) => { - if let Some(stage) = &self.cloud_provider + if let Some(stage) = live.cloud && let Some(decision) = stage.decide_route(ip, gate, &selectors) { return crate::scan::stage_block( @@ -2216,7 +2418,7 @@ impl GuardFairing { } Some(Err(_)) => return Verdict::Failed, None => { - if let Some(stage) = &self.cloud_provider + if let Some(stage) = live.cloud && let Some(decision) = stage.decide(ip, gate) { return crate::scan::stage_block( @@ -2260,7 +2462,7 @@ impl GuardFairing { Some(Err(_)) => return Verdict::Failed, _ => {} } - if let Some(stage) = &self.user_agent + if let Some(stage) = live.user_agent && let Some(answer) = stage.decide( ip, gate, @@ -2303,6 +2505,38 @@ impl GuardFairing { } } +/// The stages one request reads: the live ones when a dynamic rule can +/// change them (a fairing built from a `SecurityConfig`), the static ones of +/// the builder API otherwise. +struct Live<'a> { + geo: Option<&'a GeoStage>, + cloud: Option<&'a CloudProviderStage>, + user_agent: Option<&'a UserAgentStage>, + emergency: Option<&'a EmergencyModeStage>, + penetration: bool, +} + +impl<'a> Live<'a> { + fn of(fairing: &'a GuardFairing, live: Option<&'a Arc>) -> Self { + match live { + Some(live) => Self { + geo: live.stages.geo.as_ref(), + cloud: live.stages.cloud.as_ref(), + user_agent: live.stages.user_agent.as_ref(), + emergency: live.stages.emergency.as_ref(), + penetration: live.stages.penetration_detection, + }, + None => Self { + geo: fairing.geo_blocking.as_ref(), + cloud: fairing.cloud_provider.as_ref(), + user_agent: fairing.user_agent.as_ref(), + emergency: fairing.emergency_mode.as_ref(), + penetration: fairing.penetration_detection_enabled, + }, + } + } +} + /// The resolved answer body of a stage answer (the custom-error override /// already travels inside `custom_body`). fn stage_answer_body(answer: &guard_core_rs::tower::StageResponse) -> &str { @@ -5312,8 +5546,15 @@ mod stateful_tests { whitelist: Some(vec![String::from("not-an-ip")]), ..crate::SecurityConfig::default() }; + // The validator names the bad list first. let error = GuardFairing::from_security_config(&config).unwrap_err(); - assert!(matches!(error, crate::GuardConfigError::IpGate(_))); + assert!(matches!(error, crate::GuardConfigError::Config(_))); + // The stage constructor stays the second line of defence for an + // unvalidated config. + let error = crate::Wiring::build_unvalidated(&config, &crate::WiringOptions::default()) + .map(|_| ()) + .unwrap_err(); + assert!(matches!(error, crate::WiringError::IpGate(_))); } #[tokio::test] @@ -5516,8 +5757,10 @@ mod stateful_tests { detection_max_scan_seconds: 0.0, ..crate::SecurityConfig::default() }; - let client = - tracked_config(GuardFairing::from_security_config(&spent).expect("valid")).await; + // (Below the validated range, so the unvalidated wiring seam builds it.) + let wiring = crate::Wiring::build_unvalidated(&spent, &crate::WiringOptions::default()) + .expect("wired"); + let client = tracked_config(GuardFairing::from_wiring(wiring)).await; let response = client .get("/hello") .remote(peer_ip([203, 0, 113, 9])) diff --git a/src/guards.rs b/src/guards.rs index 50b8a0c..1726af5 100644 --- a/src/guards.rs +++ b/src/guards.rs @@ -1,7 +1,10 @@ //! The enforcement guards: `BlockGuard` for requests without a body, //! `GuardBody` for requests with one. -use crate::scan::{GuardEngine, Verdict, enforce_body_finding, metadata_verdict, record_enforced}; +use crate::scan::{ + GuardEngine, Verdict, enforce_body_finding, metadata_verdict, record_enforced, + usage_rules_verdict, +}; use rocket::data::{Data, FromData, Outcome as DataOutcome, ToByteUnit}; use rocket::http::Status; use rocket::request::{FromRequest, Outcome, Request}; @@ -50,7 +53,20 @@ impl<'r> FromRequest<'r> for BlockGuard { async fn from_request(request: &'r Request<'_>) -> Outcome { match metadata_verdict(request) { - Some(Verdict::Clean) => Outcome::Success(Self { _private: () }), + Some(Verdict::Clean) => { + // The pipeline passed: the route usage rules observe the request now. + let banned = request + .rocket() + .state::() + .and_then(|engine| usage_rules_verdict(engine, request)); + match banned { + Some(verdict) => { + record_enforced(request, verdict); + Outcome::Error((verdict.status(), ())) + } + None => Outcome::Success(Self { _private: () }), + } + } verdict => { let enforced = verdict.unwrap_or(Verdict::Failed); record_enforced(request, enforced); @@ -128,6 +144,7 @@ impl<'r> FromRequest<'r> for GuardExclusionScoped { /// | IP gate denial, live ban | `403 Forbidden` | `Forbidden` / `IP address banned` | /// | Rate limit crossed | `429 Too Many Requests` (+ `Retry-After: `) | `Too many requests` | /// | Body exceeds the cap | `413 Payload Too Large` | `Payload too large` | +/// | Body not fully read within `body_read_timeout` | `408 Request Timeout` | `Request body read timed out` | /// | Body read error or engine panic | `500 Internal Server Error` | `Security check failed` | #[derive(Debug)] pub struct GuardBody { @@ -176,7 +193,16 @@ impl<'r> FromData<'r> for GuardBody { // The route's `max_request_size` replaces the global cap for the // route (stashed by the fairing's `on_request` pass). let cap = crate::scan::route_body_cap(request).unwrap_or(engine.body_cap); - let capped = data.open((cap as u64).bytes()).into_bytes().await; + // The reference `body_read_timeout`: a body that does not arrive in + // time is refused `408`, never forwarded unread (fail-secure). + let read = data.open((cap as u64).bytes()).into_bytes(); + let capped = match engine.body_read_timeout { + Some(limit) => match rocket::tokio::time::timeout(limit, read).await { + Ok(capped) => capped, + Err(_) => return refused(request, Verdict::BodyTimeout, Status::RequestTimeout), + }, + None => read.await, + }; let bytes = match capped { Ok(capped) if capped.is_complete() => capped.into_inner(), // The cap was hit: the engine would only ever see a truncated @@ -212,6 +238,12 @@ impl<'r> FromData<'r> for GuardBody { })) .unwrap_or(Verdict::Failed); + // The pipeline and the body scan passed: the route usage rules observe the request now. + let verdict = if verdict == Verdict::Clean { + usage_rules_verdict(engine, request).unwrap_or(Verdict::Clean) + } else { + verdict + }; match verdict { Verdict::Clean => DataOutcome::Success(Self { bytes }), verdict => refused(request, verdict, verdict.status()), diff --git a/src/lib.rs b/src/lib.rs index a2e5787..c8c6dc3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,7 +26,7 @@ //! 1. **Attach [`GuardFairing`]** (`Kind::Ignite | Kind::Request | //! Kind::Response`). Its `on_request` scans the path, query, and header //! views and stashes the verdict in request-local state. Its `on_ignite` -//! registers the `400`/`403`/`413`/`429`/`500` catchers that render +//! registers the `400`/`403`/`408`/`413`/`429`/`500` catchers that render //! refusals as the //! ecosystem's plain-text error shape (skipping any status the application //! already registered a catcher for, because Rocket treats same-code @@ -165,8 +165,24 @@ //! 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. +//! dynamic-rule poll loop. The config is validated at construction +//! ([`GuardConfigError::Config`]) and the stages a dynamic rule can change +//! are read from the live config on every request. With the `agent` feature +//! the agent is built from the `agent_*` fields when none is injected +//! ([`GuardConfigError::Agent`] under `agent_strict`); the `redis` feature +//! forwards `guard-core-rs/redis`. +//! +//! ## Strict routes, request-body timeout and usage rules +//! +//! `route_resolution_strict` is decided in `on_response`: Rocket keeps the +//! last route it tried on the request, so `Request::route()` is `None` only +//! when the router matched nothing, no handler ran, and the `404` is replaced +//! with `500 Route resolution failed` (log-only under `passive_mode`). +//! [`GuardBody`] bounds its body read with `body_read_timeout` and answers +//! `408 Request body read timed out` (Rocket itself peeks the first 14 bytes +//! before routing). The route usage rules run after the pipeline passed, from +//! the guards on success or once in `on_response` for an unguarded route; a +//! request the pipeline refuses is never counted. //! //! ## Client resolution //! @@ -229,6 +245,8 @@ //! hook still prints. `panic = "abort"` in the release profile disables that //! recovery, because the process dies before the guard can respond. +#[cfg(feature = "agent")] +mod agent; mod fairing; mod guards; mod response; @@ -275,6 +293,7 @@ pub use guard_core_engine::security_config::{ 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::live::{LiveCell, LiveContext, LiveStages}; pub use guard_core_rs::metrics::MetricsCollector; pub use guard_core_rs::responses::{BlockPayload, CustomErrorResponses, OnBlockHook}; pub use guard_core_rs::tower::{ @@ -370,6 +389,12 @@ pub enum GuardConfigError { /// Country rules without a resolver, or the `GeoIP` database could not /// be set up. Geo(String), + /// [`SecurityConfig::validate`] rejected the config (the reference + /// validates at construction). + Config(SecurityConfigError), + /// `enable_agent` with `agent_strict` and the agent could not be built + /// from the `agent_*` fields (the `agent` feature). + Agent(String), } impl std::fmt::Display for GuardConfigError { @@ -382,6 +407,8 @@ impl std::fmt::Display for GuardConfigError { Self::Cloud(error) => write!(f, "block_cloud_providers: {error}"), Self::Redis(message) => write!(f, "redis: {message}"), Self::Geo(message) => write!(f, "geo: {message}"), + Self::Config(error) => write!(f, "config: {}", error.problems.join("; ")), + Self::Agent(message) => write!(f, "agent: {message}"), } } } @@ -394,7 +421,8 @@ impl std::error::Error for GuardConfigError { Self::UserAgent(error) => Some(error), Self::Ban(error) => Some(error), Self::Cloud(error) => Some(error), - Self::Redis(_) | Self::Geo(_) => None, + Self::Config(error) => Some(error), + Self::Redis(_) | Self::Geo(_) | Self::Agent(_) => None, } } } @@ -433,6 +461,7 @@ impl From for GuardConfigError { WiringError::Cloud(error) => Self::Cloud(error), WiringError::Redis(message) => Self::Redis(message), WiringError::Geo(message) => Self::Geo(message), + WiringError::Config(error) => Self::Config(error), } } } @@ -494,6 +523,21 @@ mod tests { assert!(std::error::Error::source(&ban).is_some()); } + #[test] + fn the_config_is_validated_at_construction() { + let config = SecurityConfig { + auto_ban_threshold: 0, + ..SecurityConfig::default() + }; + let error = GuardFairing::from_security_config(&config).expect_err("rejected"); + assert!(matches!(error, GuardConfigError::Config(_))); + assert!(error.to_string().contains("auto_ban_threshold"), "{error}"); + assert!(std::error::Error::source(&error).is_some()); + let agent = GuardConfigError::Agent("no runtime".to_owned()); + assert_eq!(agent.to_string(), "agent: no runtime"); + assert!(std::error::Error::source(&agent).is_none()); + } + #[test] fn from_security_config_exclude_paths_round_trip_through_the_accessor() { let config = SecurityConfig { diff --git a/src/response.rs b/src/response.rs index 0c5d7d3..125ee8e 100644 --- a/src/response.rs +++ b/src/response.rs @@ -42,6 +42,10 @@ pub const RATE_LIMITED_MESSAGE: &str = "Too many requests"; /// Detail message carried by the `413 Payload Too Large` response. pub const OVERSIZE_MESSAGE: &str = "Payload too large"; +/// Detail message carried by the `408 Request Timeout` response (the +/// request body did not arrive within `body_read_timeout`). +pub const BODY_TIMEOUT_MESSAGE: &str = guard_core_engine::request_limits::BODY_READ_TIMEOUT_MESSAGE; + /// Detail message carried by the fail-secure `500 Internal Server Error` /// response. pub const FAILURE_MESSAGE: &str = "Security check failed"; @@ -62,6 +66,7 @@ pub const EMERGENCY_MESSAGE: &str = "Service temporarily unavailable"; const DEFAULT_400: &str = "400 Bad Request"; const DEFAULT_401: &str = "401 Unauthorized"; const DEFAULT_403: &str = "403 Forbidden"; +const DEFAULT_408: &str = "408 Request Timeout"; const DEFAULT_429: &str = "429 Too Many Requests"; const DEFAULT_500: &str = "500 Internal Server Error"; const DEFAULT_503: &str = "503 Service Unavailable"; @@ -85,6 +90,7 @@ pub fn guard_catchers() -> Vec { Catcher::new(400, blocked), Catcher::new(401, unauthorized), Catcher::new(403, forbidden), + Catcher::new(408, body_timeout), Catcher::new(413, oversize), Catcher::new(429, rate_limited), Catcher::new(500, failure), @@ -151,6 +157,17 @@ fn forbidden<'r>(status: Status, request: &'r Request<'_>) -> BoxFuture<'r> { finish(status, message, None) } +/// `408` catcher: the body-timeout body when `GuardBody` refused this +/// request, a minimal default otherwise. +fn body_timeout<'r>(status: Status, request: &'r Request<'_>) -> BoxFuture<'r> { + let message = if enforced_verdict(request) == Some(Verdict::BodyTimeout) { + BODY_TIMEOUT_MESSAGE + } else { + DEFAULT_408 + }; + finish(status, message, None) +} + /// `413` catcher: always the oversize body. /// /// Unscoped on purpose: a `413` error outcome has one meaning whichever @@ -287,6 +304,7 @@ pub(crate) fn verdict_response_plain(verdict: Verdict) -> Response<'static> { DEFAULT_403, ), Verdict::HttpsRedirect => plain_response(Status::MovedPermanently, ""), + Verdict::BodyTimeout => plain_response(Status::RequestTimeout, BODY_TIMEOUT_MESSAGE), Verdict::Clean | Verdict::Failed => { plain_response(Status::InternalServerError, FAILURE_MESSAGE) } @@ -362,6 +380,8 @@ mod tests { assert_eq!(response.status(), Status::BadRequest); let response = verdict_response_plain(Verdict::AuthRequired); assert_eq!(response.status(), Status::Unauthorized); + let response = verdict_response_plain(Verdict::BodyTimeout); + assert_eq!(response.status(), Status::RequestTimeout); let response = verdict_response_plain(Verdict::CustomBlock(418)); assert_eq!( response.status(), diff --git a/src/scan.rs b/src/scan.rs index e3f3fb0..dd387fe 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -76,6 +76,9 @@ pub(crate) enum Verdict { /// Check 3: the HTTPS-enforcement stage redirected (the `301` target /// rides the redirect-target slot). HttpsRedirect, + /// The request body did not arrive within `body_read_timeout` (the + /// `408` shape); the unread body is never forwarded. + BodyTimeout, /// The engine panicked; fail secure. Failed, } @@ -98,6 +101,7 @@ impl Verdict { None => Status::InternalServerError, }, Verdict::HttpsRedirect => Status::MovedPermanently, + Verdict::BodyTimeout => Status::RequestTimeout, Verdict::Failed => Status::InternalServerError, } } @@ -119,6 +123,13 @@ pub(crate) struct Metadata(pub(crate) Option); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct Enforced(pub(crate) Option); +/// What the live config rebuilds for the fairing: the engine stages a +/// dynamic rule can change plus the rate-limit stage built over them. +pub(crate) struct RocketLive { + pub(crate) stages: Arc, + pub(crate) rate_stage: Arc, +} + /// The engine entry point, stored once per application in managed state. /// /// [`crate::GuardFairing::on_ignite`] manages it (unless the application @@ -161,6 +172,13 @@ pub(crate) struct GuardEngine { pub(crate) on_block: Option, /// The status-to-body overrides (for the adapter-rendered `400`). pub(crate) custom_error_responses: crate::CustomErrorResponses, + /// The live-config cell (a fairing built from a `SecurityConfig`): the + /// stages a dynamic rule can change, rebuilt when a rule lands. + pub(crate) live: Option>>, + /// `body_read_timeout` for the request-body read of [`crate::GuardBody`]. + pub(crate) body_read_timeout: Option, + /// The response processor the route usage rules dispatch through. + pub(crate) usage_processor: Option>, } impl GuardEngine { @@ -183,9 +201,35 @@ impl GuardEngine { observability: None, on_block: None, custom_error_responses: crate::CustomErrorResponses::new(), + live: None, + body_read_timeout: None, + usage_processor: None, } } + /// The live stages for the request in flight (`None` for a fairing + /// built through the builder API, whose stages are static). + pub(crate) fn live_now(&self) -> Option> { + self.live.as_ref().map(|cell| cell.get()) + } + + /// The rate-limit stage in force: the live one when a dynamic rule can + /// change it, the static one otherwise. + pub(crate) fn stage_now(&self) -> Option> { + match self.live_now() { + Some(live) => Some(Arc::clone(&live.rate_stage)), + None => self.stage.clone(), + } + } + + /// The global scan toggle in force (live or static). + pub(crate) fn penetration_now(&self) -> bool { + self.live_now() + .map_or(self.penetration_detection_enabled, |live| { + live.stages.penetration_detection + }) + } + /// Resolve the per-request detection-exclusion surface: the global /// config (the route-exclusion resolver supplies the per-route half) /// merging through the engine's `resolve`. @@ -284,7 +328,7 @@ impl GuardEngine { // The global `enable_penetration_detection` toggle: the body // phase skips the scan like the metadata phase (the request // proceeds clean). - if !self.penetration_detection_enabled { + if !self.penetration_now() { return None; } let text = String::from_utf8_lossy(bytes); @@ -465,7 +509,7 @@ pub(crate) fn stage_decision( if carrier_bypasses(route_carrier(request).as_deref(), "rate_limit") { return None; } - let stage = engine.stage.as_ref()?; + let stage = engine.stage_now()?; let finding = finding.map(|verdict| guard_core_rs::tower::ThreatFinding { is_threat: true, categories: verdict.categories.clone(), @@ -493,7 +537,7 @@ pub(crate) fn bans_decision(engine: &GuardEngine, request: &Request<'_>) -> Opti if carrier_bypasses(route_carrier(request).as_deref(), "ip_ban") { return None; } - let stage = engine.stage.as_ref()?; + let stage = engine.stage_now()?; let decision = stage.decide_bans_observed(client_ip(request), Some(&request_observation(request)))?; let (verdict, custom) = verdict_from_stage(&decision); @@ -524,8 +568,7 @@ pub(crate) fn metadata_threat_verdict( verdict: &RequestScanVerdict, ) -> Verdict { let passive = engine - .stage - .as_ref() + .stage_now() .is_some_and(|stage| stage.config().passive_mode); if passive { return Verdict::Clean; @@ -570,7 +613,7 @@ pub(crate) fn enforce_body_finding( request: &Request<'_>, verdict: &RequestScanVerdict, ) -> Verdict { - let Some(stage) = engine.stage.as_ref() else { + let Some(stage) = engine.stage_now() else { return Verdict::Threat; }; let finding = guard_core_rs::tower::ThreatFinding { @@ -630,6 +673,51 @@ pub(crate) fn preflight_answer( .clone() } +/// Request-local flag: the route usage rules already observed this request. +struct UsageRan(std::sync::atomic::AtomicBool); + +/// The reference `process_usage_rules`, run once per request AFTER the whole +/// pipeline passed (the reference dispatches it between the pipeline and +/// `call_next`): the route's usage and frequency behavior rules track the +/// request observation and a crossed threshold dispatches the rule's action. +/// `Some(ActivityBanned)` when a `ban` landed. A request the pipeline (or a +/// route guard) refused, an excluded or bypassed request, a preflight and an +/// unattributed request (no client ip) never reach it. Rocket has no single +/// "just before the handler" point, so the route guards call it on success +/// and `on_response` calls it for a route without a guard (once: the flag; +/// it must not read the enforced slot, which would pin it empty). +pub(crate) fn usage_rules_verdict(engine: &GuardEngine, request: &Request<'_>) -> Option { + let processor = engine.usage_processor.as_ref()?; + let carrier = route_carrier(request)?; + let ip = client_ip(request)?; + if carrier.behavior_rules.is_empty() + || pass(request) != Pass::Full + || metadata_verdict(request) != Some(Verdict::Clean) + || preflight_answer(request).is_some() + || request + .local_cache(|| UsageRan(std::sync::atomic::AtomicBool::new(false))) + .0 + .swap(true, std::sync::atomic::Ordering::AcqRel) + { + return None; + } + let endpoint_id = format!( + "{}:{}", + request.method().as_str(), + request.uri().path().as_str() + ); + processor + .process_usage_rules( + &endpoint_id, + &ip.to_string(), + &carrier.behavior_rules, + std::time::SystemTime::now(), + ) + .iter() + .any(guard_core_engine::behavior::BehaviorAction::is_ban) + .then_some(Verdict::ActivityBanned) +} + /// Request-local slot for the per-route rate-limit tier override (the /// Rocket counterpart of the reference's `request.state.route_config`). /// Interior-mutable because Rocket's request-local cache hands out shared @@ -832,6 +920,7 @@ mod tests { assert_eq!(Verdict::Banned.status(), Status::Forbidden); assert_eq!(Verdict::ActivityBanned.status(), Status::Forbidden); assert_eq!(Verdict::RateLimited(30).status(), Status::TooManyRequests); + assert_eq!(Verdict::BodyTimeout.status(), Status::RequestTimeout); assert_eq!( Verdict::RedisUnavailable.status(), Status::ServiceUnavailable diff --git a/tests/integration.rs b/tests/integration.rs index d26d688..9c6bfb8 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -350,6 +350,11 @@ fn unauthorized_route() -> Status { Status::Unauthorized } +#[get("/request-timeout")] +fn request_timeout_route() -> Status { + Status::RequestTimeout +} + #[tokio::test] async fn application_error_statuses_keep_the_minimal_default_bodies() { // A `4xx`/`5xx` the application raises itself (clean request, no guard @@ -367,13 +372,14 @@ async fn application_error_statuses_keep_the_minimal_default_bodies() { service_unavailable_route, server_error_route, unauthorized_route, + request_timeout_route, ], ), ) .await .expect("valid rocket"); - let expectations: [(&str, Status, &str); 6] = [ + let expectations: [(&str, Status, &str); 7] = [ ("/bad-request", Status::BadRequest, "400 Bad Request"), ("/forbidden", Status::Forbidden, "403 Forbidden"), ( @@ -392,6 +398,11 @@ async fn application_error_statuses_keep_the_minimal_default_bodies() { "500 Internal Server Error", ), ("/unauthorized", Status::Unauthorized, "401 Unauthorized"), + ( + "/request-timeout", + Status::RequestTimeout, + "408 Request Timeout", + ), ]; for (path, status, body) in expectations { let response = client.get(path).dispatch().await; diff --git a/tests/parity3.rs b/tests/parity3.rs new file mode 100644 index 0000000..618ce61 --- /dev/null +++ b/tests/parity3.rs @@ -0,0 +1,521 @@ +//! Reference parity, third wave: the route usage rules run after the whole +//! pipeline, the live config reaches the stages a dynamic rule can change, +//! `route_resolution_strict`, and the request-body `body_read_timeout`. +//! +//! Everything here drives the public surface only. + +use guard_core_engine::behavior::{BehaviorRule, BehaviorTracker}; +use guard_core_engine::geo::GeoIpHandler; +use guard_core_engine::route_config::{RouteConfig, RouteConfigResolver}; +use guard_core_engine::security_config::SecurityConfig; +use guard_core_rs::events::{SecurityEvent, SecurityEventBus}; +use guard_core_rs::process_response::ResponseProcessor; +use rocket::http::{ContentType, Header, Status}; +use rocket::local::asynchronous::{Client, LocalResponse}; +use rocket::{get, post, routes}; +use rocket_guard_rs::{BlockGuard, GuardBody, GuardFairing, IpBanManager, OnBlockHook, Wiring}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::{Arc, Mutex, RwLock}; + +#[get("/hello")] +fn hello(_guard: BlockGuard) -> &'static str { + "ok" +} + +#[get("/open")] +fn open() -> &'static str { + "open" +} + +#[get("/forwarded")] +fn forwarded_scoped(_scoped: rocket_guard_rs::GuardExclusionScoped) -> &'static str { + "scoped" +} + +#[post("/post", data = "")] +fn post_body(body: GuardBody) -> Vec { + body.into_inner() +} + +/// A script injection in the query string of a guarded route. +const ATTACK: &str = "/hello?q=%3Cscript%3Ealert(1)%3C/script%3E"; + +async fn client(fairing: GuardFairing) -> Client { + Client::tracked( + rocket::build() + .attach(fairing) + .mount("/", routes![hello, open, post_body]), + ) + .await + .expect("valid rocket") +} + +fn peer(last: u8) -> SocketAddr { + SocketAddr::new(IpAddr::from([192, 0, 2, last]), 45_000) +} + +async fn status_of(response: LocalResponse<'_>) -> (Status, String) { + let status = response.status(); + (status, response.into_string().await.unwrap_or_default()) +} + +fn config() -> SecurityConfig { + SecurityConfig { + enable_redis: false, + ..SecurityConfig::default() + } +} + +// --- usage rules run after the pipeline --- + +fn usage_resolver(path: &'static str) -> RouteConfigResolver { + let route = Arc::new(RouteConfig { + behavior_rules: vec![BehaviorRule { + rule_type: String::from("usage"), + threshold: 2, + window: 60, + pattern: String::new(), + action: String::from("ban"), + ban_duration: Some(3600), + correlate_with_detection: false, + }], + ..RouteConfig::default() + }); + Arc::new(move |_method: &str, request_path: &str| { + (request_path == path).then(|| Arc::clone(&route)) + }) +} + +fn usage_processor(manager: &IpBanManager) -> ResponseProcessor { + ResponseProcessor::new( + None, + None, + Vec::new(), + Arc::new(Mutex::new(BehaviorTracker::new())), + manager.clone(), + true, + 262_144, + false, + ) +} + +fn usage_fairing(path: &'static str, manager: &IpBanManager) -> GuardFairing { + GuardFairing::with_defaults() + .with_response_processor(usage_processor(manager)) + .with_route_configs(usage_resolver(path)) +} + +#[tokio::test] +async fn usage_rules_do_not_count_a_request_the_pipeline_blocked() { + let manager = IpBanManager::new(); + let client = client(usage_fairing("/hello", &manager)).await; + // Three attacks the pipeline refuses: the reference never reaches + // `process_usage_rules` for them. + for _ in 0..3 { + let response = client.get(ATTACK).remote(peer(81)).dispatch().await; + assert_eq!(response.status(), Status::BadRequest); + } + // The first request that passes is the first observation (threshold 2). + let (status, body) = status_of(client.get("/hello").remote(peer(81)).dispatch().await).await; + assert_eq!((status, body.as_str()), (Status::Ok, "ok")); +} + +#[tokio::test] +async fn usage_rules_ban_at_the_threshold_for_a_guarded_route() { + let manager = IpBanManager::new(); + let client = client(usage_fairing("/hello", &manager)).await; + for _ in 0..2 { + let (status, _) = status_of(client.get("/hello").remote(peer(82)).dispatch().await).await; + assert_eq!(status, Status::Ok); + } + let (status, body) = status_of(client.get("/hello").remote(peer(82)).dispatch().await).await; + assert_eq!( + (status, body.as_str()), + (Status::Forbidden, "IP has been banned") + ); +} + +#[tokio::test] +async fn usage_rules_run_once_for_a_route_without_a_guard() { + let manager = IpBanManager::new(); + let client = client(usage_fairing("/open", &manager)).await; + for _ in 0..3 { + let (status, _) = status_of(client.get("/open").remote(peer(83)).dispatch().await).await; + assert_eq!(status, Status::Ok, "an unguarded route is never refused"); + } + // The rule fired exactly at the third observation (each request counts once). + assert!(manager.is_banned(IpAddr::from([192, 0, 2, 83]))); +} + +#[tokio::test] +async fn usage_rules_wait_for_the_body_scan() { + let manager = IpBanManager::new(); + let client = client(usage_fairing("/post", &manager)).await; + for _ in 0..3 { + let response = client + .post("/post") + .remote(peer(84)) + .header(ContentType::Plain) + .body("") + .dispatch() + .await; + assert_eq!( + response.status(), + Status::BadRequest, + "the body scan blocks" + ); + } + let response = client + .post("/post") + .remote(peer(84)) + .header(ContentType::Plain) + .body("hello") + .dispatch() + .await; + assert_eq!( + response.status(), + Status::Ok, + "blocked bodies were not counted" + ); +} + +#[tokio::test] +async fn usage_rules_skip_an_unattributed_request() { + let manager = IpBanManager::new(); + let client = client(usage_fairing("/hello", &manager)).await; + for _ in 0..4 { + let (status, _) = status_of(client.get("/hello").dispatch().await).await; + assert_eq!(status, Status::Ok); + } +} + +// --- live config --- + +struct UnitedStates; + +impl GeoIpHandler for UnitedStates { + fn get_country(&self, _ip: IpAddr) -> Option { + Some("US".to_owned()) + } +} + +async fn live_client( + change: impl FnOnce(&mut SecurityConfig), +) -> (Client, Arc>) { + let mut config = config(); + config.geo_ip_handler = Some(Arc::new(UnitedStates)); + change(&mut config); + let wiring = Wiring::from_security_config(&config).expect("wired"); + let live = Arc::clone(&wiring.live_config); + (client(GuardFairing::from_wiring(wiring)).await, live) +} + +fn rule(live: &Arc>, change: impl FnOnce(&mut SecurityConfig)) { + change(&mut live.write().expect("live config")); +} + +#[tokio::test] +async fn a_dynamic_rate_limit_rule_changes_the_live_limit_and_keeps_the_windows() { + let (client, live) = live_client(|config| { + config.enable_rate_limiting = true; + config.rate_limit = 2; + config.rate_limit_window = 60; + }) + .await; + for _ in 0..2 { + let (status, _) = status_of(client.get("/hello").remote(peer(91)).dispatch().await).await; + assert_eq!(status, Status::Ok); + } + let (status, _) = status_of(client.get("/hello").remote(peer(91)).dispatch().await).await; + assert_eq!(status, Status::TooManyRequests); + + // A rule raises the limit: the recorded window carries over. + rule(&live, |config| config.rate_limit = 100); + let (status, _) = status_of(client.get("/hello").remote(peer(91)).dispatch().await).await; + assert_eq!(status, Status::Ok); + + // A rule that switches the limiter off stops the 429s. + rule(&live, |config| { + config.rate_limit = 1; + config.enable_rate_limiting = false; + }); + let (status, _) = status_of(client.get("/hello").remote(peer(91)).dispatch().await).await; + assert_eq!(status, Status::Ok); +} + +async fn attack_status(client: &Client) -> Status { + client + .get(ATTACK) + .remote(peer(92)) + .dispatch() + .await + .status() +} + +#[tokio::test] +async fn dynamic_country_agent_emergency_and_detection_rules_take_effect() { + let (client, live) = live_client(|_| {}).await; + let (status, _) = status_of(client.get("/hello").remote(peer(92)).dispatch().await).await; + assert_eq!(status, Status::Ok); + + rule(&live, |config| { + config.blocked_countries = std::collections::BTreeSet::from(["US".to_owned()]); + }); + let (status, _) = status_of(client.get("/hello").remote(peer(92)).dispatch().await).await; + assert_eq!(status, Status::Forbidden); + rule(&live, |config| config.blocked_countries.clear()); + + rule(&live, |config| { + config.blocked_user_agents = vec!["badbot".to_owned()]; + }); + let response = client + .get("/hello") + .remote(peer(92)) + .header(Header::new("User-Agent", "badbot/1.0")) + .dispatch() + .await; + assert_eq!(response.status(), Status::Forbidden); + rule(&live, |config| config.blocked_user_agents.clear()); + + rule(&live, |config| config.emergency_mode = true); + let (status, _) = status_of(client.get("/hello").remote(peer(92)).dispatch().await).await; + assert_eq!(status, Status::ServiceUnavailable); + rule(&live, |config| config.emergency_mode = false); + + assert_eq!(attack_status(&client).await, Status::BadRequest); + rule(&live, |config| config.enable_penetration_detection = false); + assert_eq!(attack_status(&client).await, Status::Ok); + // The body phase reads the same toggle. + let response = client + .post("/post") + .remote(peer(92)) + .header(ContentType::Plain) + .body("") + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); +} + +#[tokio::test] +async fn a_dynamic_cloud_provider_rule_builds_the_stage() { + let (client, live) = live_client(|_| {}).await; + rule(&live, |config| { + config.block_cloud_providers = Some(std::collections::BTreeSet::from(["AWS".to_owned()])); + }); + // The range table is empty until a refresh fills it, so nothing blocks, + // but the request runs through the rebuilt stage. + let (status, _) = status_of(client.get("/hello").remote(peer(93)).dispatch().await).await; + assert_eq!(status, Status::Ok); +} + +// --- route_resolution_strict --- + +fn recording_bus() -> (Arc, Arc>>) { + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + let bus = SecurityEventBus::new(true).on_event(Arc::new(move |event: &SecurityEvent| { + // The response pass also reports its security headers. + if event.event_type == "route_unresolved" { + sink.lock() + .expect("sink") + .push(format!("{}:{}", event.event_type, event.action_taken)); + } + })); + (Arc::new(bus), seen) +} + +fn strict_config() -> SecurityConfig { + SecurityConfig { + route_resolution_strict: true, + ..config() + } +} + +#[tokio::test] +async fn the_strict_route_resolution_answers_an_unmatched_request() { + let fairing = GuardFairing::from_security_config(&strict_config()).expect("valid"); + let client = client(fairing).await; + let (status, body) = status_of(client.get("/nowhere").remote(peer(61)).dispatch().await).await; + assert_eq!( + (status, body.as_str()), + (Status::InternalServerError, "Route resolution failed") + ); + // A matched route passes, strict or not. + let (status, _) = status_of(client.get("/hello").remote(peer(61)).dispatch().await).await; + assert_eq!(status, Status::Ok); + + // The builder installs the same switch on a fairing built from a config + // that left it off. + let built = GuardFairing::from_security_config(&config()) + .expect("valid") + .with_route_resolution_strict(true); + let built = self::client(built).await; + let (status, _) = status_of(built.get("/nowhere").remote(peer(61)).dispatch().await).await; + assert_eq!(status, Status::InternalServerError); + + // Without strict mode the request keeps Rocket's own `404`. + let lax = GuardFairing::from_security_config(&config()).expect("valid"); + let lax = self::client(lax).await; + let (status, _) = status_of(lax.get("/nowhere").remote(peer(61)).dispatch().await).await; + assert_eq!(status, Status::NotFound); +} + +#[tokio::test] +async fn a_route_that_matched_and_forwarded_is_not_unresolved() { + // The guard forwards a request that is not excluded, so Rocket answers `404` + // from its catcher, but a route did match. + let fairing = GuardFairing::from_security_config(&strict_config()).expect("valid"); + let client = Client::tracked( + rocket::build() + .attach(fairing) + .mount("/", routes![forwarded_scoped]), + ) + .await + .expect("valid rocket"); + let (status, _) = status_of(client.get("/forwarded").remote(peer(62)).dispatch().await).await; + assert_eq!( + status, + Status::NotFound, + "a matched route is not unresolved" + ); +} + +#[tokio::test] +async fn the_strict_route_resolution_honours_a_custom_error_body() { + let config = SecurityConfig { + custom_error_responses: [(500u16, "nope".to_owned())].into_iter().collect(), + ..strict_config() + }; + let client = client(GuardFairing::from_security_config(&config).expect("valid")).await; + let (status, body) = status_of(client.get("/nowhere").remote(peer(63)).dispatch().await).await; + assert_eq!( + (status, body.as_str()), + (Status::InternalServerError, "nope") + ); +} + +#[tokio::test] +async fn the_strict_route_resolution_is_log_only_under_passive_mode_and_emits_the_event() { + let (bus, events) = recording_bus(); + let blocks = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&blocks); + let hook: OnBlockHook = Arc::new(move |payload| { + sink.lock().expect("blocks").push(payload.status_code); + }); + let config = SecurityConfig { + passive_mode: true, + ..strict_config() + }; + let fairing = GuardFairing::from_security_config(&config) + .expect("valid") + .with_event_bus(bus) + .with_on_block(hook); + let client = client(fairing).await; + let (status, _) = status_of(client.get("/nowhere").remote(peer(64)).dispatch().await).await; + assert_eq!(status, Status::NotFound, "log only"); + assert_eq!( + events.lock().expect("events").as_slice(), + ["route_unresolved:logged_only"] + ); + assert_eq!(blocks.lock().expect("blocks").as_slice(), [Some(500)]); +} + +#[tokio::test] +async fn the_strict_route_resolution_emits_the_blocked_event_and_fires_on_block() { + let (bus, events) = recording_bus(); + let fairing = GuardFairing::from_security_config(&strict_config()) + .expect("valid") + .with_event_bus(bus); + let client = client(fairing).await; + let (status, _) = status_of(client.get("/nowhere").remote(peer(65)).dispatch().await).await; + assert_eq!(status, Status::InternalServerError); + assert_eq!( + events.lock().expect("events").as_slice(), + ["route_unresolved:request_blocked"] + ); +} + +// --- request body read timeout --- + +#[tokio::test] +async fn a_body_that_arrives_in_time_is_forwarded_whole() { + let config = SecurityConfig { + body_read_timeout: 5.0, + ..config() + }; + let client = client(GuardFairing::from_security_config(&config).expect("valid")).await; + let response = client + .post("/post") + .remote(peer(66)) + .header(ContentType::Plain) + .body("hello") + .dispatch() + .await; + assert_eq!(status_of(response).await, (Status::Ok, "hello".to_owned())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_stalled_request_body_is_answered_408_and_never_forwarded() { + use rocket::tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // The first scan of a process initializes the detection tables (seconds in a + // debug build); warm them so the timing below measures the body read only. + let warm = client(GuardFairing::from_security_config(&config()).expect("valid")).await; + let (status, _) = status_of(warm.get("/hello").remote(peer(67)).dispatch().await).await; + assert_eq!(status, Status::Ok); + + let port = std::net::TcpListener::bind("127.0.0.1:0") + .expect("free port") + .local_addr() + .expect("addr") + .port(); + let settings = rocket::Config { + port, + address: IpAddr::from([127, 0, 0, 1]), + log_level: rocket::config::LogLevel::Off, + ..rocket::Config::debug_default() + }; + let config = SecurityConfig { + body_read_timeout: 0.3, + ..config() + }; + let fairing = GuardFairing::from_security_config(&config).expect("valid"); + let ignited = rocket::custom(settings) + .attach(fairing) + .mount("/", routes![post_body]) + .ignite() + .await + .expect("ignite"); + let shutdown = ignited.shutdown(); + let server = rocket::tokio::spawn(ignited.launch()); + + let mut stream = loop { + match rocket::tokio::net::TcpStream::connect(("127.0.0.1", port)).await { + Ok(stream) => break stream, + Err(_) => rocket::tokio::time::sleep(std::time::Duration::from_millis(20)).await, + } + }; + // Declare 100 bytes, send 16, then stall. (Rocket itself waits for the first + // 14 bytes before it routes, so a stall below that never reaches the guard.) + stream + .write_all( + b"POST /post HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\nContent-Length: 100\r\nConnection: close\r\n\r\nhello, stalled!!", + ) + .await + .expect("write"); + let mut answer = Vec::new(); + let read = rocket::tokio::time::timeout( + std::time::Duration::from_secs(10), + stream.read_to_end(&mut answer), + ) + .await; + shutdown.notify(); + let _ = server.await; + assert!( + read.is_ok(), + "the server answered before the client gave up" + ); + let text = String::from_utf8_lossy(&answer); + assert!(text.starts_with("HTTP/1.1 408"), "{text}"); + assert!(text.ends_with("Request body read timed out"), "{text}"); +} diff --git a/tests/parity_api.rs b/tests/parity_api.rs index a436290..92ee316 100644 --- a/tests/parity_api.rs +++ b/tests/parity_api.rs @@ -176,6 +176,8 @@ impl TelemetryHandler for Sink { } } +// The agent tests build through the unvalidated wiring seam: the config enables the +// agent without a key and polls rules below the validated interval. fn agent_config() -> SecurityConfig { SecurityConfig { enable_agent: true, @@ -204,14 +206,16 @@ fn agent_stats_is_disabled_without_an_agent() { #[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"); + let fairing = GuardFairing::from_wiring( + Wiring::build_unvalidated( + &agent_config(), + &WiringOptions { + agent: Some(Arc::clone(&sink) as Arc), + logfire_client: None, + }, + ) + .expect("wired"), + ); assert_eq!( fairing.agent_stats(), AgentStats { @@ -240,14 +244,16 @@ fn a_degraded_agent_start_shows_in_the_stats() { 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"); + let fairing = GuardFairing::from_wiring( + Wiring::build_unvalidated( + &agent_config(), + &WiringOptions { + agent: Some(sink as Arc), + logfire_client: None, + }, + ) + .expect("wired"), + ); fairing.telemetry().expect("telemetry").start(); assert_eq!( fairing.agent_stats(), @@ -266,14 +272,16 @@ fn the_dynamic_rule_poll_loop_is_host_started() { 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"); + let fairing = GuardFairing::from_wiring( + Wiring::build_unvalidated( + &config, + &WiringOptions { + agent: Some(Arc::clone(&sink) as Arc), + logfire_client: None, + }, + ) + .expect("wired"), + ); assert_eq!( sink.polls.load(Ordering::SeqCst), 0, @@ -544,7 +552,7 @@ async fn the_wiring_parts_install_one_by_one_on_a_bare_fairing() { block_cloud_providers: Some(["AWS".to_owned()].into_iter().collect()), ..agent_config() }; - let wiring = Wiring::from_security_config_with( + let wiring = Wiring::build_unvalidated( &config, &WiringOptions { agent: Some(Arc::new(Sink::default()) as Arc),