From aa2e7894b4a7627a8a7585b5f2a59e9c471e8940 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Thu, 12 Feb 2026 11:47:38 +0100 Subject: [PATCH] test/docs: add rate limiting regression tests --- backend/.env.example | 69 ++++++++++------ backend/Cargo.lock | 1 + backend/Cargo.toml | 3 + backend/src/rate_limit.rs | 156 +++++++++++++++++++++++++++++++++++- docs/admin/configuration.md | 48 ++++++++++- 5 files changed, 247 insertions(+), 30 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index afba0da..927834c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,26 +1,43 @@ -# Likwid Backend Configuration -# Copy this file to .env and configure as needed - -# Database connection URL -DATABASE_URL=postgres://likwid:likwid@localhost:5432/likwid - -# Server configuration -SERVER_HOST=127.0.0.1 -SERVER_PORT=3000 - -# JWT Secret for authentication tokens -# IMPORTANT: Change this in production! -JWT_SECRET=change-me-in-production - -# ============================================================================= -# DEMO MODE -# ============================================================================= -# Enable demo mode for public demonstration instances. -# When enabled: -# - Restricts destructive actions (delete communities, modify instance settings) -# - Enables demo accounts (contributor, moderator, observer) with password: demo123 -# - Loads seed data with realistic governance history -# - Data can be reset via POST /api/demo/reset -# -# Set to true for demo/showcase instances, false for production -DEMO_MODE=false +# Likwid Backend Configuration +# Copy this file to .env and configure as needed + +# Database connection URL +DATABASE_URL=postgres://likwid:likwid@localhost:5432/likwid + +# Server configuration +SERVER_HOST=127.0.0.1 +SERVER_PORT=3000 + +# JWT Secret for authentication tokens +# IMPORTANT: Change this in production! +JWT_SECRET=change-me-in-production + +# ============================================================================= +# RATE LIMITING +# ============================================================================= +# The backend applies a global fixed-window rate limiter (60s window). +# +# - Set RATE_LIMIT_ENABLED=false to disable all rate limiting. +# - Set any *_RPM variable to 0 to disable that specific limiter. +# +# Per-IP rate limit (all endpoints except / and /health) +RATE_LIMIT_IP_RPM=300 +# Per-user rate limit (only applies when Authorization: Bearer is present) +RATE_LIMIT_USER_RPM=1200 +# Auth endpoints rate limit (applies per IP for /api/auth/login and /api/auth/register) +RATE_LIMIT_AUTH_RPM=30 +# Master toggle +RATE_LIMIT_ENABLED=true + +# ============================================================================= +# DEMO MODE +# ============================================================================= +# Enable demo mode for public demonstration instances. +# When enabled: +# - Restricts destructive actions (delete communities, modify instance settings) +# - Enables demo accounts (contributor, moderator, observer) with password: demo123 +# - Loads seed data with realistic governance history +# - Data can be reset via POST /api/demo/reset +# +# Set to true for demo/showcase instances, false for production +DEMO_MODE=false diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d6b4347..103abf6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1825,6 +1825,7 @@ dependencies = [ "sqlx", "thiserror 2.0.18", "tokio", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e75afc9..e8d7348 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -30,3 +30,6 @@ reqwest = { version = "0.11", default-features = false, features = ["json", "rus wasmtime = "19" wasmtime-wasi = "19" slug = "0.1" + +[dev-dependencies] +tower = "0.5" diff --git a/backend/src/rate_limit.rs b/backend/src/rate_limit.rs index 94e30ea..905a427 100644 --- a/backend/src/rate_limit.rs +++ b/backend/src/rate_limit.rs @@ -202,7 +202,17 @@ pub async fn rate_limit_middleware( #[cfg(test)] mod tests { - use super::FixedWindowLimiter; + use super::{rate_limit_middleware, FixedWindowLimiter, RateLimitState}; + use axum::body::Body; + use axum::http::{header, Request, StatusCode}; + use axum::routing::get; + use axum::Router; + use std::sync::Arc; + use tower::ServiceExt; + use uuid::Uuid; + + use crate::auth::jwt::create_token; + use crate::config::Config; use std::time::Duration; #[tokio::test] @@ -229,4 +239,148 @@ mod tests { assert!(limiter.check("k").await.is_ok()); } } + + #[tokio::test] + async fn middleware_bypasses_health() { + let mut cfg = Config::default(); + cfg.rate_limit_enabled = true; + cfg.rate_limit_ip_rpm = 1; + cfg.rate_limit_user_rpm = 0; + cfg.rate_limit_auth_rpm = 0; + let cfg = Arc::new(cfg); + + let app = Router::new() + .route("/health", get(|| async { "ok" })) + .layer(axum::middleware::from_fn_with_state( + RateLimitState::new(cfg.clone()), + rate_limit_middleware, + )); + + for _ in 0..5 { + let req = Request::builder() + .method("GET") + .uri("/health") + .header("x-forwarded-for", "1.2.3.4") + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + } + } + + #[tokio::test] + async fn middleware_enforces_ip_rate_limit() { + let mut cfg = Config::default(); + cfg.rate_limit_enabled = true; + cfg.rate_limit_ip_rpm = 2; + cfg.rate_limit_user_rpm = 0; + cfg.rate_limit_auth_rpm = 0; + let cfg = Arc::new(cfg); + + let app = Router::new() + .route("/api/ping", get(|| async { "ok" })) + .layer(axum::middleware::from_fn_with_state( + RateLimitState::new(cfg.clone()), + rate_limit_middleware, + )); + + for _ in 0..2 { + let req = Request::builder() + .method("GET") + .uri("/api/ping") + .header("x-forwarded-for", "1.2.3.4") + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + } + + let req = Request::builder() + .method("GET") + .uri("/api/ping") + .header("x-forwarded-for", "1.2.3.4") + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS); + assert!(res.headers().get(header::RETRY_AFTER).is_some()); + } + + #[tokio::test] + async fn middleware_enforces_auth_rate_limit_on_login() { + let mut cfg = Config::default(); + cfg.rate_limit_enabled = true; + cfg.rate_limit_ip_rpm = 0; + cfg.rate_limit_user_rpm = 0; + cfg.rate_limit_auth_rpm = 1; + let cfg = Arc::new(cfg); + + let app = Router::new() + .route("/api/auth/login", get(|| async { "ok" })) + .layer(axum::middleware::from_fn_with_state( + RateLimitState::new(cfg.clone()), + rate_limit_middleware, + )); + + let req = Request::builder() + .method("GET") + .uri("/api/auth/login") + .header("x-forwarded-for", "1.2.3.4") + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + let req = Request::builder() + .method("GET") + .uri("/api/auth/login") + .header("x-forwarded-for", "1.2.3.4") + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS); + assert!(res.headers().get(header::RETRY_AFTER).is_some()); + } + + #[tokio::test] + async fn middleware_enforces_user_rate_limit_when_token_is_valid() { + let mut cfg = Config::default(); + cfg.rate_limit_enabled = true; + cfg.rate_limit_ip_rpm = 0; + cfg.rate_limit_user_rpm = 1; + cfg.rate_limit_auth_rpm = 0; + cfg.jwt_secret = "testsecret".to_string(); + let cfg = Arc::new(cfg); + + let user_id = Uuid::new_v4(); + let token = create_token(user_id, "u", &cfg.jwt_secret).unwrap(); + + let app = Router::new() + .route("/api/ping", get(|| async { "ok" })) + .layer(axum::middleware::from_fn_with_state( + RateLimitState::new(cfg.clone()), + rate_limit_middleware, + )); + + let req = Request::builder() + .method("GET") + .uri("/api/ping") + .header("x-forwarded-for", "1.2.3.4") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + let req = Request::builder() + .method("GET") + .uri("/api/ping") + .header("x-forwarded-for", "1.2.3.4") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS); + assert!(res.headers().get(header::RETRY_AFTER).is_some()); + } } diff --git a/docs/admin/configuration.md b/docs/admin/configuration.md index 042fd4a..626d8e9 100644 --- a/docs/admin/configuration.md +++ b/docs/admin/configuration.md @@ -7,7 +7,7 @@ Likwid is configured through environment variables and database settings. ### Backend | Variable | Required | Default | Description | -|----------|----------|---------|-------------| +| --- | --- | --- | --- | | `DATABASE_URL` | Yes | - | PostgreSQL connection string | | `JWT_SECRET` | Yes | - | Secret for signing JWT tokens | | `SERVER_HOST` | No | `127.0.0.1` | Bind address | @@ -22,7 +22,7 @@ Likwid is configured through environment variables and database settings. ### Frontend | Variable | Required | Default | Description | -|----------|----------|---------|-------------| +| --- | --- | --- | --- | | `API_BASE` | No | `http://localhost:3000` | Backend API URL | | `PUBLIC_API_BASE` | No | Same as API_BASE | Public-facing API URL | | `INTERNAL_API_BASE` | No | - | Server-side API URL (e.g. `http://backend:3000` in container deployments) | @@ -32,17 +32,20 @@ Likwid is configured through environment variables and database settings. Managed via the Admin panel or API: ### General + - **Instance Name** - Display name for your Likwid instance - **Instance Description** - Brief description - **Registration** - Open, invite-only, or closed - **Email Verification** - Required or optional ### Features + - **Community Creation** - Who can create communities - **Public Read Access** - Allow anonymous browsing - **Federation** - Enable cross-instance communication ### Plugins + - **Active Voting Methods** - Which methods are available - **Default Voting Method** - Instance-wide default - **Active Integrations** - GitLab, Matrix, etc. @@ -63,6 +66,7 @@ Each community can configure: ``` ### Voting Method Options + - `approval` - Approval voting - `ranked_choice` - Instant runoff - `schulze` - Condorcet method @@ -70,6 +74,7 @@ Each community can configure: - `quadratic` - Voice credit allocation ### Transparency Levels + - `full` - All votes visible after closing - `anonymous` - Only totals visible - `private` - Results only, no breakdown @@ -77,17 +82,52 @@ Each community can configure: ## API Configuration ### Rate Limiting + Rate limiting is configured via backend environment variables. +Behavior: + +- **Window**: fixed 60s windows (counters reset every minute). +- **Scope**: applied as global Axum middleware for all routes. +- **Bypasses**: + - `/` and `/health` (any method) + - all `OPTIONS` requests (CORS preflight) +- **Buckets**: + - **Auth endpoints**: `RATE_LIMIT_AUTH_RPM` is applied *per IP* for (and replaces other limiters on these routes): + - `/api/auth/login` + - `/api/auth/register` + - **Per-IP**: `RATE_LIMIT_IP_RPM` is applied per IP for all other endpoints. + - **Per-user (authenticated)**: `RATE_LIMIT_USER_RPM` is additionally applied per user *when* a valid `Authorization: Bearer ` header is present. + +IP detection order: + +- `x-forwarded-for` (first IP in list) +- `x-real-ip` +- TCP peer address (Axum `ConnectInfo`) + +Responses when limited: + +- **HTTP**: `429 Too Many Requests` +- **Header**: `Retry-After: ` +- **Body**: JSON `{ "error": "Rate limit exceeded" }` + +Disabling: + +- Set `RATE_LIMIT_ENABLED=false` to disable all rate limiting. +- Set any `*_RPM` value to `0` to disable that specific limiter. + ### CORS + By default, CORS allows all origins in development. For production: -``` + +```bash CORS_ALLOWED_ORIGINS=https://likwid.example.org ``` ## Logging ### Log Levels + - `trace` - Very detailed debugging - `debug` - Debugging information - `info` - Normal operation @@ -95,7 +135,9 @@ CORS_ALLOWED_ORIGINS=https://likwid.example.org - `error` - Error conditions ### Log Format + Logs are output in JSON format for easy parsing: + ```json {"timestamp":"2026-01-27T12:00:00Z","level":"INFO","message":"Server started","port":3000} ```