diff --git a/backend/.env.example b/backend/.env.example index a87fb23..a8fcdd2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,6 +26,12 @@ JWT_SECRET=change-me-in-production # ============================================================================= # The backend applies a global fixed-window rate limiter (60s window). # +# If you run the backend behind a reverse proxy, you may want to configure trusted proxy IPs +# so the backend can safely use X-Forwarded-For / X-Real-IP for rate limiting. +# +# Comma-separated IP allowlist (examples: 127.0.0.1,::1,10.0.0.10) +TRUSTED_PROXY_IPS= +# # - Set RATE_LIMIT_ENABLED=false to disable all rate limiting. # - Set any *_RPM variable to 0 to disable that specific limiter. # diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index afc136e..bbf1e60 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -63,6 +63,42 @@ async fn register( Extension(config): Extension>, Json(req): Json, ) -> Result, (StatusCode, String)> { + if req.username.trim().len() < 3 { + return Err(( + StatusCode::BAD_REQUEST, + "Username must be at least 3 characters".to_string(), + )); + } + if req.username.len() > 50 { + return Err(( + StatusCode::BAD_REQUEST, + "Username must be at most 50 characters".to_string(), + )); + } + if req.email.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "Email is required".to_string())); + } + if req.email.len() > 255 { + return Err(( + StatusCode::BAD_REQUEST, + "Email must be at most 255 characters".to_string(), + )); + } + if req.password.len() < 8 { + return Err(( + StatusCode::BAD_REQUEST, + "Password must be at least 8 characters".to_string(), + )); + } + if let Some(display_name) = &req.display_name { + if display_name.len() > 100 { + return Err(( + StatusCode::BAD_REQUEST, + "Display name must be at most 100 characters".to_string(), + )); + } + } + // Check registration settings let settings = sqlx::query!( "SELECT registration_enabled, registration_mode FROM instance_settings LIMIT 1" diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs index d91e15e..cfcee3a 100644 --- a/backend/src/config/mod.rs +++ b/backend/src/config/mod.rs @@ -10,6 +10,8 @@ pub struct Config { pub server_port: u16, #[serde(default)] pub cors_allowed_origins: Option, + #[serde(default)] + pub trusted_proxy_ips: Option, /// Enable demo mode - restricts destructive actions and enables demo accounts #[serde(default)] pub demo_mode: bool, @@ -77,6 +79,7 @@ impl Default for Config { server_host: "127.0.0.1".to_string(), server_port: 3000, cors_allowed_origins: None, + trusted_proxy_ips: None, demo_mode: false, jwt_secret: default_jwt_secret(), rate_limit_enabled: default_rate_limit_enabled(), diff --git a/backend/src/rate_limit.rs b/backend/src/rate_limit.rs index 82fbee4..c75a481 100644 --- a/backend/src/rate_limit.rs +++ b/backend/src/rate_limit.rs @@ -17,6 +17,7 @@ use crate::config::Config; #[derive(Clone)] pub struct RateLimitState { config: Arc, + trusted_proxies: Vec, ip: Arc, user: Arc, auth: Arc, @@ -26,6 +27,7 @@ impl RateLimitState { pub fn new(config: Arc) -> Self { let window = Duration::from_secs(60); Self { + trusted_proxies: parse_trusted_proxy_ips(&config), ip: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_ip_rpm)), user: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_user_rpm)), auth: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_auth_rpm)), @@ -121,6 +123,43 @@ fn parse_ip_from_connect_info(request: &Request) -> Option { .map(|ci| ci.0.ip()) } +fn parse_trusted_proxy_ips(config: &Config) -> Vec { + let mut out: Vec = Vec::new(); + + let raw = config + .trusted_proxy_ips + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()); + + let Some(raw) = raw else { + return out; + }; + + for part in raw.split(',').map(|v| v.trim()).filter(|v| !v.is_empty()) { + match part.parse::() { + Ok(ip) => out.push(ip), + Err(_) => { + tracing::warn!(value = part, "Invalid TRUSTED_PROXY_IPS entry; ignoring"); + } + } + } + + out +} + +fn is_trusted_proxy(peer_ip: Option, extra_trusted: &[IpAddr]) -> bool { + let Some(peer_ip) = peer_ip else { + return false; + }; + + if peer_ip.is_loopback() { + return true; + } + + extra_trusted.iter().any(|ip| ip == &peer_ip) +} + fn parse_user_from_auth(headers: &HeaderMap, secret: &str) -> Option { let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?; let mut pieces = auth.split_whitespace(); @@ -162,7 +201,12 @@ pub async fn rate_limit_middleware( } let headers = request.headers(); - let ip = parse_ip_from_headers(headers).or_else(|| parse_ip_from_connect_info(&request)); + let peer_ip = parse_ip_from_connect_info(&request); + let ip = if is_trusted_proxy(peer_ip, &state.trusted_proxies) { + parse_ip_from_headers(headers).or(peer_ip) + } else { + peer_ip + }; let ip_key = ip .map(|v| format!("ip:{}", v)) @@ -212,9 +256,11 @@ pub async fn rate_limit_middleware( mod tests { use super::{rate_limit_middleware, FixedWindowLimiter, RateLimitState}; use axum::body::Body; + use axum::extract::ConnectInfo; use axum::http::{header, Request, StatusCode}; use axum::routing::get; use axum::Router; + use std::net::SocketAddr; use std::sync::Arc; use tower::ServiceExt; use uuid::Uuid; @@ -295,22 +341,26 @@ mod tests { )); for _ in 0..2 { - let req = Request::builder() + let mut req = Request::builder() .method("GET") .uri("/api/ping") .header("x-forwarded-for", "1.2.3.4") .body(Body::empty()) .unwrap(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); } - let req = Request::builder() + let mut req = Request::builder() .method("GET") .uri("/api/ping") .header("x-forwarded-for", "1.2.3.4") .body(Body::empty()) .unwrap(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); 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()); @@ -333,21 +383,25 @@ mod tests { rate_limit_middleware, )); - let req = Request::builder() + let mut req = Request::builder() .method("GET") .uri("/api/auth/login") .header("x-forwarded-for", "1.2.3.4") .body(Body::empty()) .unwrap(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); - let req = Request::builder() + let mut req = Request::builder() .method("GET") .uri("/api/auth/login") .header("x-forwarded-for", "1.2.3.4") .body(Body::empty()) .unwrap(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); 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()); @@ -374,23 +428,108 @@ mod tests { rate_limit_middleware, )); - let req = Request::builder() + let mut 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(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); let res = app.clone().oneshot(req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); - let req = Request::builder() + let mut 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(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); + 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 forwarded_ip_respected_when_peer_is_trusted_proxy() { + let cfg = Arc::new(Config { + rate_limit_enabled: true, + rate_limit_ip_rpm: 1, + rate_limit_user_rpm: 0, + rate_limit_auth_rpm: 0, + ..Default::default() + }); + + 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 mut req = Request::builder() + .method("GET") + .uri("/api/ping") + .header("x-forwarded-for", "1.1.1.1") + .body(Body::empty()) + .unwrap(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + let mut req = Request::builder() + .method("GET") + .uri("/api/ping") + .header("x-forwarded-for", "2.2.2.2") + .body(Body::empty()) + .unwrap(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + } + + #[tokio::test] + async fn forwarded_ip_ignored_when_peer_is_not_trusted_proxy() { + let cfg = Arc::new(Config { + rate_limit_enabled: true, + rate_limit_ip_rpm: 1, + rate_limit_user_rpm: 0, + rate_limit_auth_rpm: 0, + ..Default::default() + }); + + 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 mut req = Request::builder() + .method("GET") + .uri("/api/ping") + .header("x-forwarded-for", "1.1.1.1") + .body(Body::empty()) + .unwrap(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 2], 12345)))); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + let mut req = Request::builder() + .method("GET") + .uri("/api/ping") + .header("x-forwarded-for", "2.2.2.2") + .body(Body::empty()) + .unwrap(); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 2], 12345)))); 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/compose/.env.demo.example b/compose/.env.demo.example index 0e3bd15..00740a5 100644 --- a/compose/.env.demo.example +++ b/compose/.env.demo.example @@ -11,6 +11,7 @@ DB_PORT=5433 JWT_SECRET=demo_jwt_secret_not_for_production BACKEND_PORT=3001 BACKEND_BIND_HOST=0.0.0.0 +TRUSTED_PROXY_IPS= # Frontend FRONTEND_PORT=4322 diff --git a/compose/.env.production.example b/compose/.env.production.example index 87cadd1..1c0acf0 100644 --- a/compose/.env.production.example +++ b/compose/.env.production.example @@ -1,19 +1,20 @@ -# Production Environment Configuration -# Copy to .env.production and fill in values - -# Database -POSTGRES_USER=likwid -POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD -POSTGRES_DB=likwid_prod -DB_PORT=5432 - -# Backend -JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING -BACKEND_PORT=3000 - -# Frontend -FRONTEND_PORT=4321 -API_BASE=https://your-domain.com -INTERNAL_API_BASE=http://backend:3000 - -# Note: DEMO_MODE is always false for production +# Production Environment Configuration +# Copy to .env.production and fill in values + +# Database +POSTGRES_USER=likwid +POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD +POSTGRES_DB=likwid_prod +DB_PORT=5432 + +# Backend +JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING +TRUSTED_PROXY_IPS= +BACKEND_PORT=3000 + +# Frontend +FRONTEND_PORT=4321 +API_BASE=https://your-domain.com +INTERNAL_API_BASE=http://backend:3000 + +# Note: DEMO_MODE is always false for production diff --git a/compose/demo.yml b/compose/demo.yml index b0b7243..47f67f3 100644 --- a/compose/demo.yml +++ b/compose/demo.yml @@ -32,6 +32,12 @@ services: environment: DATABASE_URL: postgres://${POSTGRES_USER:-likwid_demo}:${POSTGRES_PASSWORD:-demo_secret_change_me}@postgres:5432/${POSTGRES_DB:-likwid_demo} JWT_SECRET: ${JWT_SECRET:-demo_jwt_secret_not_for_production} + TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} + RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true} + RATE_LIMIT_IP_RPM: ${RATE_LIMIT_IP_RPM:-300} + RATE_LIMIT_USER_RPM: ${RATE_LIMIT_USER_RPM:-1200} + RATE_LIMIT_AUTH_RPM: ${RATE_LIMIT_AUTH_RPM:-30} SERVER_HOST: 0.0.0.0 SERVER_PORT: 3000 DEMO_MODE: "true" diff --git a/compose/production.yml b/compose/production.yml index e80ab02..067b1a0 100644 --- a/compose/production.yml +++ b/compose/production.yml @@ -33,6 +33,12 @@ services: environment: DATABASE_URL: postgres://${POSTGRES_USER:-likwid}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-likwid_prod} JWT_SECRET: ${JWT_SECRET} + TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} + RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true} + RATE_LIMIT_IP_RPM: ${RATE_LIMIT_IP_RPM:-300} + RATE_LIMIT_USER_RPM: ${RATE_LIMIT_USER_RPM:-1200} + RATE_LIMIT_AUTH_RPM: ${RATE_LIMIT_AUTH_RPM:-30} SERVER_HOST: 0.0.0.0 SERVER_PORT: 3000 DEMO_MODE: "false" diff --git a/docs/admin/configuration.md b/docs/admin/configuration.md index ae0c8d4..d9627b5 100644 --- a/docs/admin/configuration.md +++ b/docs/admin/configuration.md @@ -14,6 +14,7 @@ Likwid is configured through environment variables and database settings. | `SERVER_PORT` | No | `3000` | HTTP port | | `DEMO_MODE` | No | `false` | Enable demo features | | `CORS_ALLOWED_ORIGINS` | No | - | Comma-separated allowlist of CORS origins for browsers (recommended in production) | +| `TRUSTED_PROXY_IPS` | No | - | Comma-separated allowlist of reverse proxy IPs whose `X-Forwarded-For` / `X-Real-IP` headers should be trusted | | `RATE_LIMIT_ENABLED` | No | `true` | Enable API rate limiting | | `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP | | `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user | @@ -102,9 +103,12 @@ Behavior: IP detection order: -- `x-forwarded-for` (first IP in list) -- `x-real-ip` -- TCP peer address (Axum `ConnectInfo`) +- If the TCP peer is a trusted proxy (loopback by default, plus `TRUSTED_PROXY_IPS`): + - `x-forwarded-for` (first IP in list) + - `x-real-ip` + - TCP peer address (Axum `ConnectInfo`) +- Otherwise: + - TCP peer address (Axum `ConnectInfo`) Responses when limited: diff --git a/docs/admin/installation.md b/docs/admin/installation.md index 80aa7e9..0f82c92 100644 --- a/docs/admin/installation.md +++ b/docs/admin/installation.md @@ -93,6 +93,8 @@ For production, use a reverse proxy (nginx, Caddy) with: - Proper headers - HSTS (set on the reverse proxy) +If you want per-IP rate limiting to use `X-Forwarded-For` / `X-Real-IP`, set `TRUSTED_PROXY_IPS` to the reverse proxy IP(s) (loopback is trusted by default). + Example nginx config: ```nginx diff --git a/docs/admin/opensuse-operator-kit.md b/docs/admin/opensuse-operator-kit.md index c4388d5..fbfd462 100644 --- a/docs/admin/opensuse-operator-kit.md +++ b/docs/admin/opensuse-operator-kit.md @@ -55,6 +55,7 @@ cp ~/likwid/compose/.env.production.example ~/likwid/compose/.env.production - `POSTGRES_PASSWORD` - `JWT_SECRET` - `API_BASE` (should be your public URL, e.g. `https://your.domain`) +- `TRUSTED_PROXY_IPS` (if your reverse proxy does not connect from loopback) 1. Start services: diff --git a/docs/admin/security.md b/docs/admin/security.md index af11968..d46aed9 100644 --- a/docs/admin/security.md +++ b/docs/admin/security.md @@ -12,17 +12,10 @@ Securing your Likwid instance. ### Password Policy -- Minimum 8 characters (configurable) -- Bcrypt hashing with cost factor 12 +- Minimum 8 characters +- Argon2 password hashing (server-side) - No password in logs or error messages -### Two-Factor Authentication - -Enable 2FA support for users: - -- TOTP (Time-based One-Time Password) -- Backup codes for recovery - ## Network Security ### HTTPS @@ -67,6 +60,8 @@ Protect against abuse: - 1200 requests/minute per authenticated user - 30 requests/minute per IP for auth endpoints +If you run the backend behind a reverse proxy, configure `TRUSTED_PROXY_IPS` so the backend can safely use `X-Forwarded-For` / `X-Real-IP` when applying per-IP limits. + ## Database Security ### Connection @@ -104,15 +99,13 @@ All inputs are validated: ## Moderation Audit Trail -All moderation actions are logged: +Moderation actions are recorded: - Who performed the action - What action was taken - Why (reason required) - When it happened -Logs are immutable and tamper-evident. - ## Updates Keep Likwid updated: diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index a5d634b..b96fb4a 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -17,6 +17,9 @@ export const onRequest = defineMiddleware(async (context, next) => { const url = new URL(context.request.url); const path = url.pathname; + const forwardedProto = context.request.headers.get('x-forwarded-proto')?.trim().toLowerCase(); + const isHttps = url.protocol === 'https:' || forwardedProto === 'https'; + if (path === '/demo' && url.searchParams.get('enter') === '1') { let nextPath = url.searchParams.get('next') || '/communities'; if (!nextPath.startsWith('/')) { @@ -27,6 +30,7 @@ export const onRequest = defineMiddleware(async (context, next) => { path: '/', httpOnly: true, sameSite: 'lax', + secure: isHttps, maxAge: 60 * 60 * 12, });