From 842eda42cf38b266c1cbaba5e336bb6163e1e99b Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 1 May 2026 10:17:43 +0100 Subject: [PATCH 1/6] fix(rate-limiter): enable TLS in redis client for rediss:// URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The redis crate was compiled without any TLS feature, so passing a rediss:// URL (e.g. AWS ElastiCache with in-transit encryption, Redis Cloud) failed at backend init with `InvalidClientConfig: can't connect with TLS, the feature is not enabled`. The plugin manager then logged "Failed to load plugin RateLimiterPlugin" and silently skipped the plugin — bindings appeared configured but no rate limiting was applied. Compile the redis dep with rustls-based TLS (pure Rust, no OpenSSL): - tls-rustls - tls-rustls-webpki-roots (Mozilla CA roots, needed for managed Redis services without custom trust setup) - tokio-rustls-comp (async support over rustls) Added a regression test (TestRedisTlsSupport) that constructs a plugin with a rediss:// URL and asserts no InvalidClientConfig is raised. Bumps cpex_rate_limiter 0.0.4 → 0.0.5. Refs: wo-tracker #68217 Signed-off-by: Pratik Gandhi --- Cargo.lock | 170 ++++++++++++++++++ .../python-package/rate_limiter/Cargo.toml | 2 +- .../rate_limiter/test_redis_integration.py | 39 ++++ 3 files changed, 210 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 3f4d459..e42d4ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,6 +207,16 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -895,6 +905,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "ordered-float" version = "5.3.0" @@ -1362,12 +1378,18 @@ dependencies = [ "num-bigint", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", "ryu", "sha1_smol", "socket2 0.5.10", "tokio", + "tokio-rustls", "tokio-util", "url", + "webpki-roots 0.26.11", ] [[package]] @@ -1419,6 +1441,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 +1467,62 @@ 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.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[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 +1601,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 +1630,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +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" @@ -1670,6 +1794,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" @@ -1809,6 +1939,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 +2124,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" @@ -2158,6 +2304,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2500,6 +2664,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..14b929f 100644 --- a/plugins/rust/python-package/rate_limiter/Cargo.toml +++ b/plugins/rust/python-package/rate_limiter/Cargo.toml @@ -24,7 +24,7 @@ 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.27", features = ["aio", "tokio-comp", "tls-rustls", "tls-rustls-webpki-roots", "tokio-rustls-comp"] } tokio = { workspace = true } [dev-dependencies] diff --git a/plugins/tests/rate_limiter/test_redis_integration.py b/plugins/tests/rate_limiter/test_redis_integration.py index 354c251..5416ff5 100644 --- a/plugins/tests/rate_limiter/test_redis_integration.py +++ b/plugins/tests/rate_limiter/test_redis_integration.py @@ -1578,3 +1578,42 @@ 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 From 9559ff62fa933e8ff46be2c7f254bccac8907a49 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 1 May 2026 13:19:40 +0100 Subject: [PATCH 2/6] fix(rate-limiter): bump redis to 0.32 to drop unmaintained transitive dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-deny security-policy CI was red on this branch with two findings: 1. RUSTSEC-2025-0134 — rustls-pemfile 2.2.0 is unmaintained. Pulled in by redis 0.27 → rustls-native-certs 0.7.3. 2. License rejected — webpki-roots is CDLA-Permissive-2.0, which is not in the workspace deny.toml allow-list. Both come from the TLS feature additions in the previous commit. Bumping the redis crate to 0.32 takes us to rustls-native-certs 0.8.x, which migrated to rustls-pki-types and dropped the rustls-pemfile dependency entirely. The advisory is closed at the root rather than suppressed via deny.toml. The license rejection is fixed by adding CDLA-Permissive-2.0 to the workspace allow-list. CDLA-Permissive-2.0 is a Linux-Foundation permissive data license used by webpki-roots for the Mozilla CA bundle — keeping the bundle pinned in the binary keeps deployments self-contained (no host-OS CA-store dependency in containers). No source-code changes — redis 0.27 → 0.32 is API-compatible for the APIs the Rust core uses (Client::open, MultiplexedConnection, cmd builder, redis::Value pattern matching with _ arms, ErrorKind variants we touch). All 47 redis call sites in redis_backend.rs compile unchanged. Local gate green: cargo check-all (57 Rust tests), pytest (112 Python tests). Signed-off-by: Pratik Gandhi --- Cargo.lock | 73 ++++--------------- deny.toml | 1 + .../python-package/rate_limiter/Cargo.toml | 2 +- 3 files changed, 16 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e42d4ef..51c6230 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" @@ -209,9 +198,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -907,9 +896,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "ordered-float" @@ -1364,32 +1353,28 @@ 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", - "rustls-pemfile", - "rustls-pki-types", "ryu", "sha1_smol", - "socket2 0.5.10", + "socket2", "tokio", "tokio-rustls", "tokio-util", "url", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] @@ -1474,7 +1459,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1483,26 +1467,16 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.7.3" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", - "rustls-pemfile", "rustls-pki-types", "schannel", "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1632,9 +1606,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", "core-foundation", @@ -1762,16 +1736,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" @@ -1923,7 +1887,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2304,15 +2268,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - [[package]] name = "webpki-roots" version = "1.0.6" diff --git a/deny.toml b/deny.toml index 02ee979..732b1aa 100644 --- a/deny.toml +++ b/deny.toml @@ -63,6 +63,7 @@ allow = [ "CC0-1.0", "CDDL-1.0", "CDDL-1.1", + "CDLA-Permissive-2.0", "ClArtistic", "CPL-1.0", "curl", diff --git a/plugins/rust/python-package/rate_limiter/Cargo.toml b/plugins/rust/python-package/rate_limiter/Cargo.toml index 14b929f..5cc6c6a 100644 --- a/plugins/rust/python-package/rate_limiter/Cargo.toml +++ b/plugins/rust/python-package/rate_limiter/Cargo.toml @@ -24,7 +24,7 @@ pyo3-stub-gen = { workspace = true } pyo3-log = { workspace = true } parking_lot = "0.12" thiserror = { workspace = true } -redis = { version = "0.27", features = ["aio", "tokio-comp", "tls-rustls", "tls-rustls-webpki-roots", "tokio-rustls-comp"] } +redis = { version = "0.32", features = ["aio", "tokio-comp", "tls-rustls", "tls-rustls-webpki-roots", "tokio-rustls-comp"] } tokio = { workspace = true } [dev-dependencies] From 34cd26d93a9af5c11463c3544079ac9df566c621 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 1 May 2026 13:54:32 +0100 Subject: [PATCH 3/6] test(rate-limiter): add TLS lazy-handshake regression test Addresses Luca's P2-4 review feedback on PR #74: the construction-only TLS regression test verifies URL parsing but not the lazy handshake. Added test_rediss_url_lazy_handshake_reaches_connectivity_layer that drives a tool_pre_invoke against rediss://127.0.0.1:1/15 so the lazy TLS path actually engages, then asserts the failure is connectivity- shaped (refused / timed out / IO) rather than the wo-tracker #68217 'feature is not enabled' / InvalidClientConfig signature. Signed-off-by: Pratik Gandhi --- .../rate_limiter/test_redis_integration.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/plugins/tests/rate_limiter/test_redis_integration.py b/plugins/tests/rate_limiter/test_redis_integration.py index 5416ff5..1349786 100644 --- a/plugins/tests/rate_limiter/test_redis_integration.py +++ b/plugins/tests/rate_limiter/test_redis_integration.py @@ -1617,3 +1617,66 @@ async def test_rediss_url_does_not_fail_with_tls_not_enabled(self): ) ) 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}" + ) + + # The error logged must indicate connectivity failure (the lazy + # handshake reached the network and the network refused / timed out) + # — NOT the InvalidClientConfig shape that means rustls itself is + # not compiled in. The latter is the failure mode of wo-tracker + # #68217 and must never recur. + all_messages = " ".join(r.getMessage() for r in caplog.records).lower() + assert "tls" not in all_messages or "connect" in all_messages or "refused" in all_messages or "timed out" in all_messages, ( + "rediss:// failure must surface as a connectivity error, not a " + f"TLS feature/config error. Captured logs: " + f"{[(r.levelname, r.getMessage()) for r in caplog.records]}" + ) + 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]}" + ) + + From 2b6a5aff82cd979caeb0140fc39343193ccb2cb8 Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 1 May 2026 15:50:00 +0100 Subject: [PATCH 4/6] fix(rate-limiter): install rustls crypto provider for rediss:// runtime path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that together make `rediss://` URLs actually work end-to-end: 1. Install the rustls ring crypto provider once at Rust core init. Previous TLS commits made `rediss://` URLs parse and the plugin load without the wo-tracker #68217 "feature is not enabled" error, but they left a runtime-time gap: rustls 0.23 dropped its implicit default crypto provider, so the first real TLS handshake panicked with `Call CryptoProvider::install_default()...`. Added a direct dep on rustls 0.23 with the `ring` feature, plus an OnceLock-guarded `ensure_crypto_provider()` in the Rust core that calls `rustls::crypto::ring::default_provider().install_default()` exactly once per process. Called from `RateLimiterPluginCore::new()` before any redis client is constructed. `ring` is the simpler default — pure Rust + small asm, BSD-3 / ISC licensed, builds cleanly across the existing PyPI wheel matrix without extra system toolchains. aws-lc-rs would be needed for FIPS validation; not a requirement for this customer. 2. Drop the `tls-rustls-webpki-roots` feature on the redis crate. That feature pulled `webpki-roots` (Mozilla's CA bundle, licensed CDLA-Permissive-2.0) into the dep tree, which the workspace deny.toml correctly rejected because that license is not in the allow list. Adding the license to the allow list (or as an exception) widens the workspace policy for one transitive crate. Without `tls-rustls-webpki-roots`, the redis crate uses `rustls-native-certs` (Apache-2.0 / MIT / ISC — already allowed) to read CA certificates from the host OS trust store at runtime. mcp-context-forge ships on UBI 10 Minimal, which includes `ca-certificates` by default; AWS ElastiCache uses public CA chains that are in any standard OS bundle. Operationally equivalent for this deployment, with a clean license posture. Reverts the `CDLA-Permissive-2.0` entry from the workspace deny.toml allow list — no longer needed. Test: the existing TLS lazy-handshake regression test (test_rediss_url_lazy_handshake_reaches_connectivity_layer) now reaches the network layer instead of panicking at TLS init. Its assertion is scoped to the wo-tracker #68217 negative signature ("feature is not enabled" / InvalidClientConfig must never appear). A positive "the lazy handshake actually reached the network" assertion would require the Rust core's log_exception() to surface the underlying RedisError text instead of the generic "error; allowing request" message — tracked as a follow-up alongside the real-TLS-Redis-fixture variant. Local gate: cargo fmt-check + clippy clean, 57 Rust unit tests pass, 107 Python integration tests pass. Cargo.lock confirms removal of unmaintained `rustls-pemfile` and license-flagged `webpki-roots`; `rustls-native-certs` 0.8.3 is the only remaining TLS-related dep. Signed-off-by: Pratik Gandhi --- Cargo.lock | 12 ++-------- deny.toml | 1 - .../python-package/rate_limiter/Cargo.toml | 16 ++++++++++++- .../python-package/rate_limiter/src/plugin.rs | 24 ++++++++++++++++++- .../rate_limiter/test_redis_integration.py | 24 +++++++++++-------- 5 files changed, 54 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51c6230..64ad407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1321,6 +1321,7 @@ dependencies = [ "pyo3-log", "pyo3-stub-gen", "redis", + "rustls", "thiserror", "tokio", ] @@ -1374,7 +1375,6 @@ dependencies = [ "tokio-rustls", "tokio-util", "url", - "webpki-roots", ] [[package]] @@ -1459,6 +1459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2268,15 +2269,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" diff --git a/deny.toml b/deny.toml index 732b1aa..02ee979 100644 --- a/deny.toml +++ b/deny.toml @@ -63,7 +63,6 @@ allow = [ "CC0-1.0", "CDDL-1.0", "CDDL-1.1", - "CDLA-Permissive-2.0", "ClArtistic", "CPL-1.0", "curl", diff --git a/plugins/rust/python-package/rate_limiter/Cargo.toml b/plugins/rust/python-package/rate_limiter/Cargo.toml index 5cc6c6a..13f439a 100644 --- a/plugins/rust/python-package/rate_limiter/Cargo.toml +++ b/plugins/rust/python-package/rate_limiter/Cargo.toml @@ -24,7 +24,21 @@ pyo3-stub-gen = { workspace = true } pyo3-log = { workspace = true } parking_lot = "0.12" thiserror = { workspace = true } -redis = { version = "0.32", features = ["aio", "tokio-comp", "tls-rustls", "tls-rustls-webpki-roots", "tokio-rustls-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/src/plugin.rs b/plugins/rust/python-package/rate_limiter/src/plugin.rs index c585bcd..0863a78 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 { diff --git a/plugins/tests/rate_limiter/test_redis_integration.py b/plugins/tests/rate_limiter/test_redis_integration.py index 1349786..d7d4c1e 100644 --- a/plugins/tests/rate_limiter/test_redis_integration.py +++ b/plugins/tests/rate_limiter/test_redis_integration.py @@ -1662,17 +1662,21 @@ async def test_rediss_url_lazy_handshake_reaches_connectivity_layer(self, caplog f"backend is unreachable; got result={result!r}" ) - # The error logged must indicate connectivity failure (the lazy - # handshake reached the network and the network refused / timed out) - # — NOT the InvalidClientConfig shape that means rustls itself is - # not compiled in. The latter is the failure mode of wo-tracker - # #68217 and must never recur. + # 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 "tls" not in all_messages or "connect" in all_messages or "refused" in all_messages or "timed out" in all_messages, ( - "rediss:// failure must surface as a connectivity error, not a " - f"TLS feature/config error. Captured logs: " - f"{[(r.levelname, r.getMessage()) for r in caplog.records]}" - ) 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: " From 1969f17853ce87567866e8b568f15a6261ac9b6e Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 1 May 2026 16:23:38 +0100 Subject: [PATCH 5/6] =?UTF-8?q?chore(rate-limiter):=20bump=200.0.5=20?= =?UTF-8?q?=E2=86=92=200.0.6=20for=20TLS=20support=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main bumped rate_limiter to 0.0.5 in #73 as part of a workspace-wide dependency refresh, so this PR's release slot moves to 0.0.6. The content of 0.0.6 is the TLS / rediss:// support work in this PR (crypto provider install, redis crate bump for advisory cleanup, TLS regression tests). Signed-off-by: Pratik Gandhi --- Cargo.lock | 2 +- plugins/rust/python-package/rate_limiter/Cargo.toml | 2 +- .../rate_limiter/cpex_rate_limiter/plugin-manifest.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64ad407..bcef421 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1310,7 +1310,7 @@ dependencies = [ [[package]] name = "rate_limiter" -version = "0.0.5" +version = "0.0.6" dependencies = [ "cpex_framework_bridge", "criterion", diff --git a/plugins/rust/python-package/rate_limiter/Cargo.toml b/plugins/rust/python-package/rate_limiter/Cargo.toml index 13f439a..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 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" From d2161a5e549bfb72e6dadedda9c76f23e7cd4eef Mon Sep 17 00:00:00 2001 From: Pratik Gandhi Date: Fri, 1 May 2026 16:37:03 +0100 Subject: [PATCH 6/6] test(rate-limiter): pin ensure_crypto_provider behaviour with a Rust unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-mutants flagged an uncovered mutation: replacing ensure_crypto_provider's body with () passed the existing test suite, because nothing in the Rust unit tests directly exercises the crypto provider install (TLS coverage lives on the Python integration side). Added a unit test in plugin.rs's tests module that calls ensure_crypto_provider() and asserts rustls::crypto::CryptoProvider::get_default().is_some(). - Real implementation installs the ring provider via OnceLock → get_default() returns Some → test passes. - Mutated implementation does nothing → no provider installed → get_default() returns None → test fails → mutant killed. OnceLock makes the call idempotent, so the test is order-independent within a cargo-test process. No behavioral change; pure coverage. Signed-off-by: Pratik Gandhi --- .../python-package/rate_limiter/src/plugin.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/plugins/rust/python-package/rate_limiter/src/plugin.rs b/plugins/rust/python-package/rate_limiter/src/plugin.rs index 0863a78..643d52d 100644 --- a/plugins/rust/python-package/rate_limiter/src/plugin.rs +++ b/plugins/rust/python-package/rate_limiter/src/plugin.rs @@ -540,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.