diff --git a/Cargo.lock b/Cargo.lock index 3f4d459..bcef421 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,17 +56,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -207,6 +196,16 @@ dependencies = [ "tokio-util", ] +[[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" @@ -895,6 +894,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ordered-float" version = "5.3.0" @@ -1305,7 +1310,7 @@ dependencies = [ [[package]] name = "rate_limiter" -version = "0.0.5" +version = "0.0.6" dependencies = [ "cpex_framework_bridge", "criterion", @@ -1316,6 +1321,7 @@ dependencies = [ "pyo3-log", "pyo3-stub-gen", "redis", + "rustls", "thiserror", "tokio", ] @@ -1348,24 +1354,25 @@ dependencies = [ [[package]] name = "redis" -version = "0.27.6" +version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44" dependencies = [ - "arc-swap", - "async-trait", "bytes", + "cfg-if", "combine", "futures-util", - "itertools 0.13.0", "itoa", "num-bigint", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-native-certs", "ryu", "sha1_smol", - "socket2 0.5.10", + "socket2", "tokio", + "tokio-rustls", "tokio-util", "url", ] @@ -1419,6 +1426,20 @@ dependencies = [ "rand", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -1431,6 +1452,52 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustls" +version = "0.23.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustpython-ast" version = "0.4.0" @@ -1509,6 +1576,15 @@ 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 = "scopeguard" version = "1.2.0" @@ -1529,6 +1605,29 @@ dependencies = [ "serde_json", ] +[[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" @@ -1638,16 +1737,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -1670,6 +1759,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1793,7 +1888,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -1809,6 +1904,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1984,6 +2089,12 @@ dependencies = [ "rand", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2500,6 +2611,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/plugins/rust/python-package/rate_limiter/Cargo.toml b/plugins/rust/python-package/rate_limiter/Cargo.toml index ff1dc1b..df723d5 100644 --- a/plugins/rust/python-package/rate_limiter/Cargo.toml +++ b/plugins/rust/python-package/rate_limiter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rate_limiter" -version = "0.0.5" +version = "0.0.6" edition.workspace = true authors.workspace = true license.workspace = true @@ -24,7 +24,21 @@ pyo3-stub-gen = { workspace = true } pyo3-log = { workspace = true } parking_lot = "0.12" thiserror = { workspace = true } -redis = { version = "0.27", features = ["aio", "tokio-comp"] } +redis = { version = "0.32", features = ["aio", "tokio-comp", "tls-rustls", "tokio-rustls-comp"] } +# Direct rustls dep with the `ring` crypto provider feature. redis 0.32 +# pulls rustls in transitively but via `default-features = false`, so no +# crypto provider is enabled by default — rustls 0.23 then panics at +# first TLS use ("Call CryptoProvider::install_default()..."). We add +# `ring` here and install it as the default provider once at plugin init. +# +# Note on CA roots: by NOT enabling `tls-rustls-webpki-roots`, the redis +# crate falls back to `rustls-native-certs` (Apache-2.0 / MIT / ISC) which +# reads CA certificates from the host OS trust store at runtime. This +# avoids a transitive dep on `webpki-roots` (CDLA-Permissive-2.0). The +# trade-off: the host container must have a CA bundle installed — true +# for the UBI Minimal image mcp-context-forge ships on, and for any +# distribution that keeps `ca-certificates` in its base. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } tokio = { workspace = true } [dev-dependencies] diff --git a/plugins/rust/python-package/rate_limiter/cpex_rate_limiter/plugin-manifest.yaml b/plugins/rust/python-package/rate_limiter/cpex_rate_limiter/plugin-manifest.yaml index e4d9ba6..b9ecfb6 100644 --- a/plugins/rust/python-package/rate_limiter/cpex_rate_limiter/plugin-manifest.yaml +++ b/plugins/rust/python-package/rate_limiter/cpex_rate_limiter/plugin-manifest.yaml @@ -1,6 +1,6 @@ description: "Rate limiting by user/tenant/tool — memory (single-process) or Redis (shared across instances)" author: "ContextForge Contributors" -version: "0.0.5" +version: "0.0.6" kind: "cpex_rate_limiter.rate_limiter.RateLimiterPlugin" available_hooks: - "prompt_pre_fetch" diff --git a/plugins/rust/python-package/rate_limiter/src/plugin.rs b/plugins/rust/python-package/rate_limiter/src/plugin.rs index c585bcd..643d52d 100644 --- a/plugins/rust/python-package/rate_limiter/src/plugin.rs +++ b/plugins/rust/python-package/rate_limiter/src/plugin.rs @@ -4,7 +4,7 @@ // Rust-owned rate limiter plugin core. Python only keeps a tiny compatibility // shell so the gateway can continue importing a `Plugin` subclass. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use cpex_framework_bridge::{build_framework_object, default_result}; use log::warn; @@ -17,6 +17,24 @@ use crate::engine::RateLimiterEngine; const LOGGER_NAME: &str = "cpex_rate_limiter.rate_limiter"; +/// Process-global guard: installs the rustls ring crypto provider exactly +/// once. rustls 0.23 dropped its implicit default crypto provider, so any +/// caller that wants TLS must install one before first use. The redis +/// crate's `tls-rustls` feature does not pick a provider, so without this +/// the first `rediss://` operation panics with +/// "Call CryptoProvider::install_default() before this point...". +/// +/// `install_default` returns Err if a provider is already installed (e.g. +/// by another caller in the same process); that's a no-op for us, so we +/// discard the result. +static CRYPTO_PROVIDER_INSTALLED: OnceLock<()> = OnceLock::new(); + +fn ensure_crypto_provider() { + CRYPTO_PROVIDER_INSTALLED.get_or_init(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + #[gen_stub_pyclass] #[pyclass] pub struct RateLimiterPluginCore { @@ -33,6 +51,10 @@ pub struct RateLimiterPluginCore { impl RateLimiterPluginCore { #[new] pub fn new(config: &Bound<'_, PyDict>) -> PyResult { + // Install the rustls crypto provider before any redis client is + // constructed — required for `rediss://` URLs to work past the + // first TLS handshake. See `ensure_crypto_provider` doc above. + ensure_crypto_provider(); let engine = Arc::new(RateLimiterEngine::new(config)?); let fail_closed = parse_fail_mode(config)?; Ok(Self { @@ -518,9 +540,29 @@ fn log_exception(py: Python<'_>, message: &str) -> PyResult<()> { #[cfg(test)] mod tests { use super::await_async_tuple; + use super::ensure_crypto_provider; use pyo3::prelude::*; use pyo3::types::{PyAnyMethods, PyDictMethods, PyModule}; + #[test] + fn ensure_crypto_provider_installs_a_default() { + // Mutation guard: cargo-mutants tries replacing the body of + // `ensure_crypto_provider` with `()`. Under that mutation no rustls + // crypto provider is installed by us, and (since no other code path + // in this crate installs one) `CryptoProvider::get_default()` returns + // None — surfacing the wo-tracker #68217 runtime-time signature + // ("Call CryptoProvider::install_default() before this point...") + // the function exists to prevent. + // + // OnceLock makes the call idempotent; this test is safe to run in + // any order alongside other tests in the same process. + ensure_crypto_provider(); + assert!( + rustls::crypto::CryptoProvider::get_default().is_some(), + "ensure_crypto_provider() must leave a default rustls crypto provider installed", + ); + } + #[test] fn await_async_tuple_parses_successful_result() -> PyResult<()> { // Ensure the embedded interpreter is initialized for this test process. diff --git a/plugins/tests/rate_limiter/test_redis_integration.py b/plugins/tests/rate_limiter/test_redis_integration.py index 354c251..d7d4c1e 100644 --- a/plugins/tests/rate_limiter/test_redis_integration.py +++ b/plugins/tests/rate_limiter/test_redis_integration.py @@ -1578,3 +1578,109 @@ async def test_unknown_config_key_emits_warning(self, redis_url_for_integration, "engine must warn on unknown config keys so misspellings are visible — " f"captured records: {[(r.levelname, r.getMessage()) for r in caplog.records]}" ) + + +class TestRedisTlsSupport: + """TLS / rediss:// scheme support. + + Regression coverage for wo-tracker #68217: managed Redis services + (AWS ElastiCache with in-transit encryption, Redis Cloud, etc.) require + a `rediss://` URL. The Rust engine must be built with the redis crate's + TLS feature so that constructing a client with a `rediss://` URL parses + successfully — without it the engine raises `InvalidClientConfig: can't + connect with TLS, the feature is not enabled` at plugin init and the + plugin is silently skipped. + """ + + @pytest.mark.asyncio + async def test_rediss_url_does_not_fail_with_tls_not_enabled(self): + """Constructing the plugin with a rediss:// URL must not raise InvalidClientConfig. + + Construction is independent of connectivity: the redis crate parses + the URL and creates a lazy client. We point at a port nothing is + listening on so the test never opens a real TLS handshake. + """ + # rediss:// = TLS scheme. Port 1 has no listener; we never attempt + # a real connection here — the regression is at URL/feature parsing. + plugin = RateLimiterPlugin( + PluginConfig( + name="RateLimiter", + kind="cpex_rate_limiter.rate_limiter.RateLimiterPlugin", + hooks=["tool_pre_invoke"], + priority=100, + config={ + "by_user": "3/s", + "backend": "redis", + "redis_url": "rediss://127.0.0.1:1/15", + "algorithm": "fixed_window", + }, + ) + ) + assert plugin is not None + + @pytest.mark.asyncio + async def test_rediss_url_lazy_handshake_reaches_connectivity_layer(self, caplog): + """Lazy TLS path must fail as connectivity, not as a TLS-feature error. + + Construction-only tests can pass even if the TLS feature compiled + in but the rustls handshake itself is broken — the redis crate parses + the URL fine and the client is created, but the first real operation + is where TLS actually engages. Driving ``tool_pre_invoke`` against a + rediss:// URL pointing at a closed port forces the client into that + lazy path. Default fail_mode=open allows the request after logging + the backend error; we assert the logged error is connectivity-shaped + (refused / timed out / IO) rather than the unmistakable + ``InvalidClientConfig: TLS feature not enabled`` shape that signaled + the original wo-tracker #68217 regression. + """ + # Standard + import logging # noqa: PLC0415 + + plugin = RateLimiterPlugin( + PluginConfig( + name="RateLimiter", + kind="cpex_rate_limiter.rate_limiter.RateLimiterPlugin", + hooks=["tool_pre_invoke"], + priority=100, + config={ + "by_user": "3/s", + "backend": "redis", + "redis_url": "rediss://127.0.0.1:1/15", + "algorithm": "fixed_window", + }, + ) + ) + ctx = PluginContext(global_context=GlobalContext(request_id="r1", user="alice")) + payload = ToolPreInvokePayload(name="tool", arguments={}) + + with caplog.at_level(logging.WARNING): + result = await plugin.tool_pre_invoke(payload, ctx) + + # fail_mode=open default: the request is allowed when backend fails. + assert result.continue_processing is True, ( + "Default fail_mode=open should allow the request when the rediss:// " + f"backend is unreachable; got result={result!r}" + ) + + # Negative assertion — pins the wo-tracker #68217 regression. + # + # The original sev-1 fingerprint was rustls panicking at plugin init + # with `InvalidClientConfig: can't connect with TLS, the feature is + # not enabled`. After the redis crate is built with `tls-rustls` and + # the rustls crypto provider is installed, that signature must never + # appear again. This test asserts its absence. + # + # A positive "the lazy handshake reached the network" assertion + # would also be valuable — but the Rust core's log_exception() + # currently surfaces only a generic "error; allowing request" + # message, dropping the underlying redis::RedisError text. Surfacing + # those details requires a Rust core logging change; tracked as a + # follow-up alongside the real TLS Redis fixture work. + all_messages = " ".join(r.getMessage() for r in caplog.records).lower() + assert "feature is not enabled" not in all_messages and "invalidclientconfig" not in all_messages, ( + "rediss:// failure looks like the wo-tracker #68217 regression " + f"(TLS feature not compiled). Captured logs: " + f"{[(r.levelname, r.getMessage()) for r in caplog.records]}" + ) + +