From d0b91062be42fbcf4f32b90ac2281ebe0202c6bc Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 04:31:42 +0900 Subject: [PATCH 01/13] feat(gateway): bound local limiter cardinality --- README.md | 7 + docs/runbooks/operations.md | 22 +++ src/lib.rs | 266 +++++++++++++++++++++++++++++++++--- 3 files changed, 275 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d1587583..22bc0dfb 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,13 @@ Useful environment variables: - `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero +- `RATE_LIMIT`: optional per-client gateway request budget; `0` disables local limiting +- `RATE_LIMIT_WINDOW`: fixed-window length in seconds for `RATE_LIMIT`, default `60` +- `RATE_LIMIT_MAX_CLIENTS`: maximum in-memory client buckets retained by the local limiter, default `4096` + +When the local limiter returns HTTP `429`, the response includes a `Retry-After` +header plus JSON `reason` codes that distinguish per-client quota exhaustion +from local limiter saturation (`local_rate_limiter_capacity_exceeded`). Example with persistent local state: diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 9b6b7015..f36cb8a6 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -78,6 +78,22 @@ The smoke test starts the service on a temporary port with a temporary JSON stat When `WAF_IDS_STATE_PATH` is enabled, the process writes a temporary sibling file and atomically replaces the configured state path. If a management write cannot be persisted, the in-memory mutation is rolled back and the API returns `500`. +## Local Admission Control + +`RATE_LIMIT` and `RATE_LIMIT_WINDOW` enable a per-client fixed-window limiter for +`/gateway` traffic. `RATE_LIMIT_MAX_CLIENTS` bounds the number of in-memory +client buckets the process will retain; stale buckets age out after one full +window. When the map is full, unseen clients receive `429 Too Many Requests` +with `Retry-After` and reason +`local_rate_limiter_capacity_exceeded` until older buckets expire. + +This is a local emergency guard, not the distributed quota authority described +in issue `#83`. The contract follows RFC 6585's guidance that `429` responses +may include `Retry-After`, and it aligns with OWASP ASVS 5.0 availability +controls by failing with a bounded, operator-visible response instead of +allowing attacker-controlled client cardinality to grow process memory without +limit. + ## Safe Change Procedure 1. Start new routes in `monitor` mode. @@ -107,3 +123,9 @@ This baseline is suitable for local and controlled lab deployments. Internet-fac - Live Suricata EVE tailing / shipper (HTTP ingest of EVE alerts is available at `POST /api/ids/suricata/eve`) - Live MISP REST pull or live OpenCTI GraphQL pull (HTTP STIX/MISP/OpenCTI document ingest and TAXII 2.1 poll are available at `POST /api/threat-intel/stix`, `POST /api/threat-intel/misp`, `POST /api/threat-intel/opencti`, and `POST /api/threat-intel/taxii/poll`) - human approval workflow for AI SOC recommendations that change enforcement + +## References + +- Nottingham, M., & Fielding, R. (2012). *Additional HTTP status codes* (RFC 6585). https://www.rfc-editor.org/info/rfc6585 +- OWASP Foundation. (2025). *OWASP Application Security Verification Standard 5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ +- Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 diff --git a/src/lib.rs b/src/lib.rs index 8f54751d..adeb811a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,8 @@ mod suricata_eve; mod taxii; pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; +const DEFAULT_RATE_LIMIT_MAX_CLIENTS: usize = 4_096; + #[derive(Clone)] pub struct AppState { inner: Arc>, @@ -60,10 +62,10 @@ pub struct AppState { dnsbl_origin: String, event_limit: usize, // Ephemeral per-client-IP fixed-window counters (not persisted). - // ponytail: unbounded map — add TTL eviction if client-IP cardinality grows. - rate_limiter: Arc>>, + rate_limiter: Arc>>, rate_limit: u32, rate_limit_window: u64, + rate_limit_max_clients: usize, // Max accepted request body size in bytes; oversized requests get 413. max_body_bytes: usize, // Optional Clearfolio document-viewer integration. `None` unless configured. @@ -96,6 +98,41 @@ pub struct ClearfolioConfig { pub permissions: String, } +#[derive(Debug, Clone, Copy)] +struct RateLimitBucket { + window_start: u64, + count: u32, + last_seen: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RateLimitDecision { + allowed: bool, + retry_after_seconds: u64, + reason: &'static str, +} + +impl RateLimitDecision { + const WINDOW_EXCEEDED: &'static str = "rate_limit_exceeded"; + const CAPACITY_EXCEEDED: &'static str = "local_rate_limiter_capacity_exceeded"; + + fn allowed() -> Self { + Self { + allowed: true, + retry_after_seconds: 0, + reason: "", + } + } + + fn denied(reason: &'static str, retry_after_seconds: u64) -> Self { + Self { + allowed: false, + retry_after_seconds: retry_after_seconds.max(1), + reason, + } + } +} + impl AppState { pub fn seeded(admin_token: Option) -> Self { Self::new(AppData::seeded(), AppConfig::memory(admin_token)) @@ -132,6 +169,7 @@ impl AppState { rate_limiter: Arc::new(Mutex::new(HashMap::new())), rate_limit: 0, rate_limit_window: 60, + rate_limit_max_clients: DEFAULT_RATE_LIMIT_MAX_CLIENTS, max_body_bytes: 1_048_576, clearfolio: None, soc_llm: None, @@ -168,6 +206,13 @@ impl AppState { self } + /// Bound the number of local in-memory client buckets retained by the rate + /// limiter. When full, unseen clients receive 429 until stale buckets age out. + pub fn with_rate_limit_max_clients(mut self, max_clients: usize) -> Self { + self.rate_limit_max_clients = max_clients.max(1); + self + } + /// Configure RBAC admin tokens (token -> principal). A non-empty map takes /// precedence over the single `admin_token`. Builder-style. pub fn with_admin_tokens(mut self, tokens: HashMap) -> Self { @@ -196,25 +241,50 @@ impl AppState { .map(|principal| principal.actor.clone()) } - /// Records one gateway request for `client_ip` and returns `true` if it is - /// within the configured rate limit. Unknown IPs share one bucket. - async fn allow_request(&self, client_ip: Option) -> bool { + /// Records one gateway request for `client_ip` and returns the local + /// admission decision. Unknown IPs share one bucket. + async fn allow_request(&self, client_ip: Option) -> RateLimitDecision { if self.rate_limit == 0 { - return true; + return RateLimitDecision::allowed(); } let key = client_ip.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); let now = now_unix(); let mut map = self.rate_limiter.lock().await; - let (window_start, count) = map.get(&key).copied().unwrap_or((now, 0)); + prune_rate_limit_buckets(&mut map, now, self.rate_limit_window); + if !map.contains_key(&key) && map.len() >= self.rate_limit_max_clients { + return RateLimitDecision::denied( + RateLimitDecision::CAPACITY_EXCEEDED, + self.rate_limit_window, + ); + } + let bucket = map.get(&key).copied().unwrap_or(RateLimitBucket { + window_start: now, + count: 0, + last_seen: now, + }); let (allowed, new_start, new_count) = rate_limit_step( now, - window_start, - count, + bucket.window_start, + bucket.count, self.rate_limit, self.rate_limit_window, ); - map.insert(key, (new_start, new_count)); - allowed + map.insert( + key, + RateLimitBucket { + window_start: new_start, + count: new_count, + last_seen: now, + }, + ); + if allowed { + RateLimitDecision::allowed() + } else { + RateLimitDecision::denied( + RateLimitDecision::WINDOW_EXCEEDED, + retry_after_seconds(now, new_start, self.rate_limit_window), + ) + } } async fn mutate_and_persist( @@ -2124,30 +2194,50 @@ async fn gateway( let client_ip = client_ip_from_headers(&headers); // Rate limiting runs before scoring/proxying so floods are shed cheaply. - if !state.allow_request(client_ip).await { + let rate_limit = state.allow_request(client_ip).await; + if !rate_limit.allowed { + let action = if rate_limit.reason == RateLimitDecision::CAPACITY_EXCEEDED { + "rate_limiter_saturated" + } else { + "rate_limited" + }; record_event( &state, client_ip, Some(route.id.clone()), - "rate_limited", - format!( - "rate limit exceeded ({} requests per {}s)", - state.rate_limit, state.rate_limit_window - ), + action, + match rate_limit.reason { + RateLimitDecision::CAPACITY_EXCEEDED => format!( + "local rate limiter saturated (max {} client buckets, {}s TTL)", + state.rate_limit_max_clients, state.rate_limit_window + ), + _ => format!( + "rate limit exceeded ({} requests per {}s)", + state.rate_limit, state.rate_limit_window + ), + }, 0, gateway_path, ) .await; - return ( + let mut response = ( StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ - "action": "rate_limited", + "action": action, + "reason": rate_limit.reason, "route_id": route.id, "limit": state.rate_limit, - "window_seconds": state.rate_limit_window + "window_seconds": state.rate_limit_window, + "max_clients": state.rate_limit_max_clients, + "retry_after_seconds": rate_limit.retry_after_seconds })), ) .into_response(); + let retry_after = + axum::http::HeaderValue::from_str(&rate_limit.retry_after_seconds.to_string()) + .expect("retry-after header is numeric"); + response.headers_mut().insert("retry-after", retry_after); + return response; } let body_text = String::from_utf8_lossy(&body); @@ -2389,6 +2479,21 @@ fn audit_actor(state: &AppState, headers: &HeaderMap) -> String { .to_string() } +fn prune_rate_limit_buckets( + map: &mut HashMap, + now: u64, + window_secs: u64, +) { + map.retain(|_, bucket| now.saturating_sub(bucket.last_seen) < window_secs); +} + +fn retry_after_seconds(now: u64, window_start: u64, window_secs: u64) -> u64 { + window_start + .saturating_add(window_secs) + .saturating_sub(now) + .max(1) +} + /// Parses an `ADMIN_TOKENS` string into a token -> [`AdminPrincipal`] map. /// /// Format is comma-separated items: @@ -2989,6 +3094,34 @@ pub fn parse_u64_env( } } +/// Parse a `usize` environment value (already read as an optional string), +/// returning `default` when absent and rejecting zero or malformed values. +pub fn parse_usize_env( + name: &str, + raw: Option<&str>, + default: usize, +) -> Result> { + match raw { + Some(raw) => { + let value = raw.parse::().map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{name} must be a positive integer, got {raw:?}: {error}"), + ) + })?; + if value == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{name} must be greater than 0"), + ) + .into()); + } + Ok(value) + } + None => Ok(default), + } +} + /// Read gateway configuration from the process environment, bind the listener, /// and serve until `shutdown` resolves. The binary entrypoint is a thin shim /// over this function so every branch is reachable from tests (the parse/error @@ -3023,6 +3156,11 @@ pub async fn run_from_env( std::env::var("RATE_LIMIT_WINDOW").ok().as_deref(), 60, )?; + let rate_limit_max_clients = parse_usize_env( + "RATE_LIMIT_MAX_CLIENTS", + std::env::var("RATE_LIMIT_MAX_CLIENTS").ok().as_deref(), + DEFAULT_RATE_LIMIT_MAX_CLIENTS, + )?; let admin_tokens = parse_admin_tokens( credentials .get_credential(CRED_ADMIN_TOKENS) @@ -3043,6 +3181,7 @@ pub async fn run_from_env( .await .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))? .with_rate_limit(rate_limit, rate_limit_window) + .with_rate_limit_max_clients(rate_limit_max_clients) .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) .with_max_body_size(max_body_bytes); @@ -3088,6 +3227,7 @@ mod tests { "EVENT_LIMIT", "RATE_LIMIT", "RATE_LIMIT_WINDOW", + "RATE_LIMIT_MAX_CLIENTS", "MAX_BODY_BYTES", ] { unsafe { std::env::remove_var(name) }; @@ -3122,6 +3262,20 @@ mod tests { assert!(parse_u64_env("RATE_LIMIT_WINDOW", Some("abc"), 60).is_err()); } + #[test] + fn parse_usize_env_reads_optional_env() { + assert_eq!( + parse_usize_env("RATE_LIMIT_MAX_CLIENTS", None, 7).unwrap(), + 7 + ); + assert_eq!( + parse_usize_env("RATE_LIMIT_MAX_CLIENTS", Some("120"), 1).unwrap(), + 120 + ); + assert!(parse_usize_env("RATE_LIMIT_MAX_CLIENTS", Some("0"), 1).is_err()); + assert!(parse_usize_env("RATE_LIMIT_MAX_CLIENTS", Some("abc"), 1).is_err()); + } + #[test] fn parses_and_limits_phishing_database_feeds() { let domains = parse_phishing_domains( @@ -3181,6 +3335,22 @@ mod tests { clear_run_env(); } + #[tokio::test] + async fn run_from_env_rejects_malformed_rate_limit_max_clients() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("RATE_LIMIT_MAX_CLIENTS", "not-a-number"); + } + assert!( + run_from_env(Box::pin(std::future::ready(()))) + .await + .is_err() + ); + clear_run_env(); + } + #[tokio::test] async fn run_from_env_rejects_malformed_max_body_bytes() { let _guard = ENV_GUARD.lock().await; @@ -3353,6 +3523,33 @@ mod tests { assert_eq!(rate_limit_step(160, 100, 2, 2, 60), (true, 160, 1)); } + #[test] + fn prune_rate_limit_buckets_drops_expired_clients() { + let mut map = HashMap::from([ + ( + "203.0.113.10".parse().unwrap(), + RateLimitBucket { + window_start: 10, + count: 2, + last_seen: 10, + }, + ), + ( + "203.0.113.11".parse().unwrap(), + RateLimitBucket { + window_start: 11, + count: 1, + last_seen: 11, + }, + ), + ]); + + prune_rate_limit_buckets(&mut map, 70, 60); + + assert!(!map.contains_key(&"203.0.113.10".parse::().unwrap())); + assert!(map.contains_key(&"203.0.113.11".parse::().unwrap())); + } + #[tokio::test] async fn gateway_rate_limits_per_client_ip() { let app = build_app(AppState::seeded(None).with_rate_limit(2, 60)); @@ -3370,6 +3567,35 @@ mod tests { assert_eq!(other.status(), StatusCode::OK); } + #[tokio::test] + async fn gateway_rejects_new_clients_when_local_limiter_is_full() { + let app = build_app( + AppState::seeded(None) + .with_rate_limit(2, 60) + .with_rate_limit_max_clients(1), + ); + + let first = app_request(&app, gateway_get_from_ip("/gateway/demo", "203.0.113.9")).await; + assert_eq!(first.status(), StatusCode::OK); + + let saturated = + app_request(&app, gateway_get_from_ip("/gateway/demo", "198.51.100.7")).await; + assert_eq!(saturated.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + saturated + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()), + Some("60") + ); + + let body: serde_json::Value = json_body(saturated).await; + assert_eq!(body["action"], "rate_limiter_saturated"); + assert_eq!(body["reason"], RateLimitDecision::CAPACITY_EXCEEDED); + assert_eq!(body["max_clients"], 1); + assert_eq!(body["retry_after_seconds"], 60); + } + async fn body_text(response: Response) -> String { let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); String::from_utf8(bytes.to_vec()).unwrap() From c104009826eabdc697ef1c27f0f6b43f1f012d65 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 05:44:43 +0900 Subject: [PATCH 02/13] fix(gateway): trust forwarded IPs only from approved peers --- README.md | 5 +- ...ent-service-design-primitive-ew10-2002.pdf | Bin 0 -> 76711 bytes docs/runbooks/operations.md | 11 +- src/credentials.rs | 73 +++++- src/lib.rs | 217 ++++++++++++++++-- 5 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 docs/papers/overload-management-service-design-primitive-ew10-2002.pdf diff --git a/README.md b/README.md index 22bc0dfb..480c4c4d 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,14 @@ Useful environment variables: - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero - `RATE_LIMIT`: optional per-client gateway request budget; `0` disables local limiting - `RATE_LIMIT_WINDOW`: fixed-window length in seconds for `RATE_LIMIT`, default `60` -- `RATE_LIMIT_MAX_CLIENTS`: maximum in-memory client buckets retained by the local limiter, default `4096` +- `RATE_LIMIT_MAX_CLIENTS`: maximum in-memory client buckets retained by the local limiter, default `4096`; may also be bootstrapped via `WAF_IDS_CREDENTIALS_PATH` as `rate_limit_max_clients` +- `TRUSTED_PROXY_IPS`: optional comma-separated proxy peer IPs allowed to supply `X-Forwarded-For` and `X-Real-IP` When the local limiter returns HTTP `429`, the response includes a `Retry-After` header plus JSON `reason` codes that distinguish per-client quota exhaustion from local limiter saturation (`local_rate_limiter_capacity_exceeded`). +Forwarded client-IP headers are ignored unless the connected peer IP is present +in `TRUSTED_PROXY_IPS`. Example with persistent local state: diff --git a/docs/papers/overload-management-service-design-primitive-ew10-2002.pdf b/docs/papers/overload-management-service-design-primitive-ew10-2002.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6f8fb890707eb95939d6b204ded8531d6c82a231 GIT binary patch literal 76711 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�eP8qlZKnx(ZZL{*gBOb!I%#}}onfhP~<)^R^- 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 literal 0 HcmV?d00001 diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index f36cb8a6..7aa2da02 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -86,13 +86,20 @@ client buckets the process will retain; stale buckets age out after one full window. When the map is full, unseen clients receive `429 Too Many Requests` with `Retry-After` and reason `local_rate_limiter_capacity_exceeded` until older buckets expire. +By default the limiter keys on the connected peer IP. `X-Forwarded-For` and +`X-Real-IP` are considered only when the peer IP is listed in +`TRUSTED_PROXY_IPS`, which prevents direct clients from manufacturing new local +limiter buckets with spoofed forwarding headers. This is a local emergency guard, not the distributed quota authority described in issue `#83`. The contract follows RFC 6585's guidance that `429` responses may include `Retry-After`, and it aligns with OWASP ASVS 5.0 availability controls by failing with a bounded, operator-visible response instead of allowing attacker-controlled client cardinality to grow process memory without -limit. +limit. Welsh et al. (2001) and Welsh and Culler (2002) motivate this choice: +overload control should be explicit in the service design, and excess demand +should be shed early enough to bound queue growth, latency, and memory rather +than letting attacker-driven request cardinality expand invisibly. ## Safe Change Procedure @@ -129,3 +136,5 @@ This baseline is suitable for local and controlled lab deployments. Internet-fac - Nottingham, M., & Fielding, R. (2012). *Additional HTTP status codes* (RFC 6585). https://www.rfc-editor.org/info/rfc6585 - OWASP Foundation. (2025). *OWASP Application Security Verification Standard 5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ - Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 +- Welsh, M., Culler, D., & Brewer, E. (2001). *SEDA: An architecture for well-conditioned, scalable Internet services*. Proceedings of the Eighteenth ACM Symposium on Operating Systems Principles, 230-243. https://www.sosp.org/2001/papers/welsh.pdf +- Welsh, M., & Culler, D. (2002). *Overload management as a fundamental service design primitive*. Proceedings of the 10th ACM SIGOPS European Workshop, 63-69. Local PDF: `docs/papers/overload-management-service-design-primitive-ew10-2002.pdf` diff --git a/src/credentials.rs b/src/credentials.rs index 02b7f39e..41cf26f6 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -1,4 +1,5 @@ -//! Secret-bearing configuration via a process-local credential registry. +//! Secret-bearing configuration plus narrowly scoped runtime bootstrap values +//! via a process-local credential registry. //! //! Org guidance: runtime code must not treat raw environment variables as the //! source of secrets. Environment (and optional credentials file) are bootstrap @@ -11,6 +12,7 @@ use std::{collections::HashMap, io::ErrorKind, path::Path}; /// Well-known secret keys loaded into the registry at bootstrap. pub const CRED_ADMIN_TOKEN: &str = "admin_token"; pub const CRED_ADMIN_TOKENS: &str = "admin_tokens"; +pub const CRED_RATE_LIMIT_MAX_CLIENTS: &str = "rate_limit_max_clients"; /// Where secret-bearing credentials were loaded from (never includes values). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -67,12 +69,12 @@ impl CredentialRegistry { /// Bootstrap secret-bearing credentials. /// /// Precedence: JSON credentials file (when present) wins per-key; missing - /// keys are filled from the env bootstrap values. Operational non-secret - /// config (bind address, limits, DNSBL origin) stays on env. + /// keys are filled from the env bootstrap values. pub fn bootstrap_secrets( credentials_path: Option<&Path>, env_admin_token: Option, env_admin_tokens: Option, + env_rate_limit_max_clients: Option, ) -> Result { let mut values = HashMap::new(); let mut from_file = false; @@ -88,12 +90,18 @@ impl CredentialRegistry { path.display() ) })?; - for key in [CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS] { + for key in [ + CRED_ADMIN_TOKEN, + CRED_ADMIN_TOKENS, + CRED_RATE_LIMIT_MAX_CLIENTS, + ] { if let Some(raw) = file_map.get(key) { let text = json_value_as_nonempty_string(raw); if let Some(text) = text { values.insert(key.to_string(), text); - from_file = true; + if matches!(key, CRED_ADMIN_TOKEN | CRED_ADMIN_TOKENS) { + from_file = true; + } } } } @@ -120,6 +128,11 @@ impl CredentialRegistry { values.insert(CRED_ADMIN_TOKENS.to_string(), tokens); from_env = true; } + if !values.contains_key(CRED_RATE_LIMIT_MAX_CLIENTS) + && let Some(max_clients) = env_rate_limit_max_clients.filter(|value| !value.is_empty()) + { + values.insert(CRED_RATE_LIMIT_MAX_CLIENTS.to_string(), max_clients); + } let source = if from_file { CredentialSource::File @@ -159,6 +172,7 @@ mod tests { None, Some("secret".to_string()), Some("tok:alice".to_string()), + Some("2048".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -167,13 +181,17 @@ mod tests { registry.get_credential(CRED_ADMIN_TOKENS), Some("tok:alice") ); + assert_eq!( + registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), + Some("2048") + ); assert!(registry.has_admin_auth()); } #[test] fn bootstrap_empty_when_no_secrets() { let registry = - CredentialRegistry::bootstrap_secrets(None, None, Some(String::new())).unwrap(); + CredentialRegistry::bootstrap_secrets(None, None, Some(String::new()), None).unwrap(); assert_eq!(registry.source(), CredentialSource::None); assert!(!registry.has_admin_auth()); } @@ -202,6 +220,7 @@ mod tests { Some(&path), Some("from-env".to_string()), Some("envtok:env".to_string()), + Some("1024".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -210,6 +229,10 @@ mod tests { registry.get_credential(CRED_ADMIN_TOKENS), Some("filetok:operator") ); + assert_eq!( + registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), + Some("1024") + ); let _ = std::fs::remove_dir_all(&dir); } @@ -232,6 +255,7 @@ mod tests { Some(&path), Some("ignored".to_string()), Some("envtok:bob".to_string()), + Some("3072".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -240,6 +264,10 @@ mod tests { registry.get_credential(CRED_ADMIN_TOKENS), Some("envtok:bob") ); + assert_eq!( + registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), + Some("3072") + ); let _ = std::fs::remove_dir_all(&dir); } @@ -258,6 +286,7 @@ mod tests { Some(&path), Some("env-secret".to_string()), None, + None, ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -280,8 +309,38 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("credentials.json"); std::fs::write(&path, "not-json").unwrap(); - let err = CredentialRegistry::bootstrap_secrets(Some(&path), None, None).unwrap_err(); + let err = CredentialRegistry::bootstrap_secrets(Some(&path), None, None, None).unwrap_err(); assert!(err.contains("not valid JSON")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn file_overrides_rate_limit_max_clients_without_affecting_secret_source() { + let dir = std::env::temp_dir().join(format!( + "wardnet-creds-config-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("credentials.json"); + std::fs::write(&path, r#"{"rate_limit_max_clients":"512"}"#).unwrap(); + + let registry = CredentialRegistry::bootstrap_secrets( + Some(&path), + None, + None, + Some("2048".to_string()), + ) + .unwrap(); + assert_eq!(registry.source(), CredentialSource::None); + assert_eq!( + registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), + Some("512") + ); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/lib.rs b/src/lib.rs index adeb811a..e87cb08c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ use axum::{ Json, Router, body::Bytes, - extract::{DefaultBodyLimit, Path as PathParam, Query, State}, + extract::{ConnectInfo, DefaultBodyLimit, Path as PathParam, Query, State}, http::{HeaderMap, Method, StatusCode, Uri}, response::{Html, IntoResponse, Response}, routing::{any, get, post}, @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, io::ErrorKind, - net::{IpAddr, Ipv4Addr}, + net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, sync::Arc, time::{SystemTime, UNIX_EPOCH}, @@ -42,7 +42,10 @@ mod opencti_import; mod stix_import; mod suricata_eve; mod taxii; -pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; +pub use credentials::{ + CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_RATE_LIMIT_MAX_CLIENTS, CredentialRegistry, + CredentialSource, +}; const DEFAULT_RATE_LIMIT_MAX_CLIENTS: usize = 4_096; @@ -66,6 +69,7 @@ pub struct AppState { rate_limit: u32, rate_limit_window: u64, rate_limit_max_clients: usize, + trusted_proxies: HashSet, // Max accepted request body size in bytes; oversized requests get 413. max_body_bytes: usize, // Optional Clearfolio document-viewer integration. `None` unless configured. @@ -170,6 +174,7 @@ impl AppState { rate_limit: 0, rate_limit_window: 60, rate_limit_max_clients: DEFAULT_RATE_LIMIT_MAX_CLIENTS, + trusted_proxies: config.trusted_proxies, max_body_bytes: 1_048_576, clearfolio: None, soc_llm: None, @@ -213,6 +218,12 @@ impl AppState { self } + /// Trust forwarded client-IP headers only from these connected proxy IPs. + pub fn with_trusted_proxies(mut self, trusted_proxies: HashSet) -> Self { + self.trusted_proxies = trusted_proxies; + self + } + /// Configure RBAC admin tokens (token -> principal). A non-empty map takes /// precedence over the single `admin_token`. Builder-style. pub fn with_admin_tokens(mut self, tokens: HashMap) -> Self { @@ -335,6 +346,7 @@ pub struct AppConfig { pub state_path: Option, pub dnsbl_origin: String, pub event_limit: usize, + pub trusted_proxies: HashSet, } impl AppConfig { @@ -347,6 +359,7 @@ impl AppConfig { state_path: None, dnsbl_origin: Self::DEFAULT_DNSBL_ORIGIN.to_string(), event_limit: Self::DEFAULT_EVENT_LIMIT, + trusted_proxies: HashSet::new(), } } } @@ -2169,6 +2182,7 @@ async fn dnsbl_zone(State(state): State) -> impl IntoResponse { async fn gateway( State(state): State, + ConnectInfo(peer_addr): ConnectInfo, method: Method, uri: Uri, headers: HeaderMap, @@ -2191,7 +2205,8 @@ async fn gateway( (route.clone(), data.threats.clone(), data.dnsbl.clone()) }; - let client_ip = client_ip_from_headers(&headers); + let peer_ip = Some(peer_addr.ip()); + let client_ip = client_ip_from_headers(&headers, peer_ip, &state.trusted_proxies); // Rate limiting runs before scoring/proxying so floods are shed cheaply. let rate_limit = state.allow_request(client_ip).await; @@ -2308,7 +2323,21 @@ async fn gateway( } } -fn client_ip_from_headers(headers: &HeaderMap) -> Option { +fn client_ip_from_headers( + headers: &HeaderMap, + peer_ip: Option, + trusted_proxies: &HashSet, +) -> Option { + if let Some(peer_ip) = peer_ip { + if trusted_proxies.contains(&peer_ip) { + return forwarded_client_ip(headers).or(Some(peer_ip)); + } + return Some(peer_ip); + } + forwarded_client_ip(headers) +} + +fn forwarded_client_ip(headers: &HeaderMap) -> Option { headers .get("x-forwarded-for") .and_then(|value| value.to_str().ok()) @@ -3122,6 +3151,30 @@ pub fn parse_usize_env( } } +pub fn parse_ip_set_env( + name: &str, + raw: Option<&str>, +) -> Result, Box> { + let mut values = HashSet::new(); + let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok(values); + }; + for candidate in raw + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let ip = candidate.parse::().map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{name} must be a comma-separated list of IP addresses, got {candidate:?}: {error}"), + ) + })?; + values.insert(ip); + } + Ok(values) +} + /// Read gateway configuration from the process environment, bind the listener, /// and serve until `shutdown` resolves. The binary entrypoint is a thin shim /// over this function so every branch is reachable from tests (the parse/error @@ -3140,6 +3193,11 @@ pub async fn run_from_env( credentials_path.as_deref(), std::env::var("ADMIN_TOKEN").ok(), std::env::var("ADMIN_TOKENS").ok(), + std::env::var("RATE_LIMIT_MAX_CLIENTS").ok(), + )?; + let trusted_proxies = parse_ip_set_env( + "TRUSTED_PROXY_IPS", + std::env::var("TRUSTED_PROXY_IPS").ok().as_deref(), )?; let config = AppConfig { admin_token: credentials @@ -3149,6 +3207,7 @@ pub async fn run_from_env( dnsbl_origin: std::env::var("DNSBL_ORIGIN") .unwrap_or_else(|_| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()), event_limit: parse_event_limit(std::env::var("EVENT_LIMIT").ok().as_deref())?, + trusted_proxies, }; let rate_limit = parse_u32_env("RATE_LIMIT", std::env::var("RATE_LIMIT").ok().as_deref(), 0)?; let rate_limit_window = parse_u64_env( @@ -3158,7 +3217,7 @@ pub async fn run_from_env( )?; let rate_limit_max_clients = parse_usize_env( "RATE_LIMIT_MAX_CLIENTS", - std::env::var("RATE_LIMIT_MAX_CLIENTS").ok().as_deref(), + credentials.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), DEFAULT_RATE_LIMIT_MAX_CLIENTS, )?; let admin_tokens = parse_admin_tokens( @@ -3185,9 +3244,12 @@ pub async fn run_from_env( .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) .with_max_body_size(max_body_bytes); - let served = axum::serve(listener, build_app(state)) - .with_graceful_shutdown(shutdown) - .await; + let served = axum::serve( + listener, + build_app(state).into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown) + .await; served?; Ok(()) } @@ -3203,7 +3265,7 @@ mod tests { use std::{ future::IntoFuture, io::{Read, Write}, - net::TcpListener as StdTcpListener, + net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener as StdTcpListener}, thread, time::{SystemTime, UNIX_EPOCH}, }; @@ -3228,6 +3290,7 @@ mod tests { "RATE_LIMIT", "RATE_LIMIT_WINDOW", "RATE_LIMIT_MAX_CLIENTS", + "TRUSTED_PROXY_IPS", "MAX_BODY_BYTES", ] { unsafe { std::env::remove_var(name) }; @@ -3276,6 +3339,23 @@ mod tests { assert!(parse_usize_env("RATE_LIMIT_MAX_CLIENTS", Some("abc"), 1).is_err()); } + #[test] + fn parse_ip_set_env_reads_optional_env() { + assert!( + parse_ip_set_env("TRUSTED_PROXY_IPS", None) + .unwrap() + .is_empty() + ); + assert_eq!( + parse_ip_set_env("TRUSTED_PROXY_IPS", Some("203.0.113.9, 198.51.100.7")).unwrap(), + HashSet::from([ + "203.0.113.9".parse::().unwrap(), + "198.51.100.7".parse::().unwrap(), + ]) + ); + assert!(parse_ip_set_env("TRUSTED_PROXY_IPS", Some("not-an-ip")).is_err()); + } + #[test] fn parses_and_limits_phishing_database_feeds() { let domains = parse_phishing_domains( @@ -3462,10 +3542,29 @@ mod tests { } } - async fn app_request(app: &Router, request: Request) -> Response { + async fn app_request(app: &Router, mut request: Request) -> Response { + if request + .extensions() + .get::>() + .is_none() + { + insert_peer(&mut request, IpAddr::V4(Ipv4Addr::LOCALHOST)); + } app.clone().oneshot(request).await.unwrap() } + fn insert_peer(request: &mut Request, ip: IpAddr) { + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::V4(SocketAddrV4::new( + match ip { + IpAddr::V4(ip) => ip, + IpAddr::V6(_) => Ipv4Addr::LOCALHOST, + }, + 443, + )))); + } + fn empty_request(method: Method, uri: &str) -> Request { Request::builder() .method(method) @@ -3502,12 +3601,26 @@ mod tests { } fn gateway_get_from_ip(uri: &str, ip: &str) -> Request { - Request::builder() + let ip = ip.parse::().unwrap(); + let mut request = Request::builder() .method(Method::GET) .uri(uri) - .header("x-forwarded-for", ip) + .header("x-forwarded-for", ip.to_string()) .body(Body::empty()) - .unwrap() + .unwrap(); + insert_peer(&mut request, ip); + request + } + + fn gateway_get_via_proxy(uri: &str, forwarded_ip: &str, proxy_ip: &str) -> Request { + let mut request = Request::builder() + .method(Method::GET) + .uri(uri) + .header("x-forwarded-for", forwarded_ip) + .body(Body::empty()) + .unwrap(); + insert_peer(&mut request, proxy_ip.parse::().unwrap()); + request } #[test] @@ -3596,6 +3709,50 @@ mod tests { assert_eq!(body["retry_after_seconds"], 60); } + #[tokio::test] + async fn gateway_ignores_forwarded_ip_from_untrusted_peer_for_rate_limiting() { + let app = build_app( + AppState::seeded(None) + .with_rate_limit(1, 60) + .with_rate_limit_max_clients(2), + ); + let first = app_request( + &app, + gateway_get_via_proxy("/gateway/demo", "203.0.113.9", "198.51.100.10"), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + + let blocked = app_request( + &app, + gateway_get_via_proxy("/gateway/demo", "203.0.113.11", "198.51.100.10"), + ) + .await; + assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn gateway_uses_forwarded_ip_from_trusted_proxy_for_rate_limiting() { + let app = build_app( + AppState::seeded(None) + .with_rate_limit(1, 60) + .with_trusted_proxies(HashSet::from(["198.51.100.10".parse().unwrap()])), + ); + let first = app_request( + &app, + gateway_get_via_proxy("/gateway/demo", "203.0.113.9", "198.51.100.10"), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + + let second = app_request( + &app, + gateway_get_via_proxy("/gateway/demo", "203.0.113.11", "198.51.100.10"), + ) + .await; + assert_eq!(second.status(), StatusCode::OK); + } + async fn body_text(response: Response) -> String { let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); String::from_utf8(bytes.to_vec()).unwrap() @@ -4249,6 +4406,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example.".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }) .await .unwrap(); @@ -4397,12 +4555,13 @@ mod tests { .await; assert_eq!(saved_dnsbl.code, "127.0.0.9"); - let gateway_request = Request::builder() + let mut gateway_request = Request::builder() .method(Method::POST) .uri("/gateway/secure/login?q=DROP%20TABLE") .header("x-forwarded-for", "198.51.100.7, 10.0.0.1") .body(Body::from("payload")) .unwrap(); + insert_peer(&mut gateway_request, "198.51.100.7".parse().unwrap()); let response = app_request(&app, gateway_request).await; assert_eq!(response.status(), StatusCode::FORBIDDEN); assert!(body_text(response).await.contains("\"action\":\"blocked\"")); @@ -4459,6 +4618,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }) .await .unwrap(); @@ -4527,6 +4687,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }) .await .unwrap(); @@ -5785,6 +5946,7 @@ mod tests { state_path: None, dnsbl_origin: "dnsbl.local".to_string(), event_limit: 20, + trusted_proxies: HashSet::new(), }, ); let app = build_app(state); @@ -5867,6 +6029,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example.".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }) .await .unwrap(); @@ -5889,6 +6052,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }) .await .unwrap(); @@ -6475,6 +6639,7 @@ mod tests { state_path: None, dnsbl_origin: " . ".to_string(), event_limit: 0, + trusted_proxies: HashSet::new(), }) .await .unwrap(); @@ -6508,7 +6673,20 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert("x-forwarded-for", HeaderValue::from_static("not-an-ip")); - assert_eq!(client_ip_from_headers(&headers), None); + assert_eq!( + client_ip_from_headers(&headers, None, &HashSet::new()), + None + ); + + let trusted_peer = "198.51.100.10".parse().unwrap(); + assert_eq!( + client_ip_from_headers( + &HeaderMap::new(), + Some(trusted_peer), + &HashSet::from([trusted_peer]), + ), + Some(trusted_peer) + ); let valid_path = temp_state_path("valid-load"); fs::write( @@ -6522,6 +6700,7 @@ mod tests { state_path: Some(valid_path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }) .await .unwrap(); @@ -6548,6 +6727,7 @@ mod tests { state_path: Some(invalid_path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }) .await; assert!(result.is_err()); @@ -6600,6 +6780,7 @@ mod tests { state_path: Some(read_only_file.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }) .await; assert!( @@ -6620,6 +6801,7 @@ mod tests { state_path: Some(read_only_dir.join("state.json")), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }) .await; assert!( @@ -6660,6 +6842,7 @@ mod tests { state_path: Some(failing_path.clone()), dnsbl_origin: "dnsbl.local".to_string(), event_limit: 10, + trusted_proxies: HashSet::new(), }, ); let app = build_app(state); @@ -6779,6 +6962,7 @@ mod tests { state_path: None, dnsbl_origin: "dnsbl.example".to_string(), event_limit: 2, + trusted_proxies: HashSet::new(), }, ); @@ -6829,6 +7013,7 @@ mod tests { state_path: Some(PathBuf::from("state.json")), dnsbl_origin: "dnsbl.example.".to_string(), event_limit: 25, + trusted_proxies: HashSet::new(), }, ); From 21a141d2902a553bbc3febc59087e9605b884483 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 06:55:19 +0900 Subject: [PATCH 03/13] fix(gateway): preserve standalone router rate limiting --- src/credentials.rs | 6 ++++ src/lib.rs | 72 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/credentials.rs b/src/credentials.rs index 41cf26f6..2053c0c1 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -28,6 +28,7 @@ pub enum CredentialSource { } impl CredentialSource { + /// Return the stable telemetry label exposed in health/support surfaces. pub fn as_str(self) -> &'static str { match self { Self::File => "file", @@ -46,18 +47,22 @@ pub struct CredentialRegistry { } impl CredentialRegistry { + /// Construct an empty registry with no bootstrapped credentials. pub fn empty() -> Self { Self::default() } + /// Read a previously bootstrapped credential or config override by name. pub fn get_credential(&self, name: &str) -> Option<&str> { self.values.get(name).map(String::as_str) } + /// Report where secret-bearing bootstrap values came from. pub fn source(&self) -> CredentialSource { self.source } + /// True when at least one admin authentication path is configured. pub fn has_admin_auth(&self) -> bool { self.get_credential(CRED_ADMIN_TOKEN) .is_some_and(|v| !v.is_empty()) @@ -146,6 +151,7 @@ impl CredentialRegistry { } } +/// Convert supported JSON credential values into non-empty strings. fn json_value_as_nonempty_string(value: &serde_json::Value) -> Option { match value { serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()), diff --git a/src/lib.rs b/src/lib.rs index e87cb08c..3ae7996a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ use axum::{ - Json, Router, + Extension, Json, Router, body::Bytes, extract::{ConnectInfo, DefaultBodyLimit, Path as PathParam, Query, State}, http::{HeaderMap, Method, StatusCode, Uri}, @@ -120,6 +120,7 @@ impl RateLimitDecision { const WINDOW_EXCEEDED: &'static str = "rate_limit_exceeded"; const CAPACITY_EXCEEDED: &'static str = "local_rate_limiter_capacity_exceeded"; + /// Build the success result for an admitted request. fn allowed() -> Self { Self { allowed: true, @@ -128,6 +129,7 @@ impl RateLimitDecision { } } + /// Build a denied decision with a stable reason and retry horizon. fn denied(reason: &'static str, retry_after_seconds: u64) -> Self { Self { allowed: false, @@ -2182,7 +2184,7 @@ async fn dnsbl_zone(State(state): State) -> impl IntoResponse { async fn gateway( State(state): State, - ConnectInfo(peer_addr): ConnectInfo, + peer_addr: Option>>, method: Method, uri: Uri, headers: HeaderMap, @@ -2205,7 +2207,7 @@ async fn gateway( (route.clone(), data.threats.clone(), data.dnsbl.clone()) }; - let peer_ip = Some(peer_addr.ip()); + let peer_ip = peer_addr.map(|Extension(ConnectInfo(peer_addr))| peer_addr.ip()); let client_ip = client_ip_from_headers(&headers, peer_ip, &state.trusted_proxies); // Rate limiting runs before scoring/proxying so floods are shed cheaply. @@ -2323,6 +2325,8 @@ async fn gateway( } } +/// Resolve the client IP for rate limiting, trusting forwarded headers only +/// when the connected peer is an explicitly trusted proxy. fn client_ip_from_headers( headers: &HeaderMap, peer_ip: Option, @@ -2337,11 +2341,12 @@ fn client_ip_from_headers( forwarded_client_ip(headers) } +/// Extract a forwarded client IP from standard proxy headers. fn forwarded_client_ip(headers: &HeaderMap) -> Option { headers .get("x-forwarded-for") .and_then(|value| value.to_str().ok()) - .and_then(|value| value.split(',').next()) + .and_then(|value| value.rsplit(',').next()) .map(str::trim) .or_else(|| { headers @@ -2508,6 +2513,7 @@ fn audit_actor(state: &AppState, headers: &HeaderMap) -> String { .to_string() } +/// Drop client buckets that have been idle for at least one rate-limit window. fn prune_rate_limit_buckets( map: &mut HashMap, now: u64, @@ -2516,6 +2522,7 @@ fn prune_rate_limit_buckets( map.retain(|_, bucket| now.saturating_sub(bucket.last_seen) < window_secs); } +/// Compute the `Retry-After` value for a fixed-window rate-limit rejection. fn retry_after_seconds(now: u64, window_start: u64, window_secs: u64) -> u64 { window_start .saturating_add(window_secs) @@ -3151,6 +3158,7 @@ pub fn parse_usize_env( } } +/// Parse a comma-separated set of IP addresses from an optional env value. pub fn parse_ip_set_env( name: &str, raw: Option<&str>, @@ -3753,6 +3761,62 @@ mod tests { assert_eq!(second.status(), StatusCode::OK); } + #[tokio::test] + async fn gateway_uses_rightmost_forwarded_hop_from_trusted_proxy() { + let app = build_app( + AppState::seeded(None) + .with_rate_limit(1, 60) + .with_trusted_proxies(HashSet::from(["198.51.100.10".parse().unwrap()])), + ); + let first = app_request( + &app, + gateway_get_via_proxy( + "/gateway/demo", + "203.0.113.200, 203.0.113.9", + "198.51.100.10", + ), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + + let blocked = app_request( + &app, + gateway_get_via_proxy( + "/gateway/demo", + "198.51.100.200, 203.0.113.9", + "198.51.100.10", + ), + ) + .await; + assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn gateway_accepts_requests_without_connect_info() { + let app = build_app(AppState::seeded(None).with_rate_limit(2, 60)); + + let first = app + .clone() + .oneshot(empty_request(Method::GET, "/gateway/demo")) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::OK); + + let second = app + .clone() + .oneshot(empty_request(Method::GET, "/gateway/demo")) + .await + .unwrap(); + assert_eq!(second.status(), StatusCode::OK); + + let third = app + .clone() + .oneshot(empty_request(Method::GET, "/gateway/demo")) + .await + .unwrap(); + assert_eq!(third.status(), StatusCode::TOO_MANY_REQUESTS); + } + async fn body_text(response: Response) -> String { let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); String::from_utf8(bytes.to_vec()).unwrap() From f678ed35d0a2c479779177c3ee8cb5b2e8c759df Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 08:04:05 +0900 Subject: [PATCH 04/13] fix(gateway): walk trusted proxy chains from the peer --- src/lib.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 3ae7996a..0717dbcb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2334,13 +2334,40 @@ fn client_ip_from_headers( ) -> Option { if let Some(peer_ip) = peer_ip { if trusted_proxies.contains(&peer_ip) { - return forwarded_client_ip(headers).or(Some(peer_ip)); + return trusted_forwarded_client_ip(headers, peer_ip, trusted_proxies) + .or(Some(peer_ip)); } return Some(peer_ip); } forwarded_client_ip(headers) } +/// Extract the first untrusted client IP from a trusted proxy chain. +fn trusted_forwarded_client_ip( + headers: &HeaderMap, + peer_ip: IpAddr, + trusted_proxies: &HashSet, +) -> Option { + let value = headers + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok())?; + + let mut current_hop = peer_ip; + for hop in value.rsplit(',').map(str::trim) { + let candidate = hop.parse::().ok()?; + if !trusted_proxies.contains(¤t_hop) { + return Some(current_hop); + } + current_hop = candidate; + } + + if trusted_proxies.contains(¤t_hop) { + None + } else { + Some(current_hop) + } +} + /// Extract a forwarded client IP from standard proxy headers. fn forwarded_client_ip(headers: &HeaderMap) -> Option { headers @@ -3791,6 +3818,51 @@ mod tests { assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS); } + #[tokio::test] + async fn gateway_walks_trusted_proxy_chain_right_to_left() { + let app = build_app( + AppState::seeded(None) + .with_rate_limit(1, 60) + .with_trusted_proxies(HashSet::from([ + "198.51.100.10".parse().unwrap(), + "198.51.100.11".parse().unwrap(), + ])), + ); + + let first = app_request( + &app, + gateway_get_via_proxy( + "/gateway/demo", + "203.0.113.9, 198.51.100.10", + "198.51.100.11", + ), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + + let second = app_request( + &app, + gateway_get_via_proxy( + "/gateway/demo", + "203.0.113.20, 198.51.100.10", + "198.51.100.11", + ), + ) + .await; + assert_eq!(second.status(), StatusCode::OK); + + let blocked = app_request( + &app, + gateway_get_via_proxy( + "/gateway/demo", + "203.0.113.9, 198.51.100.10", + "198.51.100.11", + ), + ) + .await; + assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS); + } + #[tokio::test] async fn gateway_accepts_requests_without_connect_info() { let app = build_app(AppState::seeded(None).with_rate_limit(2, 60)); From 903dc313d49d40a8ce7bc58e08688cff48cc0c75 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 09:13:11 +0900 Subject: [PATCH 05/13] fix(gateway): preserve trusted proxy real-ip fallback --- src/credentials.rs | 35 ++++++++++++++++++++++++++++++-- src/lib.rs | 50 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/credentials.rs b/src/credentials.rs index 2053c0c1..9ab5703e 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -13,6 +13,7 @@ use std::{collections::HashMap, io::ErrorKind, path::Path}; pub const CRED_ADMIN_TOKEN: &str = "admin_token"; pub const CRED_ADMIN_TOKENS: &str = "admin_tokens"; pub const CRED_RATE_LIMIT_MAX_CLIENTS: &str = "rate_limit_max_clients"; +pub const CRED_TRUSTED_PROXY_IPS: &str = "trusted_proxy_ips"; /// Where secret-bearing credentials were loaded from (never includes values). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -80,6 +81,7 @@ impl CredentialRegistry { env_admin_token: Option, env_admin_tokens: Option, env_rate_limit_max_clients: Option, + env_trusted_proxy_ips: Option, ) -> Result { let mut values = HashMap::new(); let mut from_file = false; @@ -99,6 +101,7 @@ impl CredentialRegistry { CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_RATE_LIMIT_MAX_CLIENTS, + CRED_TRUSTED_PROXY_IPS, ] { if let Some(raw) = file_map.get(key) { let text = json_value_as_nonempty_string(raw); @@ -138,6 +141,11 @@ impl CredentialRegistry { { values.insert(CRED_RATE_LIMIT_MAX_CLIENTS.to_string(), max_clients); } + if !values.contains_key(CRED_TRUSTED_PROXY_IPS) + && let Some(trusted_proxy_ips) = env_trusted_proxy_ips.filter(|value| !value.is_empty()) + { + values.insert(CRED_TRUSTED_PROXY_IPS.to_string(), trusted_proxy_ips); + } let source = if from_file { CredentialSource::File @@ -179,6 +187,7 @@ mod tests { Some("secret".to_string()), Some("tok:alice".to_string()), Some("2048".to_string()), + Some("198.51.100.10,198.51.100.11".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -191,13 +200,18 @@ mod tests { registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), Some("2048") ); + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_IPS), + Some("198.51.100.10,198.51.100.11") + ); assert!(registry.has_admin_auth()); } #[test] fn bootstrap_empty_when_no_secrets() { let registry = - CredentialRegistry::bootstrap_secrets(None, None, Some(String::new()), None).unwrap(); + CredentialRegistry::bootstrap_secrets(None, None, Some(String::new()), None, None) + .unwrap(); assert_eq!(registry.source(), CredentialSource::None); assert!(!registry.has_admin_auth()); } @@ -227,6 +241,7 @@ mod tests { Some("from-env".to_string()), Some("envtok:env".to_string()), Some("1024".to_string()), + Some("198.51.100.10".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -239,6 +254,10 @@ mod tests { registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), Some("1024") ); + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_IPS), + Some("198.51.100.10") + ); let _ = std::fs::remove_dir_all(&dir); } @@ -262,6 +281,7 @@ mod tests { Some("ignored".to_string()), Some("envtok:bob".to_string()), Some("3072".to_string()), + Some("198.51.100.10".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -274,6 +294,10 @@ mod tests { registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), Some("3072") ); + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_IPS), + Some("198.51.100.10") + ); let _ = std::fs::remove_dir_all(&dir); } @@ -293,6 +317,7 @@ mod tests { Some("env-secret".to_string()), None, None, + None, ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -315,7 +340,8 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("credentials.json"); std::fs::write(&path, "not-json").unwrap(); - let err = CredentialRegistry::bootstrap_secrets(Some(&path), None, None, None).unwrap_err(); + let err = + CredentialRegistry::bootstrap_secrets(Some(&path), None, None, None, None).unwrap_err(); assert!(err.contains("not valid JSON")); let _ = std::fs::remove_dir_all(&dir); } @@ -339,6 +365,7 @@ mod tests { None, None, Some("2048".to_string()), + Some("198.51.100.10".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::None); @@ -346,6 +373,10 @@ mod tests { registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), Some("512") ); + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_IPS), + Some("198.51.100.10") + ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/lib.rs b/src/lib.rs index 0717dbcb..573b7d18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,8 +43,8 @@ mod stix_import; mod suricata_eve; mod taxii; pub use credentials::{ - CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_RATE_LIMIT_MAX_CLIENTS, CredentialRegistry, - CredentialSource, + CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_RATE_LIMIT_MAX_CLIENTS, CRED_TRUSTED_PROXY_IPS, + CredentialRegistry, CredentialSource, }; const DEFAULT_RATE_LIMIT_MAX_CLIENTS: usize = 4_096; @@ -2348,9 +2348,15 @@ fn trusted_forwarded_client_ip( peer_ip: IpAddr, trusted_proxies: &HashSet, ) -> Option { - let value = headers + let Some(value) = headers .get("x-forwarded-for") - .and_then(|value| value.to_str().ok())?; + .and_then(|value| value.to_str().ok()) + else { + return headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()); + }; let mut current_hop = peer_ip; for hop in value.rsplit(',').map(str::trim) { @@ -3229,10 +3235,11 @@ pub async fn run_from_env( std::env::var("ADMIN_TOKEN").ok(), std::env::var("ADMIN_TOKENS").ok(), std::env::var("RATE_LIMIT_MAX_CLIENTS").ok(), + std::env::var("TRUSTED_PROXY_IPS").ok(), )?; let trusted_proxies = parse_ip_set_env( "TRUSTED_PROXY_IPS", - std::env::var("TRUSTED_PROXY_IPS").ok().as_deref(), + credentials.get_credential(CRED_TRUSTED_PROXY_IPS), )?; let config = AppConfig { admin_token: credentials @@ -3658,6 +3665,17 @@ mod tests { request } + fn gateway_get_via_proxy_real_ip(uri: &str, real_ip: &str, proxy_ip: &str) -> Request { + let mut request = Request::builder() + .method(Method::GET) + .uri(uri) + .header("x-real-ip", real_ip) + .body(Body::empty()) + .unwrap(); + insert_peer(&mut request, proxy_ip.parse::().unwrap()); + request + } + #[test] fn rate_limit_step_enforces_fixed_window() { // limit 0 disables limiting entirely. @@ -3788,6 +3806,28 @@ mod tests { assert_eq!(second.status(), StatusCode::OK); } + #[tokio::test] + async fn gateway_uses_real_ip_from_trusted_proxy_for_rate_limiting() { + let app = build_app( + AppState::seeded(None) + .with_rate_limit(1, 60) + .with_trusted_proxies(HashSet::from(["198.51.100.10".parse().unwrap()])), + ); + let first = app_request( + &app, + gateway_get_via_proxy_real_ip("/gateway/demo", "203.0.113.9", "198.51.100.10"), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + + let second = app_request( + &app, + gateway_get_via_proxy_real_ip("/gateway/demo", "203.0.113.11", "198.51.100.10"), + ) + .await; + assert_eq!(second.status(), StatusCode::OK); + } + #[tokio::test] async fn gateway_uses_rightmost_forwarded_hop_from_trusted_proxy() { let app = build_app( From a9b88b980191ec8a9075c0d506436e3f6eef970b Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 13:45:38 +0900 Subject: [PATCH 06/13] style(gateway): satisfy rustfmt after merge --- src/credentials.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/credentials.rs b/src/credentials.rs index 38edeaea..79a3db5a 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -225,8 +225,8 @@ mod tests { #[test] fn bootstrap_empty_when_no_secrets() { - let registry = CredentialRegistry::bootstrap_secrets(None, None, Some(String::new())) - .unwrap(); + let registry = + CredentialRegistry::bootstrap_secrets(None, None, Some(String::new())).unwrap(); assert_eq!(registry.source(), CredentialSource::None); assert!(!registry.has_admin_auth()); } From 1d4b7cd39ba544c729687e443499c0caebab7982 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 17:24:18 +0900 Subject: [PATCH 07/13] refactor(config): bootstrap trusted proxy policy via registry --- src/credentials.rs | 14 ++++++++++++-- src/lib.rs | 4 +--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/credentials.rs b/src/credentials.rs index 79a3db5a..81be9a93 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -80,13 +80,23 @@ impl CredentialRegistry { credentials_path: Option<&Path>, env_admin_token: Option, env_admin_tokens: Option, + ) -> Result { + Self::bootstrap_runtime_registry(credentials_path, env_admin_token, env_admin_tokens) + } + + /// Bootstrap secret-bearing credentials plus env-transported runtime + /// policy overrides, keeping env access localized to registry bootstrap. + pub fn bootstrap_runtime_registry( + credentials_path: Option<&Path>, + env_admin_token: Option, + env_admin_tokens: Option, ) -> Result { Self::bootstrap_with_runtime_overrides( credentials_path, env_admin_token, env_admin_tokens, - None, - None, + std::env::var("RATE_LIMIT_MAX_CLIENTS").ok(), + std::env::var("TRUSTED_PROXY_IPS").ok(), ) } diff --git a/src/lib.rs b/src/lib.rs index d6251781..ca8e62f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3507,12 +3507,10 @@ pub async fn run_from_env( let credentials_path = std::env::var("WAF_IDS_CREDENTIALS_PATH") .ok() .map(PathBuf::from); - let credentials = CredentialRegistry::bootstrap_with_runtime_overrides( + let credentials = CredentialRegistry::bootstrap_runtime_registry( credentials_path.as_deref(), std::env::var("ADMIN_TOKEN").ok(), std::env::var("ADMIN_TOKENS").ok(), - std::env::var("RATE_LIMIT_MAX_CLIENTS").ok(), - std::env::var("TRUSTED_PROXY_IPS").ok(), )?; let trusted_proxies = parse_ip_set_env( "TRUSTED_PROXY_IPS", From 9f319ea9832b24c28320eb071f6efe05f93694b5 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 18:30:31 +0900 Subject: [PATCH 08/13] fix(config): keep secret bootstrap isolated --- src/credentials.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/credentials.rs b/src/credentials.rs index 81be9a93..59759022 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -81,7 +81,13 @@ impl CredentialRegistry { env_admin_token: Option, env_admin_tokens: Option, ) -> Result { - Self::bootstrap_runtime_registry(credentials_path, env_admin_token, env_admin_tokens) + Self::bootstrap_with_runtime_overrides( + credentials_path, + env_admin_token, + env_admin_tokens, + None, + None, + ) } /// Bootstrap secret-bearing credentials plus env-transported runtime @@ -241,6 +247,23 @@ mod tests { assert!(!registry.has_admin_auth()); } + #[test] + fn bootstrap_secrets_excludes_runtime_policy_overrides() { + let registry = CredentialRegistry::bootstrap_secrets( + None, + Some("secret".to_string()), + Some("tok:alice".to_string()), + ) + .unwrap(); + assert_eq!(registry.get_credential(CRED_ADMIN_TOKEN), Some("secret")); + assert_eq!( + registry.get_credential(CRED_ADMIN_TOKENS), + Some("tok:alice") + ); + assert_eq!(registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), None); + assert_eq!(registry.get_credential(CRED_TRUSTED_PROXY_IPS), None); + } + #[test] fn file_overrides_env_per_key() { let dir = std::env::temp_dir().join(format!( From dc4758637124121c2aa7da16f7144b581a338af5 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 19:39:09 +0900 Subject: [PATCH 09/13] fix(gateway): stop trusting x-real-ip for limiter identity --- README.md | 2 +- docs/runbooks/operations.md | 9 ++++---- src/lib.rs | 46 +++++++++++++++---------------------- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 480c4c4d..e75625f6 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Useful environment variables: - `RATE_LIMIT`: optional per-client gateway request budget; `0` disables local limiting - `RATE_LIMIT_WINDOW`: fixed-window length in seconds for `RATE_LIMIT`, default `60` - `RATE_LIMIT_MAX_CLIENTS`: maximum in-memory client buckets retained by the local limiter, default `4096`; may also be bootstrapped via `WAF_IDS_CREDENTIALS_PATH` as `rate_limit_max_clients` -- `TRUSTED_PROXY_IPS`: optional comma-separated proxy peer IPs allowed to supply `X-Forwarded-For` and `X-Real-IP` +- `TRUSTED_PROXY_IPS`: optional comma-separated proxy peer IPs allowed to supply `X-Forwarded-For` When the local limiter returns HTTP `429`, the response includes a `Retry-After` header plus JSON `reason` codes that distinguish per-client quota exhaustion diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 7aa2da02..56eb778c 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -86,10 +86,11 @@ client buckets the process will retain; stale buckets age out after one full window. When the map is full, unseen clients receive `429 Too Many Requests` with `Retry-After` and reason `local_rate_limiter_capacity_exceeded` until older buckets expire. -By default the limiter keys on the connected peer IP. `X-Forwarded-For` and -`X-Real-IP` are considered only when the peer IP is listed in -`TRUSTED_PROXY_IPS`, which prevents direct clients from manufacturing new local -limiter buckets with spoofed forwarding headers. +By default the limiter keys on the connected peer IP. `X-Forwarded-For` is +considered only when the peer IP is listed in `TRUSTED_PROXY_IPS`, which +prevents direct clients from manufacturing new local limiter buckets with +spoofed forwarding headers. `X-Real-IP` is not trusted for limiter identity +because the header does not carry a verifiable proxy chain. This is a local emergency guard, not the distributed quota authority described in issue `#83`. The contract follows RFC 6585's guidance that `429` responses diff --git a/src/lib.rs b/src/lib.rs index ca8e62f4..e02455a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2572,10 +2572,7 @@ fn trusted_forwarded_client_ip( .get("x-forwarded-for") .and_then(|value| value.to_str().ok()) else { - return headers - .get("x-real-ip") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse().ok()); + return None; }; let mut current_hop = peer_ip; @@ -3965,17 +3962,6 @@ mod tests { request } - fn gateway_get_via_proxy_real_ip(uri: &str, real_ip: &str, proxy_ip: &str) -> Request { - let mut request = Request::builder() - .method(Method::GET) - .uri(uri) - .header("x-real-ip", real_ip) - .body(Body::empty()) - .unwrap(); - insert_peer(&mut request, proxy_ip.parse::().unwrap()); - request - } - #[test] fn rate_limit_step_enforces_fixed_window() { // limit 0 disables limiting entirely. @@ -4107,25 +4093,31 @@ mod tests { } #[tokio::test] - async fn gateway_uses_real_ip_from_trusted_proxy_for_rate_limiting() { + async fn gateway_ignores_real_ip_from_trusted_proxy_for_rate_limiting() { let app = build_app( AppState::seeded(None) .with_rate_limit(1, 60) .with_trusted_proxies(HashSet::from(["198.51.100.10".parse().unwrap()])), ); - let first = app_request( - &app, - gateway_get_via_proxy_real_ip("/gateway/demo", "203.0.113.9", "198.51.100.10"), - ) - .await; + let mut first = Request::builder() + .method(Method::GET) + .uri("/gateway/demo") + .header("x-real-ip", "203.0.113.9") + .body(Body::empty()) + .unwrap(); + insert_peer(&mut first, "198.51.100.10".parse::().unwrap()); + let first = app_request(&app, first).await; assert_eq!(first.status(), StatusCode::OK); - let second = app_request( - &app, - gateway_get_via_proxy_real_ip("/gateway/demo", "203.0.113.11", "198.51.100.10"), - ) - .await; - assert_eq!(second.status(), StatusCode::OK); + let mut second = Request::builder() + .method(Method::GET) + .uri("/gateway/demo") + .header("x-real-ip", "203.0.113.11") + .body(Body::empty()) + .unwrap(); + insert_peer(&mut second, "198.51.100.10".parse::().unwrap()); + let second = app_request(&app, second).await; + assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); } #[tokio::test] From 3a065c6f48b977866c56840510eb4795a52efbb8 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 20:47:59 +0900 Subject: [PATCH 10/13] fix(gateway): keep secret bootstrap scoped --- src/credentials.rs | 36 ++++++++++++++++++++++++++++++++++-- src/lib.rs | 7 ++----- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/credentials.rs b/src/credentials.rs index 59759022..d7d1b6e0 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -81,13 +81,16 @@ impl CredentialRegistry { env_admin_token: Option, env_admin_tokens: Option, ) -> Result { - Self::bootstrap_with_runtime_overrides( + let mut registry = Self::bootstrap_with_runtime_overrides( credentials_path, env_admin_token, env_admin_tokens, None, None, - ) + )?; + registry.values.remove(CRED_RATE_LIMIT_MAX_CLIENTS); + registry.values.remove(CRED_TRUSTED_PROXY_IPS); + Ok(registry) } /// Bootstrap secret-bearing credentials plus env-transported runtime @@ -425,4 +428,33 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn bootstrap_secrets_ignores_policy_keys_from_credentials_file() { + let dir = std::env::temp_dir().join(format!( + "wardnet-creds-secret-only-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("credentials.json"); + std::fs::write( + &path, + r#"{"admin_token":"file-secret","rate_limit_max_clients":"512","trusted_proxy_ips":"198.51.100.10"}"#, + ) + .unwrap(); + + let registry = CredentialRegistry::bootstrap_secrets(Some(&path), None, None).unwrap(); + assert_eq!( + registry.get_credential(CRED_ADMIN_TOKEN), + Some("file-secret") + ); + assert_eq!(registry.get_credential(CRED_RATE_LIMIT_MAX_CLIENTS), None); + assert_eq!(registry.get_credential(CRED_TRUSTED_PROXY_IPS), None); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/lib.rs b/src/lib.rs index e02455a5..964d4990 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2568,12 +2568,9 @@ fn trusted_forwarded_client_ip( peer_ip: IpAddr, trusted_proxies: &HashSet, ) -> Option { - let Some(value) = headers + let value = headers .get("x-forwarded-for") - .and_then(|value| value.to_str().ok()) - else { - return None; - }; + .and_then(|value| value.to_str().ok())?; let mut current_hop = peer_ip; for hop in value.rsplit(',').map(str::trim) { From 49abdd807aed0fd21310424ddfcb8200ec1dfd34 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Tue, 1 Sep 2026 01:29:58 +0900 Subject: [PATCH 11/13] fix(gateway): honor trusted proxy real ip --- src/lib.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 964d4990..8d6fad33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2568,9 +2568,15 @@ fn trusted_forwarded_client_ip( peer_ip: IpAddr, trusted_proxies: &HashSet, ) -> Option { - let value = headers + let Some(value) = headers .get("x-forwarded-for") - .and_then(|value| value.to_str().ok())?; + .and_then(|value| value.to_str().ok()) + else { + return headers + .get("x-real-ip") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()); + }; let mut current_hop = peer_ip; for hop in value.rsplit(',').map(str::trim) { @@ -4090,7 +4096,7 @@ mod tests { } #[tokio::test] - async fn gateway_ignores_real_ip_from_trusted_proxy_for_rate_limiting() { + async fn gateway_uses_real_ip_from_trusted_proxy_when_forwarded_for_is_absent() { let app = build_app( AppState::seeded(None) .with_rate_limit(1, 60) @@ -4114,7 +4120,7 @@ mod tests { .unwrap(); insert_peer(&mut second, "198.51.100.10".parse::().unwrap()); let second = app_request(&app, second).await; - assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(second.status(), StatusCode::OK); } #[tokio::test] From 0cc5eb7387365fd42b592909291f92b7b1d2ab9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:27:25 +0900 Subject: [PATCH 12/13] ci: pin hosted runner image --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df092755..9409a898 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: jobs: rust: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable From e9547c556f7c5aeb244371035675a50ce5c82e6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:27:39 +0900 Subject: [PATCH 13/13] ci: pin fuzz runner image --- .github/workflows/fuzz.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ebcb3ce9..b2a5366d 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -23,7 +23,7 @@ concurrency: jobs: fuzz: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: