Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
4 changes: 3 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IpAddr>,

/// HTTP rate-limit sliding window (seconds).
Expand Down
72 changes: 65 additions & 7 deletions src/rate_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<IpAddr>().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::<IpAddr>()
{
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::<IpAddr>().ok())
{
return client;
}
}
}

peer
Expand Down Expand Up @@ -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));
Expand All @@ -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)));
}
Expand Down
Loading