rate_limit.rs 321 lignes · 11370 octets
//! Rate Limiting Middleware and Helpers
//!
//! Feature 009: Admin & Settings — per-route tower layers for blunt-instrument
//! rate limiting on admin endpoints.
//!
//! Feature 041: Files Sync & Sharing (T074) — fine-grained per-IP limits for
//! public share endpoints and authentication endpoints, routed through the
//! generic [`RateLimitService`](crate::services::rate_limit_service::RateLimitService).
//!
//! # Thresholds
//!
//! | Scope | Default |
//! |-------|---------|
//! | Admin (tower `RateLimitLayer`) | 100 req/min |
//! | User management (tower) | 20 req/min |
//! | Reset password (tower) | 10 req/min |
//! | Public share per IP | 30 req/min |
//! | Auth per IP | env `RATE_LIMIT_AUTH_PER_MINUTE`, default 10 req/min |
//!
//! Public share and auth limits are backed by the in-process
//! [`RateLimitService`](crate::services::rate_limit_service::RateLimitService)
//! so they share bounding, cleanup and the canonical
//! `TooManyRequests` error (HTTP 429 + `Retry-After`).

use axum::{
    extract::{Request, State},
    middleware::Next,
    response::{IntoResponse, Response},
};
use sea_orm::DatabaseConnection;
use std::{sync::OnceLock, time::Duration};
use tower::limit::RateLimitLayer;

use crate::error::AppError;
use crate::services::rate_limit_service::RateLimitService;

/// Rate limiting configuration for admin routes
pub struct AdminRateLimitConfig;

impl AdminRateLimitConfig {
    /// Create rate limiter for admin endpoints (100 requests per minute)
    pub fn admin_limit() -> RateLimitLayer {
        RateLimitLayer::new(100, Duration::from_secs(60))
    }

    /// Create rate limiter for user management endpoints (20 requests per minute)
    pub fn user_management_limit() -> RateLimitLayer {
        RateLimitLayer::new(20, Duration::from_secs(60))
    }

    /// Create rate limiter for reset password endpoint (10 requests per minute)
    pub fn reset_password_limit() -> RateLimitLayer {
        RateLimitLayer::new(10, Duration::from_secs(60))
    }
}

/// Rate limiting configuration for public share endpoints
/// Feature 041: Files Sync & Sharing
pub struct PublicShareRateLimitConfig;

impl PublicShareRateLimitConfig {
    /// Create rate limiter for public share endpoints (30 requests per minute per IP)
    ///
    /// This limit is more restrictive than authenticated endpoints to prevent abuse
    /// of public links (brute force password attempts, token enumeration, etc.)
    pub fn public_share_limit() -> RateLimitLayer {
        RateLimitLayer::new(30, Duration::from_secs(60))
    }
}

/// Placeholder middleware — real admin rate limiting is applied via
/// [`AdminRateLimitConfig::admin_limit`] in the router layer.
///
/// Kept for composability if a future version routes admin traffic through
/// [`RateLimitService`] (for per-user quotas).
pub async fn admin_rate_limit(
    State(_db): State<DatabaseConnection>,
    request: Request,
    next: Next,
) -> Result<Response, AppError> {
    Ok(next.run(request).await)
}

const PUBLIC_SHARE_MAX_PER_MINUTE: u32 = 30;
const DEFAULT_AUTH_MAX_PER_MINUTE: u32 = 10;

static PUBLIC_SHARE_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
static AUTH_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
static AUTH_MAX_PER_MINUTE: OnceLock<u32> = OnceLock::new();

fn public_share_limiter() -> &'static RateLimitService {
    PUBLIC_SHARE_LIMITER.get_or_init(RateLimitService::new)
}

fn auth_limiter() -> &'static RateLimitService {
    AUTH_LIMITER.get_or_init(RateLimitService::new)
}

fn auth_max_per_minute() -> u32 {
    *AUTH_MAX_PER_MINUTE.get_or_init(|| {
        std::env::var("RATE_LIMIT_AUTH_PER_MINUTE")
            .ok()
            .and_then(|v| v.parse().ok())
            .unwrap_or(DEFAULT_AUTH_MAX_PER_MINUTE)
    })
}

/// SEC-H3: Number of trusted reverse proxy hops (0 = direct connection, ignore XFF).
/// Read once from TRUSTED_PROXY_HOPS environment variable.
static TRUSTED_PROXY_HOPS: OnceLock<usize> = OnceLock::new();

fn trusted_proxy_hops() -> usize {
    *TRUSTED_PROXY_HOPS.get_or_init(|| {
        std::env::var("TRUSTED_PROXY_HOPS")
            .ok()
            .and_then(|v| v.parse().ok())
            .unwrap_or(0)
    })
}

/// Pick the client IP from `X-Forwarded-For` and/or the direct connection.
///
/// `hops` is the number of trusted reverse-proxy hops (0 = ignore XFF).
/// When `hops > 0`, takes the Nth IP from the **right** of the XFF list.
fn extract_ip(xff: Option<&str>, connect: Option<String>, hops: usize) -> String {
    if hops > 0 {
        if let Some(xff_value) = xff {
            let ips: Vec<&str> = xff_value.split(',').map(|s| s.trim()).collect();
            if ips.len() >= hops {
                return ips[ips.len() - hops].to_string();
            }
        }
    }
    connect.unwrap_or_else(|| "unknown".to_string())
}

/// SEC-H3: Extract the real client IP, respecting TRUSTED_PROXY_HOPS.
/// When TRUSTED_PROXY_HOPS > 0, takes the Nth IP from the right of X-Forwarded-For.
/// Otherwise falls back to the direct connection IP.
fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>) -> String {
    extract_ip(xff, connect_ip, trusted_proxy_hops())
}

/// Extract IP address from Request (uses ConnectInfo when available)
pub fn extract_client_ip(request: &Request) -> String {
    let xff = request
        .headers()
        .get("x-forwarded-for")
        .and_then(|h| h.to_str().ok());
    let connect_ip = request
        .extensions()
        .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
        .map(|ci| ci.0.ip().to_string())
        .or_else(|| {
            // `MockConnectInfo` stores itself, not `ConnectInfo` (axum 0.8).
            request
                .extensions()
                .get::<axum::extract::connect_info::MockConnectInfo<std::net::SocketAddr>>()
                .map(|mock| mock.0.ip().to_string())
        });
    extract_ip_from_xff_or_connect(xff, connect_ip)
}

/// Extract IP address from HeaderMap (used in handlers where Request is consumed)
pub fn extract_client_ip_from_headers(headers: &axum::http::HeaderMap) -> String {
    let xff = headers.get("x-forwarded-for").and_then(|h| h.to_str().ok());
    extract_ip_from_xff_or_connect(xff, None)
}

/// Check rate limit for a public share endpoint request.
///
/// Feature 041: Files Sync & Sharing (T074). Returns an [`AppError`]
/// response (HTTP 429 with `Retry-After`) if the limit is exceeded.
#[allow(clippy::result_large_err)]
pub fn check_public_share_rate_limit(request: &Request) -> Result<(), Response> {
    let ip = extract_client_ip(request);
    match public_share_limiter().check(&format!("share:{ip}"), PUBLIC_SHARE_MAX_PER_MINUTE) {
        Ok(()) => Ok(()),
        Err(err) => {
            tracing::warn!(ip = %ip, "Public share rate limit exceeded");
            Err(err.into_response())
        }
    }
}

/// Check rate limit for a public share endpoint using `HeaderMap`.
///
/// Feature 041: Files Sync & Sharing (T074). Prefer this variant when the
/// `Request` has already been destructured by an extractor.
#[allow(clippy::result_large_err)]
pub fn check_public_share_rate_limit_from_headers(
    headers: &axum::http::HeaderMap,
) -> Result<(), Response> {
    let ip = extract_client_ip_from_headers(headers);
    match public_share_limiter().check(&format!("share:{ip}"), PUBLIC_SHARE_MAX_PER_MINUTE) {
        Ok(()) => Ok(()),
        Err(err) => {
            tracing::warn!(ip = %ip, "Public share rate limit exceeded");
            Err(err.into_response())
        }
    }
}

/// SEC-C2: Check auth rate limit for an incoming request.
///
/// Protects login, register, refresh, and forgot-password from brute-force
/// attacks. Returns 429 `Too Many Requests` with a `Retry-After` header when
/// the per-IP budget is exceeded.
#[allow(clippy::result_large_err)]
pub fn check_auth_rate_limit(headers: &axum::http::HeaderMap) -> Result<(), Response> {
    let ip = extract_client_ip_from_headers(headers);
    match auth_limiter().check(&format!("auth:{ip}"), auth_max_per_minute()) {
        Ok(()) => Ok(()),
        Err(err) => Err(err.into_response()),
    }
}

/// Per-IP rate-limit layer for authentication endpoints.
///
/// Uses [`extract_client_ip`] so the key comes from `ConnectInfo` when
/// `TRUSTED_PROXY_HOPS` is 0 (the default), rather than collapsing every
/// request into a global `auth:unknown` bucket.
pub(crate) async fn auth_ip_rate_limit(request: Request, next: Next) -> Response {
    let ip = extract_client_ip(&request);
    match auth_limiter().check(&format!("auth:{ip}"), auth_max_per_minute()) {
        Ok(()) => next.run(request).await,
        Err(err) => err.into_response(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_rate_limit_config_creation() {
        let _admin = AdminRateLimitConfig::admin_limit();
        let _user_mgmt = AdminRateLimitConfig::user_management_limit();
        let _reset = AdminRateLimitConfig::reset_password_limit();
    }

    #[test]
    fn auth_limiter_isolates_budget_per_ip() {
        let rl = RateLimitService::new();
        for _ in 0..3 {
            rl.check("auth:1.2.3.4", 3).unwrap();
        }
        assert!(rl.check("auth:1.2.3.4", 3).is_err());
        // Different IP has its own budget.
        rl.check("auth:5.6.7.8", 3)
            .expect("distinct IP must not share the other IP's budget");
    }

    #[test]
    fn share_limiter_isolates_budget_per_ip() {
        let rl = RateLimitService::new();
        for _ in 0..3 {
            rl.check("share:1.2.3.4", 3).unwrap();
        }
        assert!(rl.check("share:1.2.3.4", 3).is_err());
        rl.check("share:9.9.9.9", 3)
            .expect("distinct IP must not share the other IP's budget");
    }

    #[test]
    fn extract_client_ip_uses_connect_info_without_xff() {
        let mut request = Request::builder()
            .uri("/")
            .body(axum::body::Body::empty())
            .expect("request");
        request
            .extensions_mut()
            .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
                [203, 0, 113, 1],
                12345,
            ))));
        assert_eq!(extract_client_ip(&request), "203.0.113.1");
    }

    #[test]
    fn extract_client_ip_uses_mock_connect_info_without_xff() {
        let mut request = Request::builder()
            .uri("/")
            .body(axum::body::Body::empty())
            .expect("request");
        request
            .extensions_mut()
            .insert(axum::extract::connect_info::MockConnectInfo(
                std::net::SocketAddr::from(([203, 0, 113, 1], 12345)),
            ));
        assert_eq!(extract_client_ip(&request), "203.0.113.1");
    }

    #[test]
    fn hops_zero_uses_connect_not_xff() {
        assert_eq!(
            extract_ip(Some("1.2.3.4"), Some("10.0.0.9".into()), 0),
            "10.0.0.9"
        );
    }

    #[test]
    fn hops_one_takes_rightmost_xff() {
        assert_eq!(
            extract_ip(Some("1.2.3.4, 5.6.7.8"), Some("10.0.0.9".into()), 1),
            "5.6.7.8"
        );
    }

    #[test]
    fn hops_two_takes_second_from_right() {
        assert_eq!(
            extract_ip(
                Some("1.2.3.4, 5.6.7.8, 9.9.9.9"),
                Some("10.0.0.9".into()),
                2
            ),
            "5.6.7.8"
        );
    }
}