diff --git a/AGENTS.md b/AGENTS.md index 3270797..7bcd6fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Downstream consumers: `axum-guard-rs` re-exports this crate's `GuardLayer`/`with ## Boundary Rules - **No security logic in this crate.** Detection comes from `guard-core-engine`'s `detect`. Policy values (thresholds, caps) are configuration, not logic. -- **No framework dependencies.** This crate depends on `http`, `http-body`, `http-body-util`, `bytes`, `tower`, and the engine. Never add `axum`, `hyper`, `actix-web`, `rocket`, or a tokio runtime dependency to `[dependencies]`. tokio belongs in `[dev-dependencies]` for tests only. +- **No framework dependencies.** This crate depends on `http`, `http-body`, `http-body-util`, `bytes`, `tower`, and the engine. Never add `axum`, `hyper`, `actix-web`, `rocket`, or a tokio runtime dependency to `[dependencies]`. tokio belongs in `[dev-dependencies]` for tests only. The one exception is the optional `agent` feature, which pulls guard-agent-rs and tokio's `rt` feature to build the telemetry agent from the config (guard-agent-rs depends on the facade, so the facade cannot own that step); the default build has neither. `body_read_timeout` is enforced through a host-supplied timer (`with_body_timer`) for the same reason. - **No modifications to the engine.** Engine behavior changes are `guard-core-rs` PRs. - **Fail-secure, always.** A body read error, an engine panic, or a body over the cap must never result in an uninspected passthrough. Failures answer `500` (or `413` for oversize). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5e8ea..15bc493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,16 @@ Unreleased - `from_security_config_with(config, WiringOptions)` and `from_wiring(Wiring)`: the telemetry agent is injected through the facade's `TelemetryHandler`, which wires the event bus, the request metrics, `agent_stats()` (now reporting `enabled` and `degraded` from the sink), `spawn_dynamic_rules()` (host-started poll loop) and the cloud refresh (per-request `refresh_if_due` at the cloud position plus `refresh_cloud_ip_ranges`) - The `GuardExclusionScoped` request extension, the `security_bypass` event for all-checks-bypass routes, the global `auth_verifier` as the fallback verifier of a route that carries none (`with_auth_verifier`), and re-exports of `ResponseBits`, `RequestBits`, `SecurityMetric`, `SecurityEvent`, `CustomResponse`, `CloudRefreshScheduler`, `StoreError` and the wiring types - `GuardConfigError` gains `Cloud`, `Redis` and `Geo` variants +- Live dynamic rules: a layer built from a config reads the stages a rule can change (geo, cloud, blocked user agents, emergency, the penetration toggle, the rate limits and the ban knobs) from the engine's live cell on every request, so a rule-driven change takes effect (`LiveCell`, `LiveContext`, `LiveStages` are re-exported) +- `route_resolution_strict` through the `GuardRouteUnresolved` request extension (`500 Route resolution failed`, a logged `route_unresolved` event under `passive_mode`) and `GuardLayer::with_route_resolution_strict` +- `body_read_timeout` for the request body: `with_body_read_timeout` (installed from the config), `with_body_timer` (the host's timer, `SleepFn`) and the `408 Request body read timed out` answer +- The optional `agent` feature builds the telemetry agent from the `agent_*` config fields (`GuardConfigError::Agent` under `agent_strict`, `agent_stats().degraded` otherwise) and the `redis` feature forwards the facade's Redis backend +- `GuardConfigError::Config`: the config is validated (`SecurityConfig::validate`) before the pipeline is built ### Changed +- The route usage rules (`process_usage_rules`) run after the check pipeline, just before the request is forwarded, as the reference `dispatch` does (they ran first, so a blocked request counted toward a usage threshold), and only for an attributed request +- `from_security_config` rejects a config the validator refuses with `GuardConfigError::Config` instead of reaching the stage constructors (an invalid IP list entry is now reported there) - `exclude_paths` now uses the engine matcher (prefix on a `/` boundary, percent-decoded, dot-collapsed) and runs the scoped pipeline instead of bypassing everything: bans, the global IP, country and cloud rules and the rate limit still apply to an excluded request, which now also emits request metrics and gets the response pass without return rules - `from_security_config` goes through the facade `Wiring`: geo country rules, cloud providers, endpoint rate limits, rate-limit auto-ban, the custom request check, the auth verifier, trusted proxies, the config response modifier, `on_error`, `cors_expose_headers`, Redis stores and the telemetry knobs are now installed (previously dropped); country rules without a `geo_ip_handler` or `ipinfo_token` fail closed with `GuardConfigError::Geo`; the detection exclusions block is always installed - The response modifier runs even when no `ResponseProcessor` is installed, return rules evaluate the response body when the processor asks for it (the body is buffered and re-emitted, a read error answers the fail-secure `500`), and an all-checks-bypass route is forwarded with only the modifier applied (passive mode keeps it in the pipeline) diff --git a/Cargo.lock b/Cargo.lock index 3da0c2f..19b0fa5 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", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[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,23 @@ dependencies = [ "libc", ] +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -38,12 +90,41 @@ 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 = "bit-set" version = "0.8.0" @@ -59,6 +140,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "block-buffer" version = "0.12.1" @@ -87,6 +174,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -96,6 +185,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" @@ -143,18 +238,70 @@ dependencies = [ "uncased", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.3.1" @@ -179,7 +326,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", +] + +[[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]] @@ -191,6 +358,7 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", + "ctutils", ] [[package]] @@ -204,12 +372,28 @@ 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 = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fancy-regex" version = "0.19.2" @@ -265,6 +449,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-channel" version = "0.3.34" @@ -311,8 +501,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -322,9 +514,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + +[[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", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "uuid", ] [[package]] @@ -339,6 +566,7 @@ dependencies = [ "percent-encoding", "rand", "regex", + "rustix", "serde", "serde_json", "sha2", @@ -353,6 +581,7 @@ dependencies = [ "guard-core-engine", "html-escape", "http", + "redis", "regex", "serde_json", "sha2", @@ -388,6 +617,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + [[package]] name = "html-escape" version = "0.2.15" @@ -467,6 +705,22 @@ dependencies = [ "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", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -475,13 +729,22 @@ 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", "http-body", + "httparse", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -622,12 +885,86 @@ dependencies = [ "hashbrown", ] +[[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 = "itoa" 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", + "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" @@ -645,6 +982,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -666,6 +1009,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 = "memchr" version = "2.8.3" @@ -708,6 +1057,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 = "parse-zoneinfo" version = "0.4.1" @@ -780,6 +1135,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", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -798,6 +1170,63 @@ dependencies = [ "unicode-ident", ] +[[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", + "thiserror", + "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", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "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", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -830,6 +1259,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", +] + +[[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", + "url", + "xxhash-rust", +] + [[package]] name = "regex" version = "1.13.1" @@ -859,6 +1313,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", + "http-body", + "http-body-util", + "hyper", + "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" @@ -873,12 +1362,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -888,21 +1406,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", @@ -914,6 +1473,59 @@ 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 = "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" @@ -981,6 +1593,22 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" @@ -1048,6 +1676,9 @@ 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" @@ -1060,6 +1691,26 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[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 = "tinystr" version = "0.8.4" @@ -1102,6 +1753,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-util" version = "0.7.19" @@ -1126,6 +1787,7 @@ dependencies = [ "futures-util", "pin-project-lite", "sync_wrapper", + "tokio", "tower-layer", "tower-service", ] @@ -1151,6 +1813,7 @@ version = "1.5.0" dependencies = [ "bytes", "chrono", + "guard-agent-rs", "guard-core-engine", "guard-core-rs", "http", @@ -1176,6 +1839,24 @@ dependencies = [ "tower-guard-rs", ] +[[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", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1207,6 +1888,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1237,6 +1924,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -1249,7 +1946,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", @@ -1277,12 +1974,43 @@ 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 = "version_check" 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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1302,6 +2030,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" @@ -1334,6 +2073,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" @@ -1352,6 +2120,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-core" version = "0.62.2" @@ -1499,6 +2276,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 = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 4de47c5..cfd53bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,10 +37,32 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } serde_json = "1" tower = { version = "0.5", default-features = false } +# 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 `GuardLayer::from_security_config`). +agent = ["dep:guard-agent-rs", "dep:tokio"] + [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tower = { version = "0.5", default-features = false, features = ["util"] } +# 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 layer 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 a4995f4..528303d 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,9 @@ Scan notes: the query string is scanned as `parse_qsl`-decoded per-parameter pai ## Config, client resolution and exclusions -`GuardLayer::from_security_config(&config)` builds the whole pipeline from a `SecurityConfig` through the engine facade's `Wiring`, so every field with an engine-side consumer lands on its stage (the facade's `wiring` module documents the field-by-field table). `from_security_config_with(&config, &WiringOptions { agent, logfire_client })` also injects the telemetry agent: any `TelemetryHandler`, for example guard-agent-rs's `GuardAgentTelemetry` built from the `agent_*` fields. With a sink the event bus, the request metrics and `agent_stats()` (`enabled`, `degraded`) are live, and `spawn_dynamic_rules()` starts the dynamic-rule poll loop on a std thread (host-started, drop the handle to stop it). Redis-backed rate limits and bans need the facade's `redis` feature (enabled through your own `guard-core-rs` dependency) or `with_distributed_store`. `route_resolution_strict`, `body_read_timeout`, `log_format` and `custom_log_file` stay on `Wiring::config`: this adapter has no runtime and reads none of them. +`GuardLayer::from_security_config(&config)` validates the config (`SecurityConfig::validate`, as the reference does at construction; a rejected value is `GuardConfigError::Config`) and builds the whole pipeline through the engine facade's `Wiring`, so every field with an engine-side consumer lands on its stage (the facade's `wiring` module documents the field-by-field table). `from_security_config_with(&config, &WiringOptions { agent, logfire_client })` also injects the telemetry agent: any `TelemetryHandler`. With the optional `agent` cargo feature the layer builds the agent itself from the `agent_*` fields when `enable_agent` is set (inside a tokio runtime), the reference `guard_agent(config.to_agent_config())` step: a failure degrades (`agent_stats().degraded`) unless `agent_strict`, which makes it `GuardConfigError::Agent`. guard-agent-rs depends on the guard-core-rs facade, so the facade cannot own that step and each adapter does. With a sink the event bus, the request metrics and `agent_stats()` (`enabled`, `degraded`) are live, and `spawn_dynamic_rules()` starts the dynamic-rule poll loop on a std thread (host-started, drop the handle to stop it). + +A layer built from a config reads its rate limits, country lists, blocked user agents, cloud providers, emergency mode, penetration toggle and ban knobs from the live config on every request, so a dynamic rule that changes one takes effect on the next request (the recorded rate-limit windows and the ban store carry over). Redis-backed rate limits and bans need the `redis` cargo feature (it forwards the facade's backend; without it `enable_redis` keeps per process state) or `with_distributed_store`. `route_resolution_strict` answers `500 Route resolution failed` for a request carrying the `GuardRouteUnresolved` extension (the framework glue inserts it for a request it could not route; `passive_mode` only logs it). `body_read_timeout` bounds the request-body read once the host installs a timer with `with_body_timer` (this crate owns no runtime), and a body that does not arrive in time is answered `408 Request body read timed out`. The route usage rules run after the pipeline, just before the request is forwarded. `log_format` and `custom_log_file` stay on `Wiring::config`. The connecting peer is the `GuardClientIp` request extension. It is resolved through the client policy (trusted proxies, depth, `X-Forwarded-For`, IPv4-mapped canonicalization) and the resolved client feeds the IP gate, bans, rate limits, geo, cloud, events and the response pass. A request with no peer that cannot be resolved answers `403 Client address could not be determined` under `fail_secure` (the `from_security_config` default), after firing `on_block` with the `client_address_unresolved` check; with `fail_secure = false` it continues unattributed. The HTTPS stage trusts `X-Forwarded-Proto` only from a trusted connecting peer. diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..2aaf777 --- /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::GuardLayer; + + 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 = GuardLayer::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 = GuardLayer::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 = GuardLayer::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 = GuardLayer::from_security_config_with(&config(), &options).expect("layer"); + assert!(layer.agent_stats().enabled); + } +} diff --git a/src/lib.rs b/src/lib.rs index 690e729..e650f39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -184,6 +184,8 @@ //! //! [`axum-guard-rs`]: https://github.com/Guard-Core/axum-guard-rs +#[cfg(feature = "agent")] +mod agent; mod body; mod response; mod service; @@ -245,6 +247,7 @@ pub use guard_core_rs::headers_auth::{ pub use guard_core_rs::https_enforcement::{ HttpsEnforcementStage, HttpsRedirectAnswer as HttpsRedirect, }; +pub use guard_core_rs::live::{LiveCell, LiveContext, LiveStages}; pub use guard_core_rs::metrics::{MetricsCollector, SecurityMetric}; pub use guard_core_rs::process_response::ResponseProcessor; pub use guard_core_rs::request_logging::{RequestLoggingStage, RequestLoggingStageConfig}; @@ -280,6 +283,12 @@ pub enum GuardConfigError { /// Country rules without a `geo_ip_handler` or an `ipinfo_token`, or the /// `GeoIP` database lifecycle refused the config. 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 { @@ -292,6 +301,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}"), } } } @@ -304,7 +315,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, } } } @@ -343,6 +355,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), } } } @@ -374,6 +387,32 @@ pub struct GuardClientIp(pub IpAddr); #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct GuardExclusionScoped; +/// The marker extension a host (or a framework glue layer such as +/// `axum-guard-rs`) inserts on a request the framework could not resolve to +/// a route (the reference `request.state.guard_route_unresolved`). With +/// `route_resolution_strict` set the guard answers it `500 Route resolution +/// failed` (a logged event under `passive_mode`); without it the marker is +/// inert and the request runs the pipeline with no per-route config. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct GuardRouteUnresolved; + +/// A timer the layer races the request-body read against: given the +/// `body_read_timeout`, a future that completes when it elapses. The layer +/// owns no runtime, so the host supplies one (for example +/// `|duration| Box::pin(tokio::time::sleep(duration))`). +pub type SleepFn = Arc< + dyn Fn(std::time::Duration) -> std::pin::Pin + Send>> + + Send + + Sync, +>; + +/// What the live config rebuilds for this layer: the engine stages a dynamic +/// rule can change plus the rate-limit stage built over them. +pub(crate) struct TowerLive { + pub(crate) stages: Arc, + pub(crate) rate_stage: RateLimitStage, +} + /// Reference default detection configuration. /// /// The engine's [`DetectConfig`] carries no `Default` impl, so the adapter @@ -463,6 +502,7 @@ impl core::fmt::Debug for BanState { /// # let _ = layer; /// ``` #[derive(Clone)] +#[allow(clippy::struct_excessive_bools)] pub struct GuardLayer { config: DetectConfig, body_cap: usize, @@ -558,6 +598,21 @@ pub struct GuardLayer { /// The stage built by [`GuardLayer::layer`](tower::Layer::layer) from /// the fields above; `None` until the layer wraps a service. stage: Option>, + /// The live-config cell (a layer built from a `SecurityConfig`): the + /// stages a dynamic rule can change, rebuilt when a rule lands. + live: Option>>, + /// The cell's value for the request in flight (set per request by + /// [`GuardLayer::refresh_live`]; the stage accessors read it first). + live_now: Option>, + /// `route_resolution_strict`. + route_resolution_strict: bool, + /// `body_read_timeout`, enforced when a timer is installed. + body_read_timeout: Option, + /// The host's timer the body read races (see [`SleepFn`]). + body_timer: Option, + /// The agent init failed and `agent_strict` was off (the reference + /// `agent_degraded`). + agent_degraded: bool, /// The cloud-refresh seam the `refresh_cloud_ip_ranges` maintenance /// call drives (the reference `refresh_cloud_ip_ranges`'s handler). cloud_refresh: Option<( @@ -630,6 +685,12 @@ impl GuardLayer { penetration_detection_enabled: true, scan_fn: guard_core_engine::detection_exclusions::scan_request, stage: None, + live: None, + live_now: None, + route_resolution_strict: false, + body_read_timeout: None, + body_timer: None, + agent_degraded: false, cloud_refresh: None, } } @@ -650,8 +711,12 @@ impl GuardLayer { /// The global scan toggle (`enable_penetration_detection`, the /// reference default `true`). - pub(crate) const fn penetration_detection_enabled(&self) -> bool { - self.penetration_detection_enabled + pub(crate) fn penetration_detection_enabled(&self) -> bool { + self.live_now + .as_ref() + .map_or(self.penetration_detection_enabled, |live| { + live.stages.penetration_detection + }) } /// Set the global scan toggle (`enable_penetration_detection`): the @@ -711,6 +776,45 @@ impl GuardLayer { self.auth_verifier.as_ref() } + /// Set `route_resolution_strict`: a request marked + /// [`GuardRouteUnresolved`] is answered `500 Route resolution failed` + /// (the reference `RouteConfigCheck`; logged only under `passive_mode`). + #[must_use] + pub const fn with_route_resolution_strict(mut self, strict: bool) -> Self { + self.route_resolution_strict = strict; + self + } + + pub(crate) const fn route_resolution_strict(&self) -> bool { + self.route_resolution_strict + } + + /// Set the request-body read bound (the reference `body_read_timeout`). + /// A body that does not arrive in full within it is answered `408 + /// Request body read timed out` instead of being forwarded. + /// [`GuardLayer::from_security_config`] installs the config's value; the + /// bound is enforced once the host supplies a timer with + /// [`GuardLayer::with_body_timer`] (the layer owns no runtime), and the + /// read is unbounded without one. + #[must_use] + pub const fn with_body_read_timeout(mut self, timeout: std::time::Duration) -> Self { + self.body_read_timeout = Some(timeout); + self + } + + /// Install the timer the body read races the `body_read_timeout` + /// against, for example + /// `Arc::new(|duration| Box::pin(tokio::time::sleep(duration)))`. + #[must_use] + pub fn with_body_timer(mut self, timer: SleepFn) -> Self { + self.body_timer = Some(timer); + self + } + + pub(crate) fn body_read_bound(&self) -> Option<(std::time::Duration, &SleepFn)> { + self.body_read_timeout.zip(self.body_timer.as_ref()) + } + /// Install the telemetry fan-out `agent_stats` reports on. #[must_use] pub fn with_telemetry(mut self, telemetry: Arc) -> Self { @@ -755,24 +859,27 @@ impl GuardLayer { /// maps every field onto the stage or knob it owns, in one place, with /// the reference semantics, and the layer installs each part. /// - /// The telemetry agent is a collaborator the config cannot carry: use - /// [`GuardLayer::from_security_config_with`] to inject one through + /// The telemetry agent: with the `agent` feature and `enable_agent` set, + /// the layer builds it from the `agent_*` fields (inside a tokio + /// runtime); otherwise inject one through + /// [`GuardLayer::from_security_config_with`] and /// [`WiringOptions::agent`]. Without a sink the event bus, the metrics /// collector and the dynamic rules stay off and /// [`GuardLayer::agent_stats`] reports `enabled: false`. /// - /// Fields the wiring documents as living on [`Wiring::config`] - /// (`route_resolution_strict`, `body_read_timeout`, `log_format`, - /// `custom_log_file`, `lazy_init`): this adapter owns no runtime, so it - /// reads none of them. Per-route resolution is the host's resolver, and - /// the body is read as the wrapped service's body type delivers it. + /// The config is validated first ([`SecurityConfig::validate`], as the + /// reference validates at construction). A layer built this way reads the + /// stages a dynamic rule can change from the live config on every request. + /// `route_resolution_strict` and `body_read_timeout` are installed (see + /// [`GuardLayer::with_body_timer`] for the timer the timeout needs); + /// `log_format` and `custom_log_file` stay on [`Wiring::config`]. /// /// # Errors /// /// [`GuardConfigError`] when 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, a Redis or geo - /// setup the config cannot satisfy). + /// (a value the validator rejects, an invalid IP/CIDR list entry, a zero + /// rate-limit knob, a ReDoS-unsafe blocked user-agent pattern, an unknown + /// cloud provider, a Redis or geo setup the config cannot satisfy). pub fn from_security_config( config: &guard_core_engine::security_config::SecurityConfig, ) -> Result { @@ -791,16 +898,58 @@ impl GuardLayer { 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(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`] on a new layer. #[must_use] pub fn from_wiring(wiring: Wiring) -> Self { + let live_context = wiring.live.clone(); let mut layer = Self::new(wiring.detect) + .with_route_resolution_strict(wiring.config.route_resolution_strict) + .with_body_read_timeout( + std::time::Duration::try_from_secs_f64(wiring.config.body_read_timeout) + .unwrap_or(std::time::Duration::from_secs(3)), + ) .with_passive_mode(wiring.passive_mode) .with_penetration_detection(wiring.penetration_detection) .with_exclude_paths(wiring.config.exclude_paths.clone()) @@ -877,7 +1026,32 @@ impl GuardLayer { if let Some(dynamic_rules) = wiring.dynamic_rules { layer = layer.with_dynamic_rules(dynamic_rules); } - layer + layer.install_live(&live_context) + } + + /// Install 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`). + fn install_live(mut self, context: &LiveContext) -> Self { + let snapshot = self.clone(); + let counters = self + .ban_state + .as_ref() + .map_or_else(ViolationCounters::new, |state| state.counters.clone()); + let cell = context.cell(move |stages| TowerLive { + stages: Arc::clone(stages), + rate_stage: snapshot.build_live_stage(stages, &counters), + }); + if let Ok(cell) = cell { + self.live = Some(Arc::new(cell)); + } + self + } + + /// Point the layer at the live stages for the request in flight (a + /// no-op for a layer built without a config). + pub(crate) fn refresh_live(&mut self) { + self.live_now = self.live.as_ref().map(|cell| cell.get()); } /// Replace the body buffering cap, in bytes. @@ -1392,10 +1566,11 @@ impl GuardLayer { pub fn agent_stats(&self) -> AgentStats { AgentStats { enabled: self.telemetry.is_some(), - degraded: self - .telemetry - .as_deref() - .is_some_and(CompositeAgentHandler::degraded), + degraded: self.agent_degraded + || self + .telemetry + .as_deref() + .is_some_and(CompositeAgentHandler::degraded), } } @@ -1466,8 +1641,11 @@ impl GuardLayer { &self.custom_error_responses } - pub(crate) const fn emergency_mode(&self) -> Option<&EmergencyModeStage> { - self.emergency_mode.as_ref() + pub(crate) fn emergency_mode(&self) -> Option<&EmergencyModeStage> { + match &self.live_now { + Some(live) => live.stages.emergency.as_ref(), + None => self.emergency_mode.as_ref(), + } } pub(crate) const fn https_enforcement(&self) -> Option<&HttpsEnforcementStage> { @@ -1494,16 +1672,25 @@ impl GuardLayer { self.time_window_gate.as_ref() } - pub(crate) const fn geo_blocking(&self) -> Option<&GeoStage> { - self.geo_blocking.as_ref() + pub(crate) fn geo_blocking(&self) -> Option<&GeoStage> { + match &self.live_now { + Some(live) => live.stages.geo.as_ref(), + None => self.geo_blocking.as_ref(), + } } - pub(crate) const fn cloud_provider(&self) -> Option<&CloudProviderStage> { - self.cloud_provider.as_ref() + pub(crate) fn cloud_provider(&self) -> Option<&CloudProviderStage> { + match &self.live_now { + Some(live) => live.stages.cloud.as_ref(), + None => self.cloud_provider.as_ref(), + } } - pub(crate) const fn user_agent(&self) -> Option<&UserAgentStage> { - self.user_agent.as_ref() + pub(crate) fn user_agent(&self) -> Option<&UserAgentStage> { + match &self.live_now { + Some(live) => live.stages.user_agent.as_ref(), + None => self.user_agent.as_ref(), + } } pub(crate) const fn response_processor(&self) -> Option<&Arc> { @@ -1524,7 +1711,10 @@ impl GuardLayer { /// [`GuardLayer::layer`](tower::Layer::layer)); every stateful /// decision and emission goes through it. pub(crate) fn stage(&self) -> Option<&RateLimitStage> { - self.stage.as_deref() + match &self.live_now { + Some(live) => Some(&live.rate_stage), + None => self.stage.as_deref(), + } } /// Build the engine stage from the configured handles and seams. @@ -1546,17 +1736,45 @@ impl GuardLayer { }, |state| state.config.clone(), ); + self.stage_over( + rate_limit, + ip_ban, + self.rate_limiter.as_deref().cloned(), + self.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: &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.rate_limiter { - builder = builder.limiter(limiter.as_ref().clone()); + if let Some(limiter) = limiter { + builder = builder.limiter(limiter); } - if let Some(state) = &self.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 { { diff --git a/src/service.rs b/src/service.rs index ab5a007..266d1f8 100644 --- a/src/service.rs +++ b/src/service.rs @@ -108,6 +108,8 @@ impl std::fmt::Debug for GuardService { enum BufferFailure { /// The body exceeded the buffering cap; reading stopped early. TooLarge, + /// The body did not arrive within `body_read_timeout`. + TimedOut, /// The body stream errored mid-read. The underlying error is dropped on /// purpose: it is body-transport noise (client abort, socket reset), not /// a security signal, and it must not leak into the `500` response. @@ -147,7 +149,8 @@ where #[allow(clippy::too_many_lines)] // the reference pipeline order, one arm per check fn call(&mut self, request: Request) -> Self::Future { let inner = self.inner.clone(); - let layer = self.layer.clone(); + let mut layer = self.layer.clone(); + layer.refresh_live(); Box::pin(async move { let started = Instant::now(); let (mut parts, mut body) = request.into_parts(); @@ -200,6 +203,19 @@ where }); let route = route_carrier.as_deref(); + // The reference `RouteConfigCheck` (it also runs on the scoped + // pipeline): a request the framework could not resolve to a route + // is refused under `route_resolution_strict`. + if layer.route_resolution_strict() + && parts + .extensions + .get::() + .is_some() + && let Some(response) = unresolved_route_response(&parts, &layer, &facts) + { + return Ok(response); + } + // The reference `handle_security_bypass`: a route that bypasses // "all" checks is forwarded to the real downstream service and // only the response modifier runs (no checks, no metrics, no @@ -212,39 +228,6 @@ where } } - // 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 !scoped - && !route_rules.is_empty() - && let Some(processor) = layer.response_processor() - { - let endpoint_id = format!("{}:{}", facts.method, facts.path); - let actions = processor.process_usage_rules( - &endpoint_id, - &facts.ip.map_or_else(String::new, |ip| ip.to_string()), - route_rules, - std::time::SystemTime::now(), - ); - if actions - .iter() - .any(guard_core_engine::behavior::BehaviorAction::is_ban) - { - return Ok(block_response( - &parts, - &layer, - 403, - crate::ACTIVITY_BANNED_MESSAGE, - )); - } - } - // The reference `RouteConfigResolver.should_bypass_check`: the // named check, or the `"all"` wildcard. let bypassed = |check: &str| { @@ -506,11 +489,19 @@ where .and_then(|route| route.max_request_size) .and_then(|size| usize::try_from(size).ok()) .unwrap_or_else(|| layer.body_cap()); - let buffered = match buffer_body(&mut body, body_cap).await { + let buffered = match buffer_body_bounded(&mut body, body_cap, &layer).await { Ok(buffered) => buffered, Err(BufferFailure::TooLarge) => { return Ok(oversize_response(&parts, &layer)); } + Err(BufferFailure::TimedOut) => { + return Ok(block_response( + &parts, + &layer, + guard_core_engine::request_limits::BODY_READ_TIMEOUT_STATUS, + guard_core_engine::request_limits::BODY_READ_TIMEOUT_MESSAGE, + )); + } Err(BufferFailure::Read) => { return Ok(failure_response(&parts, &layer)); } @@ -928,6 +919,39 @@ where return Ok(block_response(&parts, &layer, status, "")); } + // The reference `process_usage_rules`: after the whole pipeline + // passed (`dispatch` runs `behavioral_processor.process_usage_rules` + // between `_handle_pipeline_block` and `call_next`), 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). A request the pipeline blocked never reaches it, and + // neither does an unattributed one (`and client_ip`). + if let Some(route) = route + && !route.behavior_rules.is_empty() + && let Some(ip) = facts.ip + && let Some(processor) = layer.response_processor() + { + let endpoint_id = format!("{}:{}", facts.method, facts.path); + let actions = processor.process_usage_rules( + &endpoint_id, + &ip.to_string(), + &route.behavior_rules, + std::time::SystemTime::now(), + ); + if actions + .iter() + .any(guard_core_engine::behavior::BehaviorAction::is_ban) + { + return Ok(block_response( + &parts, + &layer, + 403, + crate::ACTIVITY_BANNED_MESSAGE, + )); + } + } + let rebuilt = B::from(buffered.unwrap_or_default()); forward(&layer, &facts, parts, rebuilt, started, inner, false).await }) @@ -1005,6 +1029,61 @@ fn unresolved_client_response(parts: &Parts, layer: &GuardLayer) -> Response block_response(parts, layer, status, &body) } +/// The reference `RouteConfigCheck._handle_unresolved_route`: the +/// `route_unresolved` event, and the `500 Route resolution failed` answer +/// unless `passive_mode` (log only). `None` when the request continues. +fn unresolved_route_response( + parts: &Parts, + layer: &GuardLayer, + facts: &RequestFacts, +) -> Option>> { + use guard_core_engine::route_config::{ + UNRESOLVED_ROUTE_CHECK_NAME, UNRESOLVED_ROUTE_MESSAGE, UNRESOLVED_ROUTE_REASON, + UNRESOLVED_ROUTE_STATUS, + }; + if let Some(bus) = &layer.events { + let mut event = guard_core_rs::events::SecurityEvent::new( + guard_core_rs::event_types::EVENT_ROUTE_UNRESOLVED, + &facts.ip_string, + if layer.passive_mode { + "logged_only" + } else { + "request_blocked" + }, + UNRESOLVED_ROUTE_REASON, + "middleware", + ); + event.endpoint = Some(guard_core_rs::redact::redact_url_for_display( + &facts.path, + &sensitive_names(layer), + )); + event.method = Some(facts.method.clone()); + bus.send_event(&event); + } + let observation = request_observation(parts); + let payload = build_block_payload( + UNRESOLVED_ROUTE_CHECK_NAME, + UNRESOLVED_ROUTE_REASON, + "", + layer.passive_mode, + &facts.ip_string, + observation.url.as_deref().unwrap_or("/"), + observation.method.as_deref().unwrap_or(""), + Some(UNRESOLVED_ROUTE_STATUS), + &sensitive_names(layer), + ); + fire_block_hook(layer.on_block(), &payload); + if layer.passive_mode { + return None; + } + let body = resolve_error_body( + layer.custom_error_responses(), + UNRESOLVED_ROUTE_STATUS, + UNRESOLVED_ROUTE_MESSAGE, + ); + Some(block_response(parts, layer, UNRESOLVED_ROUTE_STATUS, &body)) +} + /// The reference `security_bypass` event of an all-checks-bypass route. fn emit_security_bypass_event(layer: &GuardLayer, facts: &RequestFacts, route: &RouteConfig) { let Some(bus) = &layer.events else { @@ -1570,6 +1649,34 @@ fn block_response_owned( generated } +/// [`buffer_body`] raced against the layer's `body_read_timeout` timer (the +/// reference `body_read_timeout`); unbounded when the host installed no timer. +async fn buffer_body_bounded( + body: &mut B, + cap: usize, + layer: &GuardLayer, +) -> Result, BufferFailure> +where + B: Body + Unpin, + B::Error: Into, +{ + let Some((timeout, timer)) = layer.body_read_bound() else { + return buffer_body(body, cap).await; + }; + let mut elapsed = timer(timeout); + let mut read = std::pin::pin!(buffer_body(body, cap)); + std::future::poll_fn(|cx| { + if let Poll::Ready(result) = read.as_mut().poll(cx) { + return Poll::Ready(result); + } + if elapsed.as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(BufferFailure::TimedOut)); + } + Poll::Pending + }) + .await +} + /// Buffer a request body up to `cap` bytes. /// /// `Ok(None)` means the body was empty. Trailers are discarded: the engine @@ -2162,8 +2269,15 @@ mod tests { whitelist: Some(vec![String::from("not-an-ip")]), ..SecurityConfig::default() }; + // The validator names the bad list first. let error = GuardLayer::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] @@ -2328,7 +2442,11 @@ mod tests { detection_max_scan_seconds: 0.0, ..SecurityConfig::default() }; - let (status, _) = config_status_and_body(&spent, benign_request("203.0.113.9")).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 layer = GuardLayer::from_wiring(wiring); + let (status, _) = status_and_body(&layer, benign_request("203.0.113.9")).await; assert_eq!(status, StatusCode::BAD_REQUEST); } @@ -5737,4 +5855,394 @@ mod tests { let (blocked, _) = status_and_body(&layer, get).await; assert_eq!(blocked, StatusCode::BAD_REQUEST); } + + // --- reference parity: usage rules order, live rules, strict routes, + // body timeout, config validation --- + + fn usage_route() -> RouteConfig { + RouteConfig { + behavior_rules: vec![guard_core_engine::behavior::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() + } + } + + fn usage_processor() -> guard_core_rs::process_response::ResponseProcessor { + guard_core_rs::process_response::ResponseProcessor::new( + None, + None, + Vec::new(), + Arc::new(Mutex::new( + guard_core_engine::behavior::BehaviorTracker::new(), + )), + IpBanManager::new(), + true, + 262_144, + false, + ) + } + + #[tokio::test] + async fn usage_rules_run_after_the_pipeline_so_a_blocked_request_is_not_counted() { + let layer = GuardLayer::new(default_config()) + .with_response_processor(usage_processor()) + .with_route_configs(resolver_for(&[("GET", "/api")], usage_route())); + // Three attack requests the pipeline blocks: the reference never + // reaches `process_usage_rules` for them. + for _ in 0..3 { + let (status, _) = + status_and_body(&layer, attack_request_at("/api", "192.0.2.81")).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + // The first request that passes the pipeline is the first observation + // (threshold 2): it forwards instead of drawing the ban. + let (status, body) = status_and_body(&layer, request_at("/api", "192.0.2.81")).await; + assert_eq!((status, body.as_str()), (StatusCode::OK, "gate=off")); + } + + #[tokio::test] + async fn usage_rules_skip_an_unattributed_request() { + let layer = GuardLayer::new(default_config()) + .with_response_processor(usage_processor()) + .with_route_configs(resolver_for(&[("GET", "/api")], usage_route())); + for _ in 0..4 { + let request = Request::builder() + .uri("/api") + .body(Full::new(Bytes::new())) + .expect("request"); + let (status, _) = status_and_body(&layer, request).await; + assert_eq!(status, StatusCode::OK); + } + } + + fn live_layer( + change: impl FnOnce(&mut guard_core_engine::security_config::SecurityConfig), + ) -> ( + GuardLayer, + Arc>, + ) { + let mut config = guard_core_engine::security_config::SecurityConfig { + enable_redis: false, + geo_ip_handler: Some(Arc::new(UnitedStates)), + ..guard_core_engine::security_config::SecurityConfig::default() + }; + change(&mut config); + let wiring = crate::Wiring::from_security_config(&config).expect("wired"); + let live = Arc::clone(&wiring.live_config); + (GuardLayer::from_wiring(wiring), live) + } + + fn rule( + live: &Arc>, + change: impl FnOnce(&mut guard_core_engine::security_config::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 (layer, live) = live_layer(|config| { + config.enable_rate_limiting = true; + config.rate_limit = 2; + config.rate_limit_window = 60; + }); + for _ in 0..2 { + let (status, _) = status_and_body(&layer, request_at("/a", "192.0.2.91")).await; + assert_eq!(status, StatusCode::OK); + } + let (status, _) = status_and_body(&layer, request_at("/a", "192.0.2.91")).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + + // A rule raises the limit: the recorded window carries over, so the + // next request fits under the new ceiling. + rule(&live, |config| config.rate_limit = 100); + let (status, _) = status_and_body(&layer, request_at("/a", "192.0.2.91")).await; + assert_eq!(status, StatusCode::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_and_body(&layer, request_at("/a", "192.0.2.91")).await; + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn dynamic_country_agent_emergency_and_detection_rules_take_effect() { + let (layer, live) = live_layer(|_| {}); + let (status, _) = status_and_body(&layer, request_at("/a", "192.0.2.92")).await; + assert_eq!(status, StatusCode::OK); + + // A country blocklist (the handler resolves every address to US). + rule(&live, |config| { + config.blocked_countries = std::collections::BTreeSet::from(["US".to_owned()]); + }); + let (status, _) = status_and_body(&layer, request_at("/a", "192.0.2.92")).await; + assert_eq!(status, StatusCode::FORBIDDEN); + rule(&live, |config| config.blocked_countries.clear()); + + // A blocked user agent. + rule(&live, |config| { + config.blocked_user_agents = vec!["badbot".to_owned()]; + }); + let request = Request::builder() + .uri("/a") + .header("user-agent", "badbot/1.0") + .extension(gate_ip("192.0.2.92")) + .body(Full::new(Bytes::new())) + .expect("request"); + let (status, _) = status_and_body(&layer, request).await; + assert_eq!(status, StatusCode::FORBIDDEN); + rule(&live, |config| config.blocked_user_agents.clear()); + + // Emergency mode locks everyone out. + rule(&live, |config| config.emergency_mode = true); + let (status, _) = status_and_body(&layer, request_at("/a", "192.0.2.92")).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + rule(&live, |config| config.emergency_mode = false); + + // The detection toggle: the attack passes once scanning is off. + let (status, _) = status_and_body(&layer, attack_request_at("/a", "192.0.2.92")).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + rule(&live, |config| config.enable_penetration_detection = false); + let (status, _) = status_and_body(&layer, attack_request_at("/a", "192.0.2.92")).await; + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn a_dynamic_cloud_provider_rule_builds_the_stage() { + let (layer, live) = live_layer(|_| {}); + 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 still runs through the rebuilt stage. + let (status, _) = status_and_body(&layer, request_at("/a", "192.0.2.93")).await; + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn the_strict_route_resolution_answers_an_unresolved_request() { + let strict = GuardLayer::new(default_config()).with_route_resolution_strict(true); + let mut unresolved = request_at("/nowhere", "192.0.2.61"); + unresolved + .extensions_mut() + .insert(crate::GuardRouteUnresolved); + let (status, body) = status_and_body(&strict, unresolved).await; + assert_eq!( + (status, body.as_str()), + (StatusCode::INTERNAL_SERVER_ERROR, "Route resolution failed") + ); + + // The enforced answer emits the event too. + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&events); + let bus = guard_core_rs::events::SecurityEventBus::new(true).on_event(Arc::new( + move |event: &guard_core_rs::events::SecurityEvent| { + sink.lock() + .expect("sink") + .push(format!("{}:{}", event.event_type, event.action_taken)); + }, + )); + let eventful = GuardLayer::new(default_config()) + .with_event_bus(Arc::new(bus)) + .with_route_resolution_strict(true); + let mut unresolved = request_at("/nowhere", "192.0.2.61"); + unresolved + .extensions_mut() + .insert(crate::GuardRouteUnresolved); + let (status, _) = status_and_body(&eventful, unresolved).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + events.lock().expect("sink").as_slice(), + ["route_unresolved:request_blocked"] + ); + + // A resolved request passes, strict or not. + let (status, _) = status_and_body(&strict, request_at("/here", "192.0.2.61")).await; + assert_eq!(status, StatusCode::OK); + + // Without strict mode the marker is inert. + let lax = GuardLayer::new(default_config()); + let mut unresolved = request_at("/nowhere", "192.0.2.61"); + unresolved + .extensions_mut() + .insert(crate::GuardRouteUnresolved); + let (status, _) = status_and_body(&lax, unresolved).await; + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn the_strict_route_resolution_is_log_only_under_passive_mode_and_emits_the_event() { + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&events); + let bus = guard_core_rs::events::SecurityEventBus::new(true).on_event(Arc::new( + move |event: &guard_core_rs::events::SecurityEvent| { + sink.lock() + .expect("sink") + .push(format!("{}:{}", event.event_type, event.action_taken)); + }, + )); + let passive = GuardLayer::new(default_config()) + .with_passive_mode(true) + .with_event_bus(Arc::new(bus)) + .with_route_resolution_strict(true); + let mut unresolved = request_at("/nowhere", "192.0.2.62"); + unresolved + .extensions_mut() + .insert(crate::GuardRouteUnresolved); + let (status, _) = status_and_body(&passive, unresolved).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + events.lock().expect("sink").as_slice(), + ["route_unresolved:logged_only"] + ); + } + + /// A scripted upload: `stall` never delivers a frame, otherwise it + /// answers pending `pending_polls` times (waking itself) and ends. + #[derive(Debug)] + struct UploadBody { + stall: bool, + pending_polls: u8, + } + + impl From for UploadBody { + fn from(_bytes: Bytes) -> Self { + Self { + stall: false, + pending_polls: 0, + } + } + } + + impl http_body::Body for UploadBody { + type Data = Bytes; + type Error = Infallible; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Infallible>>> { + if self.stall { + return Poll::Pending; + } + if self.pending_polls > 0 { + self.pending_polls -= 1; + cx.waker().wake_by_ref(); + return Poll::Pending; + } + Poll::Ready(None) + } + } + + fn upload(body: UploadBody) -> Request { + Request::builder() + .method("POST") + .uri("/upload") + .extension(gate_ip("192.0.2.63")) + .body(body) + .expect("request") + } + + fn upload_service( + layer: &GuardLayer, + ) -> impl Service< + Request, + Response = http::Response>>, + Error = Infallible, + > { + layer.layer(tower::service_fn(|_request: Request| async { + Ok::<_, Infallible>(http::Response::new(Full::new(Bytes::new()))) + })) + } + + fn ready_timer() -> crate::SleepFn { + Arc::new(|_| Box::pin(std::future::ready(()))) + } + + #[tokio::test] + async fn a_stalled_body_is_answered_408_once_the_timer_fires() { + let layer = GuardLayer::new(default_config()) + .with_body_read_timeout(std::time::Duration::from_millis(5)) + .with_body_timer(ready_timer()); + let stalled = UploadBody { + stall: true, + pending_polls: 0, + }; + let response = upload_service(&layer) + .oneshot(upload(stalled)) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT); + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + assert_eq!(&bytes[..], b"Request body read timed out"); + } + + #[tokio::test] + async fn a_body_that_arrives_in_time_is_forwarded_whole() { + let layer = GuardLayer::new(default_config()) + .with_body_read_timeout(std::time::Duration::from_secs(3)) + .with_body_timer(Arc::new(|_| Box::pin(std::future::pending()))); + // The read pends twice (the timer pends too), then completes. + let slow = UploadBody { + stall: false, + pending_polls: 2, + }; + let response = upload_service(&layer) + .oneshot(upload(slow)) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn the_config_supplies_the_body_timeout_and_the_host_supplies_the_timer() { + let config = guard_core_engine::security_config::SecurityConfig { + enable_redis: false, + body_read_timeout: 7.0, + ..guard_core_engine::security_config::SecurityConfig::default() + }; + let layer = GuardLayer::from_security_config(&config).expect("layer"); + assert!(layer.body_read_bound().is_none(), "no timer, no bound"); + let layer = layer.with_body_timer(ready_timer()); + assert_eq!( + layer.body_read_bound().map(|(timeout, _)| timeout), + Some(std::time::Duration::from_secs(7)) + ); + assert_eq!( + layer.route_resolution_strict(), + config.route_resolution_strict + ); + } + + #[test] + fn the_config_is_validated_at_construction() { + let config = guard_core_engine::security_config::SecurityConfig { + enable_redis: false, + auto_ban_threshold: 0, + ..guard_core_engine::security_config::SecurityConfig::default() + }; + let error = GuardLayer::from_security_config(&config).expect_err("rejected"); + assert!(matches!(error, crate::GuardConfigError::Config(_))); + assert!(error.to_string().contains("auto_ban_threshold"), "{error}"); + assert!(std::error::Error::source(&error).is_some()); + let agent = crate::GuardConfigError::Agent("no runtime".to_owned()); + assert_eq!(agent.to_string(), "agent: no runtime"); + assert!(std::error::Error::source(&agent).is_none()); + } } diff --git a/tests/parity3.rs b/tests/parity3.rs new file mode 100644 index 0000000..a3ec68b --- /dev/null +++ b/tests/parity3.rs @@ -0,0 +1,143 @@ +//! Reference parity, third pass, through the public API: the usage-rule +//! order and the rule-driven config changes. + +use bytes::Bytes; +use http::{Request, Response, StatusCode}; +use http_body_util::{BodyExt, Full}; +use std::collections::BTreeSet; +use std::convert::Infallible; +use std::net::IpAddr; +use std::sync::{Arc, Mutex, RwLock}; +use tower::{Layer, ServiceExt}; +use tower_guard_rs::{ + BehaviorRule, GuardClientIp, GuardLayer, IpBanManager, ResponseProcessor, RouteConfig, + SecurityConfig, Wiring, default_config, +}; + +async fn send(layer: &GuardLayer, request: Request>) -> StatusCode { + let response = layer + .layer(tower::service_fn(|_request: Request>| async { + Ok::<_, Infallible>(Response::new(Full::new(Bytes::from("ok")))) + })) + .oneshot(request) + .await + .expect("response"); + let status = response.status(); + let _ = response.into_body().collect().await; + status +} + +fn at(uri: &str, ip: &str) -> Request> { + Request::builder() + .uri(uri) + .extension(GuardClientIp(ip.parse::().expect("ip"))) + .body(Full::new(Bytes::new())) + .expect("request") +} + +#[tokio::test] +async fn usage_rules_run_after_the_pipeline_not_before_it() { + let route = 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() + }; + let resolver: tower_guard_rs::RouteConfigResolver = Arc::new(move |method, path| { + (method == "GET" && path == "/api").then(|| Arc::new(route.clone())) + }); + let processor = ResponseProcessor::new( + None, + None, + Vec::new(), + Arc::new(Mutex::new( + guard_core_engine::behavior::BehaviorTracker::new(), + )), + IpBanManager::new(), + true, + 262_144, + false, + ); + let layer = GuardLayer::new(default_config()) + .with_response_processor(processor) + .with_route_configs(resolver); + for _ in 0..3 { + // The pipeline blocks the attack: it never counts as a usage hit. + assert_eq!( + send(&layer, at("/api?cmd=$(whoami)", "192.0.2.81")).await, + StatusCode::BAD_REQUEST + ); + } + assert_eq!( + send(&layer, at("/api", "192.0.2.81")).await, + StatusCode::OK, + "the first request that passed the pipeline is the first observation" + ); +} + +fn live_layer( + change: impl FnOnce(&mut SecurityConfig), +) -> (GuardLayer, Arc>) { + let mut config = SecurityConfig { + enable_redis: false, + ..SecurityConfig::default() + }; + change(&mut config); + let wiring = Wiring::from_security_config(&config).expect("wired"); + let live = Arc::clone(&wiring.live_config); + (GuardLayer::from_wiring(wiring), live) +} + +#[tokio::test] +async fn a_rate_limit_rule_changes_the_limit_the_layer_enforces() { + let (layer, live) = live_layer(|config| { + config.enable_rate_limiting = true; + config.rate_limit = 1; + config.rate_limit_window = 60; + }); + assert_eq!(send(&layer, at("/a", "192.0.2.91")).await, StatusCode::OK); + assert_eq!( + send(&layer, at("/a", "192.0.2.91")).await, + StatusCode::TOO_MANY_REQUESTS + ); + live.write().expect("live config").rate_limit = 100; + assert_eq!(send(&layer, at("/a", "192.0.2.91")).await, StatusCode::OK); +} + +#[tokio::test] +async fn a_blocked_user_agent_rule_and_the_detection_toggle_take_effect() { + let (layer, live) = live_layer(|_| {}); + let with_agent = |agent: &str| { + Request::builder() + .uri("/a") + .header("user-agent", agent) + .extension(GuardClientIp("192.0.2.92".parse::().expect("ip"))) + .body(Full::new(Bytes::new())) + .expect("request") + }; + assert_eq!(send(&layer, with_agent("badbot/1.0")).await, StatusCode::OK); + live.write().expect("live config").blocked_user_agents = vec!["badbot".to_owned()]; + assert_eq!( + send(&layer, with_agent("badbot/1.0")).await, + StatusCode::FORBIDDEN + ); + + live.write().expect("live config").blocked_countries = BTreeSet::new(); + assert_eq!( + send(&layer, at("/a?cmd=$(whoami)", "192.0.2.92")).await, + StatusCode::BAD_REQUEST + ); + live.write() + .expect("live config") + .enable_penetration_detection = false; + assert_eq!( + send(&layer, at("/a?cmd=$(whoami)", "192.0.2.92")).await, + StatusCode::OK + ); +} diff --git a/tests/parity_api.rs b/tests/parity_api.rs index d7bdb85..e180a8d 100644 --- a/tests/parity_api.rs +++ b/tests/parity_api.rs @@ -89,7 +89,9 @@ impl TelemetryHandler for Sink { fn with_sink(config: &SecurityConfig) -> (GuardLayer, Arc) { let sink = Arc::new(Sink::default()); - let layer = GuardLayer::from_security_config_with( + // The unvalidated seam: the dynamic-rule loop test polls on a one second + // interval, below the validated range. + let wiring = Wiring::build_unvalidated( config, &WiringOptions { agent: Some(Arc::clone(&sink) as Arc), @@ -97,7 +99,7 @@ fn with_sink(config: &SecurityConfig) -> (GuardLayer, Arc) { }, ) .expect("valid config"); - (layer, sink) + (GuardLayer::from_wiring(wiring), sink) } // --- the builder surface --------------------------------------------------- @@ -199,16 +201,17 @@ fn every_wiring_error_maps_onto_the_config_error() { ), ]; for (config, label) in cases { - let error = GuardLayer::from_security_config(&config).expect_err(label); - assert!(error.to_string().contains(label), "{label}: {error}"); - let wired: GuardConfigError = Wiring::from_security_config(&config) + // The validating constructors refuse every one of them. + GuardLayer::from_security_config(&config).expect_err(label); + Wiring::from_security_config(&config) + .map(|_| ()) + .expect_err(label); + // The stage constructors name the value when validation is skipped. + let error: GuardConfigError = Wiring::build_unvalidated(&config, &WiringOptions::default()) .map(|_| ()) .expect_err(label) .into(); - assert_eq!( - std::mem::discriminant(&wired), - std::mem::discriminant(&error) - ); + assert!(error.to_string().contains(label), "{label}: {error}"); assert_eq!(std::error::Error::source(&error).is_some(), label != "geo"); } let ban: GuardConfigError = WiringError::Ban(tower_guard_rs::IpBanConfigError::NonPositive {