diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5a611e..e8811bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,10 +46,11 @@ jobs: flags: "--no-default-features --features aws_lc_rs,redis" - name: injected_verifier # No built-in verifier at all: the build a consumer takes when it - # injects its own `hooks::TokenVerifier`. Links no JWT crypto (and - # so no `rsa`), which is only true as long as nothing outside the - # `builtin_jwt` gate reaches for jsonwebtoken — this leg is what - # keeps that honest. The binary builds without a verifier too. + # injects its own `hooks::TokenVerifier`. Links no JWT or TLS + # crypto (and so no `rsa`), which is only true as long as nothing + # outside the backend features reaches for jsonwebtoken or a + # rustls provider — this leg is what keeps that honest. The binary + # builds without a verifier too. flags: "--no-default-features --features cli" - name: all_features # Both backends at once, the build cargo-semver-checks and docs.rs @@ -88,6 +89,18 @@ jobs: exit 1 fi + # A build without a crypto backend (`default-features = false`, what a + # transcoding-only consumer takes) must pass an advisory scan without + # the ignores in deny.toml: none of the crates they cover is linked. + - name: No ignored advisories without a crypto backend + if: matrix.backend.name == 'injected_verifier' + run: | + tree=$(cargo tree -e normal --target all --prefix none --format '{p}' ${{ matrix.backend.flags }}) + if printf '%s\n' "$tree" | grep -E '^(rsa|rustls-rustcrypto|paste) v|^rustls-webpki v0\.102\.'; then + echo "::error::the build without a crypto backend links a crate with an ignored advisory" + exit 1 + fi + # `cargo add structured-proxy` takes the default features: the library # must compile none of the binary's dependencies, which sit behind `cli`. - name: No CLI dependencies by default diff --git a/Cargo.toml b/Cargo.toml index 7c0be87..168f3fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,20 +93,22 @@ redis = { version = "1.2", features = ["tokio-comp", "connection-manager"], opti jsonwebtoken = { version = "11", default-features = false, features = [ "use_pem", ], optional = true } -# Outbound HTTPS (JWKS fetches, the rate-limit service) links no C: reqwest 0.13's -# `rustls` feature hardwires the aws-lc-rs provider (C FFI), and rustls' `ring` -# provider compiles C and assembly. `rustls-no-provider` builds rustls without a -# bundled provider; src/tls.rs hands reqwest a fully preconfigured ClientConfig -# with the pure-Rust RustCrypto provider and the Mozilla roots from webpki-roots. -# Bundling the roots keeps the binary self-contained, so it works on musl / -# scratch / distroless images with no system CA bundle. +# Outbound HTTPS (JWKS fetches, the rate-limit service). reqwest 0.13's `rustls` +# feature hardwires the aws-lc-rs provider (C FFI), so `rustls-no-provider` +# builds rustls without one; src/tls.rs hands reqwest a fully preconfigured +# ClientConfig with the Mozilla roots from webpki-roots. Bundling the roots +# keeps the binary self-contained, so it works on musl / scratch / distroless +# images with no system CA bundle. The crypto provider follows the crypto +# backend features below: none is linked without one, and the process-wide +# rustls provider the embedder installs is used instead. reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json"] } rustls = { version = "0.23", default-features = false, features = ["std", "tls12"] } -# `0.0.2-alpha` still names its `rustls-webpki` 0.102 dependency, which carries -# four CRL / name-constraint advisories; the provider only reads algorithm -# identifier constants from it, while certificates are verified by rustls' own -# (patched) webpki. See deny.toml. -rustls-rustcrypto = "0.0.2-alpha" +# The pure-Rust provider of the `rust_crypto` backend. `0.0.2-alpha` still +# names its `rustls-webpki` 0.102 dependency, which carries four CRL / +# name-constraint advisories; the provider only reads algorithm identifier +# constants from it, while certificates are verified by rustls' own (patched) +# webpki. See deny.toml. +rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } webpki-roots = "1" globset = "0.4" base64 = "0.23" @@ -130,20 +132,22 @@ default = ["rust_crypto"] # `ProxyServer::with_token_verifier`. builtin_jwt = ["dep:jsonwebtoken"] -# Crypto backend for the BUILT-IN verifier. Additive, like every Cargo feature: -# with both on, jsonwebtoken cannot infer a provider, so the built-in verifier -# installs `aws_lc_rs` (constant-time, advisory-free) for the process. They no -# longer decide for the whole dependency graph: a consumer that cannot accept -# this crate's choice supplies its own verifier instead (see `builtin_jwt`). +# Crypto backend for the BUILT-IN verifier and the outbound TLS client. +# Additive, like every Cargo feature: with both on, jsonwebtoken cannot infer a +# provider, so the built-in verifier installs `aws_lc_rs` (constant-time, +# advisory-free) for the process, and TLS uses aws-lc too. They no longer +# decide for the whole dependency graph: a consumer that cannot accept this +# crate's choice supplies its own verifier (see `builtin_jwt`) and installs its +# own rustls provider. With neither, the crate links no crypto backend at all. # # `rust_crypto` (default): pure-Rust RustCrypto backend. Pulls in `rsa`, which # carries RUSTSEC-2023-0071 (Marvin Attack). Not exploitable on our verify-only # path (public key, no private-key ops); see deny.toml / issue #48. -rust_crypto = ["builtin_jwt", "jsonwebtoken/rust_crypto"] +rust_crypto = ["builtin_jwt", "jsonwebtoken/rust_crypto", "dep:rustls-rustcrypto"] # `aws_lc_rs`: constant-time / FIPS-capable backend via aws-lc (C FFI), # advisory-free. Opt-in for consumers that allow FFI; enable with # `default-features = false, features = ["aws_lc_rs"]`. -aws_lc_rs = ["builtin_jwt", "jsonwebtoken/aws_lc_rs"] +aws_lc_rs = ["builtin_jwt", "jsonwebtoken/aws_lc_rs", "rustls/aws_lc_rs"] # Shared Redis-backed rate-limit store for multi-instance deployments. redis = ["dep:redis"] @@ -161,8 +165,10 @@ http-body-util = "0.1" # tests/upstream_controls.rs (tonic's server API cannot set success trailers). http-body = "1" # The local HTTPS server the outbound TLS client is tested against -# (src/tls/tests.rs). Default features would pull in aws-lc. +# (src/tls/tests.rs), and the provider it runs on in every build. Default +# features would pull in aws-lc. tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } +rustls-rustcrypto = "0.0.2-alpha" # benches/jwt_verify.rs. Plots and rayon are left out: numbers are enough. criterion = { version = "0.8", default-features = false, features = ["async_tokio", "cargo_bench_support"] } # The embedding-hooks integration test (tests/hooks.rs) writes hook impls using diff --git a/README.md b/README.md index 9010a04..233324f 100644 --- a/README.md +++ b/README.md @@ -791,27 +791,38 @@ async-trait = "0.1" serde_json = "1" ``` -which links no JWT crypto, and supplies the backend from its own binary. With no +which links no JWT or TLS crypto (see [Outbound TLS](#outbound-tls)), and +supplies the backend from its own binary. With no verifier injected and no backend feature, an `auth.mode: "jwt"` config is rejected at startup with that instruction, rather than silently accepting tokens. ## Outbound TLS -The proxy's own HTTPS calls (JWKS fetches, the rate-limit service) use rustls -with the pure-Rust RustCrypto provider (`rustls-rustcrypto`) and Mozilla's root -store bundled from `webpki-roots`, so no system CA bundle is needed. Neither -`ring` nor aws-lc is linked: the default build and the -`default-features = false` build contain no C crypto, which CI checks. Only the -opt-in `aws_lc_rs` JWT backend brings aws-lc in. - -The provider verifies RSA server signatures with `rsa`, so every build links -that crate, under the same RUSTSEC-2023-0071 note as the `rust_crypto` backend: -only public-key verification runs. The current provider release still names -`rustls-webpki` 0.102, whose CRL and name-constraint advisories are listed in -`deny.toml` with why they do not apply: the provider reads only algorithm -identifiers from it, and rustls verifies certificates with its own patched -`rustls-webpki`. +The proxy's own HTTP calls (JWKS fetches, the rate-limit service) use rustls +with Mozilla's root store bundled from `webpki-roots`, so no system CA bundle is +needed. The rustls crypto provider is, in order: + +1. the one your process installed with + `rustls::crypto::CryptoProvider::install_default`, if any: an explicit + choice wins; +2. aws-lc, with the `aws_lc_rs` feature; +3. the pure-Rust RustCrypto provider (`rustls-rustcrypto`), with `rust_crypto`. + +Neither `ring` nor aws-lc is linked unless you ask: the default build contains +no C crypto, which CI checks. A `default-features = false` build links no TLS +crypto provider at all, so a crate that only transcodes pulls in neither `rsa` +nor `rustls-rustcrypto`. If such a build configures a JWKS endpoint or the +rate-limit service, install a provider before building the proxy (the client +needs one even for an `http://` endpoint); otherwise startup fails with an error +that says so. + +The RustCrypto provider verifies RSA server signatures with `rsa`, under the same +RUSTSEC-2023-0071 note as the `rust_crypto` JWT backend: only public-key +verification runs. Its current release still names `rustls-webpki` 0.102, whose +CRL and name-constraint advisories are listed in `deny.toml` with why they do not +apply: the provider reads only algorithm identifiers from it, and rustls +verifies certificates with its own patched `rustls-webpki`. ## How It Works diff --git a/deny.toml b/deny.toml index dd1df59..2a1dc60 100644 --- a/deny.toml +++ b/deny.toml @@ -23,8 +23,9 @@ # (decrypt/sign), which never runs on a verify path. # Revisit when RustCrypto ships a constant-time `rsa` stable release. # -# The TLS provider has no per-algorithm features, so `rsa` is in every build, -# including `default-features = false` with an injected `hooks::TokenVerifier`. +# Both chains come with the `rust_crypto` feature: a `default-features = false` +# build links neither, and CI checks that none of the crates ignored here is in +# it. # # RUSTSEC-2026-0049 / -0098 / -0099 / -0104: `rustls-webpki` 0.102. # Chain: rustls-webpki 0.102.8 -> rustls-rustcrypto 0.0.2-alpha -> structured-proxy. diff --git a/src/auth/jwks.rs b/src/auth/jwks.rs index b66cea5..07d656d 100644 --- a/src/auth/jwks.rs +++ b/src/auth/jwks.rs @@ -53,23 +53,28 @@ const JWKS_HTTP_TIMEOUT: Duration = Duration::from_secs(5); impl JwksCache { /// Create a cache for `uri` (keys are loaded lazily on first lookup), whose /// keys are refetched after [`DEFAULT_MAX_AGE`]. - pub fn new(uri: String) -> Self { + /// + /// # Errors + /// + /// The HTTPS client cannot be built: the rustls crypto provider installed + /// for the process supports neither TLS 1.2 nor TLS 1.3. + pub fn new(uri: String) -> Result { let client = reqwest::Client::builder() .timeout(JWKS_HTTP_TIMEOUT) // Hand reqwest a fully preconfigured rustls backend rather than - // relying on a process-global default provider: no install ordering + // relying on reqwest to find a provider: no install ordering // constraint, no global side effect, safe for library/test callers. - .tls_backend_preconfigured(crate::tls::client_config()) + .tls_backend_preconfigured(crate::tls::client_config()?) .build() - .unwrap_or_default(); - Self { + .map_err(|e| format!("invalid JWKS client: {e}"))?; + Ok(Self { uri, client, set: RwLock::new(KeySet::default()), last_refresh: Mutex::new(None), max_age: DEFAULT_MAX_AGE, min_refresh_interval: MIN_REFRESH_INTERVAL, - } + }) } /// Refetch the keys once they are older than `max_age`. Refreshes stay diff --git a/src/auth/jwks/tests.rs b/src/auth/jwks/tests.rs index 0a9f778..81e25a3 100644 --- a/src/auth/jwks/tests.rs +++ b/src/auth/jwks/tests.rs @@ -111,7 +111,7 @@ async fn endpoint() -> (Arc, String) { #[tokio::test] async fn fresh_keys_are_served_from_the_cache() { let (endpoint, uri) = endpoint().await; - let cache = JwksCache::new(uri); + let cache = JwksCache::new(uri).unwrap(); assert!(cache.key_for("k1").await.is_some()); assert!(cache.key_for("k1").await.is_some()); assert_eq!(endpoint.fetches(), 1); @@ -123,6 +123,7 @@ async fn a_key_removed_by_the_provider_stops_verifying_once_the_set_ages_out() { // aged set is refetched on the next lookup, and k1 is gone. let (endpoint, uri) = endpoint().await; let cache = JwksCache::new(uri) + .unwrap() .with_max_age(Duration::ZERO) .with_min_refresh_interval(Duration::ZERO); assert!(cache.key_for("k1").await.is_some()); @@ -135,6 +136,7 @@ async fn a_key_removed_by_the_provider_stops_verifying_once_the_set_ages_out() { async fn an_unreachable_provider_keeps_the_known_keys() { let (endpoint, uri) = endpoint().await; let cache = JwksCache::new(uri) + .unwrap() .with_max_age(Duration::ZERO) .with_min_refresh_interval(Duration::ZERO); assert!(cache.key_for("k1").await.is_some()); @@ -151,7 +153,7 @@ async fn aged_keys_refresh_no_more_often_than_the_minimum_interval() { // Aged out, but a refresh has just run: the known key is used without // another fetch. let (endpoint, uri) = endpoint().await; - let cache = JwksCache::new(uri).with_max_age(Duration::ZERO); + let cache = JwksCache::new(uri).unwrap().with_max_age(Duration::ZERO); assert!(cache.key_for("k1").await.is_some()); assert!(cache.key_for("k1").await.is_some()); assert_eq!(endpoint.fetches(), 1); @@ -163,7 +165,7 @@ async fn a_lookup_overtaken_by_a_refresh_uses_the_refreshed_keys() { // slot; meanwhile another request's refresh replaces the set without k1. // The waiting lookup is throttled, and must not hand back its old k1. let (_endpoint, uri) = endpoint().await; - let cache = Arc::new(JwksCache::new(uri).with_max_age(Duration::ZERO)); + let cache = Arc::new(JwksCache::new(uri).unwrap().with_max_age(Duration::ZERO)); assert!(cache.key_for("k1").await.is_some()); let mut slot = cache.last_refresh.lock().await; @@ -192,7 +194,7 @@ async fn an_empty_key_set_is_throttled_like_any_other() { // loaded: lookups for a kid it lacks must not refetch every time. let (endpoint, uri) = endpoint().await; endpoint.answer(StatusCode::OK, serde_json::json!({ "keys": [] })); - let cache = JwksCache::new(uri); + let cache = JwksCache::new(uri).unwrap(); for _ in 0..3 { assert!(cache.key_for("k1").await.is_none()); } @@ -208,6 +210,7 @@ async fn a_lookup_during_a_refresh_waits_for_its_keys() { let interval = Duration::from_millis(50); let cache = Arc::new( JwksCache::new(uri) + .unwrap() .with_max_age(Duration::ZERO) .with_min_refresh_interval(interval), ); diff --git a/src/auth/verifier.rs b/src/auth/verifier.rs index 9a7ba9d..61294d7 100644 --- a/src/auth/verifier.rs +++ b/src/auth/verifier.rs @@ -58,7 +58,7 @@ impl ConfigVerifier { MIN_REFRESH_INTERVAL.as_secs() )); } - KeySource::Jwks(JwksCache::new(uri.clone()).with_max_age(max_age)) + KeySource::Jwks(JwksCache::new(uri.clone())?.with_max_age(max_age)) } else if let Some(pem_path) = &jwt.public_key_pem_file { let pem = std::fs::read(pem_path) .map_err(|e| format!("failed to read auth.jwt.public_key_pem_file: {e}"))?; diff --git a/src/lib.rs b/src/lib.rs index 6f00623..0705ff6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,16 @@ //! injection is how a consumer that needs a different one gets it without //! deciding for everyone else who links this crate. Such a build takes //! `default-features = false` and links no JWT crypto at all. +//! +//! ## Outbound TLS +//! +//! JWKS fetches and the rate-limit service go over rustls. The crypto provider +//! is the one the process installed with +//! `rustls::crypto::CryptoProvider::install_default`, else the one the crypto +//! backend feature brings (aws-lc for `aws_lc_rs`, RustCrypto for +//! `rust_crypto`). A build with neither feature links no TLS crypto, and a +//! config that needs an outbound client then fails at startup unless a +//! provider is installed first. // `builtin_jwt` is implied by each backend and never meant to stand alone: on // its own it would link jsonwebtoken with no provider, which panics at runtime. diff --git a/src/shield/resolve.rs b/src/shield/resolve.rs index bb98155..c68ed79 100644 --- a/src/shield/resolve.rs +++ b/src/shield/resolve.rs @@ -189,7 +189,7 @@ impl LimitService { } let client = reqwest::Client::builder() .timeout(Duration::from_millis(cfg.timeout_ms.max(1))) - .tls_backend_preconfigured(crate::tls::client_config()) + .tls_backend_preconfigured(crate::tls::client_config()?) .build() .map_err(|e| format!("invalid limit_service client: {e}"))?; let ttl = Duration::from_secs(cfg.ttl_secs.max(1)); @@ -401,163 +401,4 @@ impl LimitService { } #[cfg(test)] -mod tests { - use super::*; - - fn profiles() -> HashMap { - let mut m = HashMap::new(); - m.insert( - "premium".to_string(), - profile_from_numbers(1000, 100).unwrap(), // limit 1000 - ); - m - } - - fn jwt_limits() -> JwtLimits { - JwtLimits::from_config(&JwtLimitConfig { - tier_claim: "ratelimit_tier".to_string(), - rpm_claim: "ratelimit_rpm".to_string(), - burst_claim: "ratelimit_burst".to_string(), - }) - } - - #[test] - fn jwt_tier_name_maps_to_profile() { - let claims = serde_json::json!({ "ratelimit_tier": "premium" }); - let p = jwt_limits().resolve(&claims, &profiles()).unwrap(); - assert_eq!(p.limit, 1000); - } - - #[test] - fn jwt_direct_numbers_build_a_profile() { - let claims = serde_json::json!({ "ratelimit_rpm": 300, "ratelimit_burst": 30 }); - let p = jwt_limits().resolve(&claims, &profiles()).unwrap(); - assert_eq!(p.limit, 300); - } - - #[test] - fn jwt_tier_takes_precedence_over_numbers() { - let claims = serde_json::json!({ "ratelimit_tier": "premium", "ratelimit_rpm": 5 }); - let p = jwt_limits().resolve(&claims, &profiles()).unwrap(); - assert_eq!(p.limit, 1000); - } - - #[test] - fn jwt_unknown_tier_falls_through_to_numbers_then_none() { - // Unknown tier + no numbers → no resolution. - let claims = serde_json::json!({ "ratelimit_tier": "gold" }); - assert!(jwt_limits().resolve(&claims, &profiles()).is_none()); - // Unknown tier + numbers → numbers win. - let claims = serde_json::json!({ "ratelimit_tier": "gold", "ratelimit_rpm": 42 }); - assert_eq!( - jwt_limits().resolve(&claims, &profiles()).unwrap().limit, - 42 - ); - } - - #[tokio::test] - async fn actively_used_stale_entry_survives_eviction() { - use crate::config::LimitServiceConfig; - let svc = LimitService::build( - &LimitServiceConfig { - endpoint: "http://127.0.0.1:0/".to_string(), - ttl_secs: 1, - timeout_ms: 50, - }, - profiles(), - ) - .unwrap(); - // Simulate a service outage: the last successful fetch was long ago, so - // `at` is stale and well past evict_after, but the key is still in active - // use right now. - let old = Instant::now() - .checked_sub(Duration::from_secs(600)) - .expect("clock supports the offset"); - svc.cache.insert( - "k".to_string(), - Cached { - profile: Some(profile_from_numbers(10, 10).unwrap()), - at: old, - last_access: old, - }, - ); - let _ = svc.resolve("k"); - svc.sweep(); - assert!( - svc.cache.contains_key("k"), - "an actively-used stale entry must not be evicted during an outage" - ); - } - - fn service(endpoint: &str) -> Arc { - LimitService::build( - &LimitServiceConfig { - endpoint: endpoint.to_string(), - ttl_secs: 60, - timeout_ms: 50, - }, - profiles(), - ) - .unwrap() - } - - #[test] - fn service_unknown_tier_falls_through_to_numbers() { - let svc = service("http://127.0.0.1:9/"); - // Unknown tier but explicit numbers present → numbers win (like JWT). - let p = svc - .map_response(LimitResponse { - tier: Some("gold".to_string()), - rate_per_min: Some(50), - burst: None, - }) - .unwrap(); - assert_eq!(p.limit, 50); - } - - #[test] - fn build_rejects_non_http_endpoint() { - // Syntactically valid URLs that reqwest GET can't use must be rejected. - for ep in ["redis://127.0.0.1/", "file:///etc/passwd", "ftp://h/x"] { - let r = LimitService::build( - &LimitServiceConfig { - endpoint: ep.to_string(), - ttl_secs: 60, - timeout_ms: 50, - }, - profiles(), - ); - assert!(r.is_err(), "expected {ep} to be rejected"); - } - } - - #[test] - fn build_rejects_malformed_endpoint() { - let bad = LimitService::build( - &LimitServiceConfig { - endpoint: "not a url".to_string(), - ttl_secs: 60, - timeout_ms: 50, - }, - profiles(), - ); - assert!(bad.is_err()); - } - - #[test] - fn jwt_zero_rate_is_not_a_usable_limit() { - // A dynamic rate of 0 must not clamp to 1; it yields no limit so the - // caller falls through to the next resolver. - let claims = serde_json::json!({ "ratelimit_rpm": 0 }); - assert!(jwt_limits().resolve(&claims, &profiles()).is_none()); - } - - #[test] - fn numeric_string_claims_are_accepted() { - let claims = serde_json::json!({ "ratelimit_rpm": "250" }); - assert_eq!( - jwt_limits().resolve(&claims, &profiles()).unwrap().limit, - 250 - ); - } -} +mod tests; diff --git a/src/shield/resolve/tests.rs b/src/shield/resolve/tests.rs new file mode 100644 index 0000000..64acb0e --- /dev/null +++ b/src/shield/resolve/tests.rs @@ -0,0 +1,173 @@ +use super::*; + +fn profiles() -> HashMap { + let mut m = HashMap::new(); + m.insert( + "premium".to_string(), + profile_from_numbers(1000, 100).unwrap(), // limit 1000 + ); + m +} + +fn jwt_limits() -> JwtLimits { + JwtLimits::from_config(&JwtLimitConfig { + tier_claim: "ratelimit_tier".to_string(), + rpm_claim: "ratelimit_rpm".to_string(), + burst_claim: "ratelimit_burst".to_string(), + }) +} + +/// The service's HTTP client needs a rustls provider. A build without a +/// crypto backend links none, so the test process installs the pure-Rust one, +/// as an embedder of such a build would. +fn with_tls_provider() { + #[cfg(not(any(feature = "rust_crypto", feature = "aws_lc_rs")))] + { + use rustls::crypto::CryptoProvider; + // A concurrent test may have installed it first; either way one is set. + let installed = rustls_rustcrypto::provider().install_default().is_ok(); + assert!(installed || CryptoProvider::get_default().is_some()); + } +} + +#[test] +fn jwt_tier_name_maps_to_profile() { + let claims = serde_json::json!({ "ratelimit_tier": "premium" }); + let p = jwt_limits().resolve(&claims, &profiles()).unwrap(); + assert_eq!(p.limit, 1000); +} + +#[test] +fn jwt_direct_numbers_build_a_profile() { + let claims = serde_json::json!({ "ratelimit_rpm": 300, "ratelimit_burst": 30 }); + let p = jwt_limits().resolve(&claims, &profiles()).unwrap(); + assert_eq!(p.limit, 300); +} + +#[test] +fn jwt_tier_takes_precedence_over_numbers() { + let claims = serde_json::json!({ "ratelimit_tier": "premium", "ratelimit_rpm": 5 }); + let p = jwt_limits().resolve(&claims, &profiles()).unwrap(); + assert_eq!(p.limit, 1000); +} + +#[test] +fn jwt_unknown_tier_falls_through_to_numbers_then_none() { + // Unknown tier + no numbers → no resolution. + let claims = serde_json::json!({ "ratelimit_tier": "gold" }); + assert!(jwt_limits().resolve(&claims, &profiles()).is_none()); + // Unknown tier + numbers → numbers win. + let claims = serde_json::json!({ "ratelimit_tier": "gold", "ratelimit_rpm": 42 }); + assert_eq!( + jwt_limits().resolve(&claims, &profiles()).unwrap().limit, + 42 + ); +} + +#[tokio::test] +async fn actively_used_stale_entry_survives_eviction() { + use crate::config::LimitServiceConfig; + with_tls_provider(); + let svc = LimitService::build( + &LimitServiceConfig { + endpoint: "http://127.0.0.1:0/".to_string(), + ttl_secs: 1, + timeout_ms: 50, + }, + profiles(), + ) + .unwrap(); + // Simulate a service outage: the last successful fetch was long ago, so + // `at` is stale and well past evict_after, but the key is still in active + // use right now. + let old = Instant::now() + .checked_sub(Duration::from_secs(600)) + .expect("clock supports the offset"); + svc.cache.insert( + "k".to_string(), + Cached { + profile: Some(profile_from_numbers(10, 10).unwrap()), + at: old, + last_access: old, + }, + ); + let _ = svc.resolve("k"); + svc.sweep(); + assert!( + svc.cache.contains_key("k"), + "an actively-used stale entry must not be evicted during an outage" + ); +} + +fn service(endpoint: &str) -> Arc { + with_tls_provider(); + LimitService::build( + &LimitServiceConfig { + endpoint: endpoint.to_string(), + ttl_secs: 60, + timeout_ms: 50, + }, + profiles(), + ) + .unwrap() +} + +#[test] +fn service_unknown_tier_falls_through_to_numbers() { + let svc = service("http://127.0.0.1:9/"); + // Unknown tier but explicit numbers present → numbers win (like JWT). + let p = svc + .map_response(LimitResponse { + tier: Some("gold".to_string()), + rate_per_min: Some(50), + burst: None, + }) + .unwrap(); + assert_eq!(p.limit, 50); +} + +#[test] +fn build_rejects_non_http_endpoint() { + // Syntactically valid URLs that reqwest GET can't use must be rejected. + for ep in ["redis://127.0.0.1/", "file:///etc/passwd", "ftp://h/x"] { + let r = LimitService::build( + &LimitServiceConfig { + endpoint: ep.to_string(), + ttl_secs: 60, + timeout_ms: 50, + }, + profiles(), + ); + assert!(r.is_err(), "expected {ep} to be rejected"); + } +} + +#[test] +fn build_rejects_malformed_endpoint() { + let bad = LimitService::build( + &LimitServiceConfig { + endpoint: "not a url".to_string(), + ttl_secs: 60, + timeout_ms: 50, + }, + profiles(), + ); + assert!(bad.is_err()); +} + +#[test] +fn jwt_zero_rate_is_not_a_usable_limit() { + // A dynamic rate of 0 must not clamp to 1; it yields no limit so the + // caller falls through to the next resolver. + let claims = serde_json::json!({ "ratelimit_rpm": 0 }); + assert!(jwt_limits().resolve(&claims, &profiles()).is_none()); +} + +#[test] +fn numeric_string_claims_are_accepted() { + let claims = serde_json::json!({ "ratelimit_rpm": "250" }); + assert_eq!( + jwt_limits().resolve(&claims, &profiles()).unwrap().limit, + 250 + ); +} diff --git a/src/tls.rs b/src/tls.rs index d629bb8..a44c228 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -3,27 +3,83 @@ use std::sync::Arc; +use rustls::crypto::CryptoProvider; + /// Build the rustls client config for an outbound HTTPS client. /// /// Trusts Mozilla's root store bundled via `webpki-roots`, so the binary needs /// no system CA bundle and works in musl / scratch / distroless images. -pub(crate) fn client_config() -> rustls::ClientConfig { +/// +/// # Errors +/// +/// No crypto provider is available (see [`select_provider`]), or the one installed +/// for the process supports neither TLS 1.2 nor TLS 1.3. +pub(crate) fn client_config() -> Result { let mut roots = rustls::RootCertStore::empty(); roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - client_config_with_roots(roots) + let provider = select_provider(CryptoProvider::get_default(), builtin_provider)?; + client_config_with(provider, roots) } -/// [`client_config`] trusting `roots`. +/// A client config over `provider` trusting `roots`. /// -/// The crypto provider is the pure-Rust RustCrypto one, installed per config -/// rather than process-wide, so nothing here links C and no global default -/// has to be set first. -pub(crate) fn client_config_with_roots(roots: rustls::RootCertStore) -> rustls::ClientConfig { - rustls::ClientConfig::builder_with_provider(Arc::new(rustls_rustcrypto::provider())) +/// The provider is set per config rather than process-wide, so building one +/// has no global side effect and no install ordering to respect. +pub(crate) fn client_config_with( + provider: Arc, + roots: rustls::RootCertStore, +) -> Result { + Ok(rustls::ClientConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() - .expect("the RustCrypto provider supports the default TLS protocol versions") + .map_err(|e| format!("the rustls crypto provider cannot do TLS 1.2 or 1.3: {e}"))? .with_root_certificates(roots) - .with_no_client_auth() + .with_no_client_auth()) +} + +/// The crypto provider for outbound TLS: the one the process `installed`, else +/// the `builtin` one this crate's crypto backend brings. +/// +/// An installed provider is the application's explicit choice and wins, the +/// order rustls itself follows. Without a backend feature the crate links no +/// provider, so a build that only transcodes pulls no TLS crypto at all. +/// Every outbound client needs one, `http://` endpoints included: reqwest +/// builds its TLS connector with the client. +/// +/// # Errors +/// +/// Neither is available: the message names both ways to provide one. +fn select_provider( + installed: Option<&Arc>, + builtin: impl FnOnce() -> Option, +) -> Result, String> { + if let Some(installed) = installed { + return Ok(Arc::clone(installed)); + } + builtin().map(Arc::new).ok_or_else(|| { + "the outbound HTTP client needs a rustls crypto provider: enable the \ + `rust_crypto` or `aws_lc_rs` feature, or install one with \ + `rustls::crypto::CryptoProvider::install_default` before building the proxy" + .to_string() + }) +} + +/// aws-lc wins whenever it is compiled in, as it does for JWT verification: +/// constant-time, and free of the advisories the RustCrypto provider carries. +#[cfg(feature = "aws_lc_rs")] +fn builtin_provider() -> Option { + Some(rustls::crypto::aws_lc_rs::default_provider()) +} + +/// The pure-Rust RustCrypto provider, the only backend this build compiled. +#[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] +fn builtin_provider() -> Option { + Some(rustls_rustcrypto::provider()) +} + +/// No crypto backend is compiled in. +#[cfg(not(any(feature = "rust_crypto", feature = "aws_lc_rs")))] +fn builtin_provider() -> Option { + None } #[cfg(test)] diff --git a/src/tls/tests.rs b/src/tls/tests.rs index 2ee08e2..f42eb17 100644 --- a/src/tls/tests.rs +++ b/src/tls/tests.rs @@ -1,7 +1,8 @@ -//! The outbound TLS client against a local HTTPS server over the pure-Rust -//! provider: the handshake completes over TLS 1.3 and TLS 1.2 with ECDSA and +//! The outbound TLS client against a local HTTPS server over the provider the +//! build brings: the handshake completes over TLS 1.3 and TLS 1.2 with ECDSA and //! RSA server keys, and a certificate for another name or from an unknown CA -//! is refused. Fixtures come from `testdata/generate.sh`. +//! is refused. Then which provider the client takes, and the errors when it +//! has none. Fixtures come from `testdata/generate.sh`. use super::*; use rustls::pki_types::pem::PemObject; @@ -74,9 +75,14 @@ async fn serve( } /// A client trusting `trusted`, with `host` resolving to the test server. +/// +/// It runs on the provider the build brings (aws-lc with `aws_lc_rs`), so the +/// handshakes below cover the one production uses; a build without a backend +/// falls back to RustCrypto, as an embedder would install one. fn client(trusted: &str, host: &str, addr: SocketAddr) -> reqwest::Client { + let provider = Arc::new(builtin_provider().unwrap_or_else(rustls_rustcrypto::provider)); reqwest::Client::builder() - .tls_backend_preconfigured(client_config_with_roots(roots(trusted))) + .tls_backend_preconfigured(client_config_with(provider, roots(trusted)).unwrap()) .resolve(host, addr) .build() .unwrap() @@ -160,10 +166,11 @@ async fn certificate_from_an_unknown_ca_is_refused() { assert_eq!(server.await.unwrap(), None); } +#[cfg(any(feature = "rust_crypto", feature = "aws_lc_rs"))] #[test] fn production_config_trusts_the_bundled_mozilla_roots() { - // Same provider, and the bundled root store is not empty. - let config = client_config(); + // The backend's provider, and the bundled root store is not empty. + let config = client_config().unwrap(); assert!(!webpki_roots::TLS_SERVER_ROOTS.is_empty()); assert!(config .crypto_provider() @@ -171,3 +178,75 @@ fn production_config_trusts_the_bundled_mozilla_roots() { .iter() .any(|suite| suite.version() == &rustls::version::TLS13)); } + +/// Which provider `p` is: its key provider's type, named by `Debug`. +#[cfg(any(feature = "rust_crypto", feature = "aws_lc_rs"))] +fn kind(p: &rustls::crypto::CryptoProvider) -> String { + format!("{:?}", p.key_provider) +} + +#[cfg(feature = "aws_lc_rs")] +#[test] +fn the_aws_lc_backend_brings_the_aws_lc_provider() { + // Also with `rust_crypto` on: aws-lc wins the tie, as for JWTs. + let builtin = builtin_provider().unwrap(); + assert_eq!( + kind(&builtin), + kind(&rustls::crypto::aws_lc_rs::default_provider()) + ); + assert_ne!(kind(&builtin), kind(&rustls_rustcrypto::provider())); +} + +#[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] +#[test] +fn the_rust_crypto_backend_brings_the_rustcrypto_provider() { + let builtin = builtin_provider().unwrap(); + assert_eq!(kind(&builtin), kind(&rustls_rustcrypto::provider())); +} + +#[cfg(not(any(feature = "rust_crypto", feature = "aws_lc_rs")))] +#[test] +fn without_a_crypto_backend_no_provider_is_linked() { + // The build a transcoding-only consumer takes: no TLS crypto of its own. + assert!(builtin_provider().is_none()); +} + +#[test] +fn the_installed_provider_wins_over_the_builtin_one() { + let installed = Arc::new(rustls_rustcrypto::provider()); + let chosen = select_provider(Some(&installed), || { + panic!("the builtin provider is not built when one is installed") + }) + .unwrap(); + assert!(Arc::ptr_eq(&chosen, &installed)); +} + +#[test] +fn without_an_installed_provider_the_builtin_one_is_used() { + let chosen = select_provider(None, || Some(rustls_rustcrypto::provider())).unwrap(); + assert!(!chosen.cipher_suites.is_empty()); +} + +#[test] +fn without_any_provider_the_error_names_both_remedies() { + let err = select_provider(None, || None).unwrap_err(); + for remedy in [ + "rust_crypto", + "aws_lc_rs", + "CryptoProvider::install_default", + ] { + assert!(err.contains(remedy), "{remedy}: {err}"); + } +} + +#[test] +fn a_provider_without_tls12_or_tls13_suites_is_refused() { + // An installed provider is the embedder's; one that cannot negotiate a + // safe version is an error, not a panic. + let provider = rustls::crypto::CryptoProvider { + cipher_suites: Vec::new(), + ..rustls_rustcrypto::provider() + }; + let err = client_config_with(Arc::new(provider), roots(CA)).unwrap_err(); + assert!(err.contains("TLS 1.2 or 1.3"), "{err}"); +}