From e5f9e2f752d76a487f80bdd1960bb080ccefe369 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 02:04:30 +0000 Subject: [PATCH 1/5] fix(security): derive rate-limit client IP from trusted proxy safely When HTTP_TRUSTED_PROXIES is configured, use X-Real-IP or the rightmost X-Forwarded-For hop instead of the client-controlled leftmost entry. This closes a rate-limit bypass and DoS amplification path behind nginx-style reverse proxies that append the connecting address. Co-authored-by: Alexander Wagner --- .env.example | 2 +- src/config.rs | 4 ++- src/rate_limit.rs | 72 ++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 223cde1..f04ca5e 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,7 @@ RTMPS_BIND=0.0.0.0:1936 # HTTP API and UI listener address HTTP_BIND=0.0.0.0:8080 -# Comma-separated proxy IPs that may set X-Forwarded-For for rate limiting +# Comma-separated proxy IPs that may set X-Real-IP / X-Forwarded-For for rate limiting # Example behind Docker/nginx on the same host: 127.0.0.1,172.17.0.1 HTTP_TRUSTED_PROXIES= diff --git a/src/config.rs b/src/config.rs index df4fb7b..d08cf88 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,7 +29,9 @@ pub struct ServerConfig { /// HTTP API + UI, e.g. "0.0.0.0:8080" pub http_bind: String, - /// When the TCP peer is one of these addresses, use `X-Forwarded-For` for rate limiting. + /// When the TCP peer is one of these addresses, derive the client IP from + /// `X-Real-IP` or the rightmost `X-Forwarded-For` hop (the address appended + /// by the immediate trusted proxy). pub http_trusted_proxies: Vec, /// HTTP rate-limit sliding window (seconds). diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 2b5b306..d787cc5 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -144,15 +144,36 @@ fn client_ip(request: &Request, trusted_proxies: &[IpAddr]) -> IpAddr { .map(|info| info.0.ip()) .unwrap_or(IpAddr::from([127, 0, 0, 1])); - if trusted_proxies.contains(&peer) - && let Some(xff) = request + if trusted_proxies.contains(&peer) { + // Prefer X-Real-IP when the trusted proxy sets it (e.g. nginx $remote_addr). + if let Some(real_ip) = request + .headers() + .get("X-Real-IP") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|part| !part.is_empty()) + .and_then(|part| part.parse::().ok()) + { + return real_ip; + } + + if let Some(xff) = request .headers() .get("X-Forwarded-For") .and_then(|v| v.to_str().ok()) - && let Some(client) = xff.split(',').map(str::trim).find(|part| !part.is_empty()) - && let Ok(ip) = client.parse::() - { - return ip; + { + // Use the rightmost address: the one appended by the immediate trusted + // proxy ($proxy_add_x_forwarded_for), not client-controlled leftmost entries. + if let Some(client) = xff + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .next_back() + .and_then(|part| part.parse::().ok()) + { + return client; + } + } } peer @@ -287,7 +308,7 @@ mod tests { } #[test] - fn trusted_proxy_uses_x_forwarded_for() { + fn trusted_proxy_uses_rightmost_x_forwarded_for() { use axum::body::Body; let proxy = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); @@ -300,6 +321,43 @@ mod tests { .extensions_mut() .insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 1], 12345)))); + let ip = client_ip(&request, &[proxy]); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); + } + + #[test] + fn trusted_proxy_ignores_client_supplied_leftmost_xff() { + use axum::body::Body; + + let proxy = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let mut request = Request::builder() + .uri("/api/v1/health") + .header("X-Forwarded-For", "198.51.100.99, 203.0.113.5") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 1], 12345)))); + + let ip = client_ip(&request, &[proxy]); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5))); + } + + #[test] + fn trusted_proxy_prefers_x_real_ip() { + use axum::body::Body; + + let proxy = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let mut request = Request::builder() + .uri("/api/v1/health") + .header("X-Real-IP", "203.0.113.5") + .header("X-Forwarded-For", "198.51.100.99, 10.0.0.1") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 1], 12345)))); + let ip = client_ip(&request, &[proxy]); assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5))); } From d193a047bc8ce6ad68c75552d375c18e3a1d8bd7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 05:02:02 +0000 Subject: [PATCH 2/5] fix(ci): satisfy clippy filter_next in rate-limit XFF parsing Replace filter().next_back() with rfind() when selecting the rightmost non-empty X-Forwarded-For hop, as required by clippy::filter_next. Co-authored-by: Alexander Wagner --- src/rate_limit.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/rate_limit.rs b/src/rate_limit.rs index d787cc5..252b91d 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -167,8 +167,7 @@ fn client_ip(request: &Request, trusted_proxies: &[IpAddr]) -> IpAddr { if let Some(client) = xff .split(',') .map(str::trim) - .filter(|part| !part.is_empty()) - .next_back() + .rfind(|part| !part.is_empty()) .and_then(|part| part.parse::().ok()) { return client; From caba1108906d9f99dcaf47432a0783b7abfad9e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:32:21 +0000 Subject: [PATCH 3/5] fix(rate_limit): log when trusted-proxy IP headers fail to parse When a trusted proxy's X-Real-IP or X-Forwarded-For value is present but not a valid IpAddr, client_ip() silently fell back to the proxy's own peer IP, collapsing many clients into one rate-limit bucket with no operator-visible signal. Log a warning in that case instead. --- src/rate_limit.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 252b91d..b9875b0 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -146,15 +146,19 @@ fn client_ip(request: &Request, trusted_proxies: &[IpAddr]) -> IpAddr { if trusted_proxies.contains(&peer) { // Prefer X-Real-IP when the trusted proxy sets it (e.g. nginx $remote_addr). - if let Some(real_ip) = request + if let Some(real_ip_header) = request .headers() .get("X-Real-IP") .and_then(|v| v.to_str().ok()) .map(str::trim) .filter(|part| !part.is_empty()) - .and_then(|part| part.parse::().ok()) { - return real_ip; + match real_ip_header.parse::() { + Ok(real_ip) => return real_ip, + Err(_) => crate::log_warn!( + "rate_limit: trusted proxy {peer} sent unparsable X-Real-IP '{real_ip_header}', falling back to X-Forwarded-For/peer" + ), + } } if let Some(xff) = request @@ -164,13 +168,14 @@ fn client_ip(request: &Request, trusted_proxies: &[IpAddr]) -> IpAddr { { // Use the rightmost address: the one appended by the immediate trusted // proxy ($proxy_add_x_forwarded_for), not client-controlled leftmost entries. - if let Some(client) = xff - .split(',') - .map(str::trim) - .rfind(|part| !part.is_empty()) - .and_then(|part| part.parse::().ok()) + if let Some(rightmost) = xff.split(',').map(str::trim).rfind(|part| !part.is_empty()) { - return client; + match rightmost.parse::() { + Ok(client) => return client, + Err(_) => crate::log_warn!( + "rate_limit: trusted proxy {peer} sent unparsable X-Forwarded-For hop '{rightmost}', falling back to peer IP" + ), + } } } } From 662b6df8440e50ee6ba5f9dd7bb09f229d54d53a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:32:50 +0000 Subject: [PATCH 4/5] Update Cargo.lock --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ff0078..5bdf8b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1708,9 +1708,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.4" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -1724,9 +1724,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", From c0c4d9756e974dbb4ba2ff04ccc588ad074d8cc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:36:14 +0000 Subject: [PATCH 5/5] fix(rate_limit): stop trusting X-Real-IP for client identification A trusted reverse proxy is only guaranteed to append to X-Forwarded-For ($proxy_add_x_forwarded_for); X-Real-IP is commonly passed through unmodified by proxies that don't set it themselves. Trusting it let a client pick an arbitrary rate-limit bucket by sending the header directly, reopening the bypass this PR is meant to close. Also fixes cargo fmt formatting on the rightmost-XFF match arm. --- .env.example | 2 +- src/config.rs | 6 ++++-- src/rate_limit.rs | 46 ++++++++++++++++++---------------------------- 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index f04ca5e..223cde1 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,7 @@ RTMPS_BIND=0.0.0.0:1936 # HTTP API and UI listener address HTTP_BIND=0.0.0.0:8080 -# Comma-separated proxy IPs that may set X-Real-IP / X-Forwarded-For for rate limiting +# Comma-separated proxy IPs that may set X-Forwarded-For for rate limiting # Example behind Docker/nginx on the same host: 127.0.0.1,172.17.0.1 HTTP_TRUSTED_PROXIES= diff --git a/src/config.rs b/src/config.rs index d08cf88..c1abe79 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,8 +30,10 @@ pub struct ServerConfig { /// HTTP API + UI, e.g. "0.0.0.0:8080" pub http_bind: String, /// When the TCP peer is one of these addresses, derive the client IP from - /// `X-Real-IP` or the rightmost `X-Forwarded-For` hop (the address appended - /// by the immediate trusted proxy). + /// the rightmost `X-Forwarded-For` hop (the address appended by the + /// immediate trusted proxy). `X-Real-IP` is intentionally not trusted, as + /// proxies that don't set it themselves may pass a client-supplied value + /// through unmodified. pub http_trusted_proxies: Vec, /// HTTP rate-limit sliding window (seconds). diff --git a/src/rate_limit.rs b/src/rate_limit.rs index b9875b0..4dbc890 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -145,37 +145,24 @@ fn client_ip(request: &Request, trusted_proxies: &[IpAddr]) -> IpAddr { .unwrap_or(IpAddr::from([127, 0, 0, 1])); if trusted_proxies.contains(&peer) { - // Prefer X-Real-IP when the trusted proxy sets it (e.g. nginx $remote_addr). - if let Some(real_ip_header) = request - .headers() - .get("X-Real-IP") - .and_then(|v| v.to_str().ok()) - .map(str::trim) - .filter(|part| !part.is_empty()) - { - match real_ip_header.parse::() { - Ok(real_ip) => return real_ip, - Err(_) => crate::log_warn!( - "rate_limit: trusted proxy {peer} sent unparsable X-Real-IP '{real_ip_header}', falling back to X-Forwarded-For/peer" - ), - } - } - + // Use the rightmost address: the one appended by the immediate trusted + // proxy ($proxy_add_x_forwarded_for), not client-controlled leftmost entries. + // + // X-Real-IP is deliberately NOT trusted here: unlike XFF, which the + // trusted proxy appends to, X-Real-IP is commonly just passed through + // unmodified by proxies that don't set it themselves, which would let + // a client pick an arbitrary rate-limit bucket by setting it directly. if let Some(xff) = request .headers() .get("X-Forwarded-For") .and_then(|v| v.to_str().ok()) + && let Some(rightmost) = xff.split(',').map(str::trim).rfind(|part| !part.is_empty()) { - // Use the rightmost address: the one appended by the immediate trusted - // proxy ($proxy_add_x_forwarded_for), not client-controlled leftmost entries. - if let Some(rightmost) = xff.split(',').map(str::trim).rfind(|part| !part.is_empty()) - { - match rightmost.parse::() { - Ok(client) => return client, - Err(_) => crate::log_warn!( - "rate_limit: trusted proxy {peer} sent unparsable X-Forwarded-For hop '{rightmost}', falling back to peer IP" - ), - } + match rightmost.parse::() { + Ok(client) => return client, + Err(_) => crate::log_warn!( + "rate_limit: trusted proxy {peer} sent unparsable X-Forwarded-For hop '{rightmost}', falling back to peer IP" + ), } } } @@ -348,7 +335,10 @@ mod tests { } #[test] - fn trusted_proxy_prefers_x_real_ip() { + fn trusted_proxy_ignores_x_real_ip() { + // A proxy that only forwards X-Real-IP unmodified (rather than setting + // it itself) would let a client pick an arbitrary rate-limit bucket by + // sending this header directly, so it must never be trusted. use axum::body::Body; let proxy = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); @@ -363,6 +353,6 @@ mod tests { .insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 1], 12345)))); let ip = client_ip(&request, &[proxy]); - assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5))); + assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); } }