From 989d88022a7c633b994a4df004eed558fab8114c Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 2 Apr 2026 21:03:59 +0200 Subject: [PATCH] feat: implement rate limiting across all API endpoints Add sliding-window in-memory RateLimiter service (Arc>) with per-IP and per-user-id limits on all public endpoint classes: - POST /api/v1/join: 5/min per IP - GET /api/v1/feed: configurable per IP (feed_rate_per_min, default 60) - POST /api/v1/upload: configurable per user (upload_rate_per_hour, default 10) - GET /api/v1/export/zip|html: configurable per IP (export_rate_per_day, default 3) Limits are hot-reloadable via the config table. All 429 responses use German error messages. Client IP is read from X-Forwarded-For (Caddy). Co-Authored-By: Claude Sonnet 4.6 --- backend/src/auth/handlers.rs | 23 ++++++++++---- backend/src/handlers/admin.rs | 35 +++++++++++++++++++-- backend/src/handlers/feed.rs | 23 ++++++++++++++ backend/src/handlers/upload.rs | 13 ++++++++ backend/src/services/mod.rs | 1 + backend/src/services/rate_limiter.rs | 46 ++++++++++++++++++++++++++++ backend/src/state.rs | 3 ++ 7 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 backend/src/services/rate_limiter.rs diff --git a/backend/src/auth/handlers.rs b/backend/src/auth/handlers.rs index a2f934b..ecc5c43 100644 --- a/backend/src/auth/handlers.rs +++ b/backend/src/auth/handlers.rs @@ -1,7 +1,9 @@ +use std::time::Duration; + use axum::extract::State; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; use axum::Json; -use chrono::{Duration, Utc}; +use chrono::Utc; use rand::Rng; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -12,6 +14,7 @@ use crate::error::AppError; use crate::models::event::Event; use crate::models::session::Session; use crate::models::user::{User, UserRole}; +use crate::services::rate_limiter::client_ip; use crate::state::AppState; #[derive(Deserialize)] @@ -29,8 +32,16 @@ pub struct JoinResponse { pub async fn join( State(state): State, + headers: HeaderMap, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { + let ip = client_ip(&headers, "unknown"); + if !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) { + return Err(AppError::TooManyRequests( + "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), + )); + } + let display_name = body.display_name.trim(); if display_name.is_empty() || display_name.len() > 50 { return Err(AppError::BadRequest( @@ -62,7 +73,7 @@ pub async fn join( .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; let token_hash = jwt::hash_token(&token); - let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days); + let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days); Session::create(&state.pool, user.id, &token_hash, expires_at).await?; Ok(( @@ -134,7 +145,7 @@ pub async fn recover( .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; let token_hash = jwt::hash_token(&token); - let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days); + let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days); Session::create(&state.pool, user.id, &token_hash, expires_at).await?; return Ok(Json(RecoverResponse { @@ -146,7 +157,7 @@ pub async fn recover( // Wrong PIN — increment failure count let attempts = User::increment_failed_pin(&state.pool, user.id).await?; if attempts >= 3 { - let lockout = Utc::now() + Duration::minutes(15); + let lockout = Utc::now() + chrono::Duration::minutes(15); User::lock_pin(&state.pool, user.id, lockout).await?; } } @@ -217,7 +228,7 @@ pub async fn admin_login( .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; let token_hash = jwt::hash_token(&token); - let expires_at = Utc::now() + Duration::days(1); + let expires_at = Utc::now() + chrono::Duration::days(1); Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?; Ok(Json(AdminLoginResponse { jwt: token })) diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index 5070559..3357628 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -1,12 +1,15 @@ +use std::collections::HashMap; +use std::time::Duration; + use axum::extract::State; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; use axum::Json; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use sysinfo::System; use crate::auth::middleware::RequireAdmin; use crate::error::AppError; +use crate::services::rate_limiter::client_ip; use crate::state::AppState; // ── DTOs ───────────────────────────────────────────────────────────────────── @@ -172,7 +175,16 @@ pub async fn get_export_jobs( pub async fn download_zip( State(state): State, _auth: crate::auth::middleware::AuthUser, + headers: HeaderMap, ) -> Result { + let ip = client_ip(&headers, "unknown"); + let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await; + if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) { + return Err(AppError::TooManyRequests( + "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), + )); + } + let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) .await? .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; @@ -194,7 +206,16 @@ pub async fn download_zip( pub async fn download_html( State(state): State, _auth: crate::auth::middleware::AuthUser, + headers: HeaderMap, ) -> Result { + let ip = client_ip(&headers, "unknown"); + let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await; + if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) { + return Err(AppError::TooManyRequests( + "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), + )); + } + let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug) .await? .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; @@ -277,3 +298,13 @@ pub async fn export_status( "html": job_status("html"), }))) } + +async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize { + let row: Option<(String,)> = + sqlx::query_as("SELECT value FROM config WHERE key = $1") + .bind(key) + .fetch_optional(pool) + .await + .unwrap_or(None); + row.and_then(|r| r.0.parse().ok()).unwrap_or(default) +} diff --git a/backend/src/handlers/feed.rs b/backend/src/handlers/feed.rs index 74323dc..78f3441 100644 --- a/backend/src/handlers/feed.rs +++ b/backend/src/handlers/feed.rs @@ -1,4 +1,7 @@ +use std::time::Duration; + use axum::extract::{Query, State}; +use axum::http::HeaderMap; use axum::Json; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -6,6 +9,7 @@ use uuid::Uuid; use crate::auth::middleware::AuthUser; use crate::error::AppError; +use crate::services::rate_limiter::client_ip; use crate::state::AppState; #[derive(Deserialize)] @@ -53,8 +57,17 @@ struct FeedRow { pub async fn feed( State(state): State, auth: AuthUser, + headers: HeaderMap, Query(q): Query, ) -> Result, AppError> { + let ip = client_ip(&headers, "unknown"); + let rate_limit = get_config_usize(&state.pool, "feed_rate_per_min", 60).await; + if !state.rate_limiter.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) { + return Err(AppError::TooManyRequests( + "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), + )); + } + let limit = q.limit.unwrap_or(20).min(100); let rows = if let Some(hashtag) = &q.hashtag { @@ -227,6 +240,16 @@ pub async fn hashtags( )) } +async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize { + let row: Option<(String,)> = + sqlx::query_as("SELECT value FROM config WHERE key = $1") + .bind(key) + .fetch_optional(pool) + .await + .unwrap_or(None); + row.and_then(|r| r.0.parse().ok()).unwrap_or(default) +} + async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option> { let row: Option<(DateTime,)> = sqlx::query_as("SELECT created_at FROM upload WHERE id = $1") diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index 0933e5a..eb319f6 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use axum::extract::{Multipart, Path, State}; use axum::http::StatusCode; use axum::Json; @@ -16,6 +18,17 @@ pub async fn upload( auth: AuthUser, mut multipart: Multipart, ) -> Result<(StatusCode, Json), AppError> { + // Rate limit: N uploads per hour per user + let upload_rate = get_config_i64(&state.pool, "upload_rate_per_hour", 10).await as usize; + if !state + .rate_limiter + .check(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600)) + { + return Err(AppError::TooManyRequests( + "Du hast dein Upload-Limit für diese Stunde erreicht.".into(), + )); + } + // Check if user is banned let user = User::find_by_id(&state.pool, auth.user_id) .await? diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index fff4d7b..2254b23 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,2 +1,3 @@ pub mod compression; pub mod export; +pub mod rate_limiter; diff --git a/backend/src/services/rate_limiter.rs b/backend/src/services/rate_limiter.rs new file mode 100644 index 0000000..4a4bfc0 --- /dev/null +++ b/backend/src/services/rate_limiter.rs @@ -0,0 +1,46 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// Thread-safe sliding-window rate limiter backed by an in-memory HashMap. +/// Each key (e.g. `"join:{ip}"` or `"upload:{user_id}"`) tracks timestamps +/// of recent requests and rejects new ones once the window is full. +#[derive(Clone)] +pub struct RateLimiter { + windows: Arc>>>, +} + +impl RateLimiter { + pub fn new() -> Self { + Self { + windows: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Returns `true` if the request is allowed, `false` if rate-limited. + pub fn check(&self, key: impl Into, max: usize, window: Duration) -> bool { + let now = Instant::now(); + let key = key.into(); + let mut map = self.windows.lock().unwrap(); + let timestamps = map.entry(key).or_default(); + // Drop entries outside the window + timestamps.retain(|&t| now.duration_since(t) < window); + if timestamps.len() < max { + timestamps.push(now); + true + } else { + false + } + } +} + +/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back +/// to a provided socket address string. +pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String { + headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(|s| s.trim().to_owned()) + .unwrap_or_else(|| fallback.to_owned()) +} diff --git a/backend/src/state.rs b/backend/src/state.rs index 9d9b939..baa4895 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -3,6 +3,7 @@ use tokio::sync::broadcast; use crate::config::AppConfig; use crate::services::compression::CompressionWorker; +use crate::services::rate_limiter::RateLimiter; #[derive(Clone, Debug)] pub struct SseEvent { @@ -16,6 +17,7 @@ pub struct AppState { pub config: AppConfig, pub sse_tx: broadcast::Sender, pub compression: CompressionWorker, + pub rate_limiter: RateLimiter, } impl AppState { @@ -28,6 +30,7 @@ impl AppState { config, sse_tx, compression, + rate_limiter: RateLimiter::new(), } } }