From 4b4e2458e4f6c5b04ebc38ce0e63a10f458aa64c Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Thu, 12 Feb 2026 12:17:11 +0100 Subject: [PATCH] security: configurable CORS allowlist --- backend/.env.example | 9 +++ backend/src/config/mod.rs | 3 + backend/src/main.rs | 137 ++++++++++++++++++++++++++++++++++-- docs/admin/configuration.md | 9 ++- docs/admin/security.md | 29 +++++++- 5 files changed, 180 insertions(+), 7 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 927834c..a87fb23 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,15 @@ DATABASE_URL=postgres://likwid:likwid@localhost:5432/likwid SERVER_HOST=127.0.0.1 SERVER_PORT=3000 +# ============================================================================= +# CORS +# ============================================================================= +# In development, CORS defaults to allowing any origin. +# In production, set an allowlist (comma-separated) to restrict browser access. +# Example: +# CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org +CORS_ALLOWED_ORIGINS= + # JWT Secret for authentication tokens # IMPORTANT: Change this in production! JWT_SECRET=change-me-in-production diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs index 0bd44dc..d91e15e 100644 --- a/backend/src/config/mod.rs +++ b/backend/src/config/mod.rs @@ -8,6 +8,8 @@ pub struct Config { pub server_host: String, #[serde(default = "default_server_port")] pub server_port: u16, + #[serde(default)] + pub cors_allowed_origins: Option, /// Enable demo mode - restricts destructive actions and enables demo accounts #[serde(default)] pub demo_mode: bool, @@ -74,6 +76,7 @@ impl Default for Config { database_url: "postgres://likwid:likwid@localhost:5432/likwid".to_string(), server_host: "127.0.0.1".to_string(), server_port: 3000, + cors_allowed_origins: None, demo_mode: false, jwt_secret: default_jwt_secret(), rate_limit_enabled: default_rate_limit_enabled(), diff --git a/backend/src/main.rs b/backend/src/main.rs index c30cfb6..4a89ec3 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -152,10 +152,7 @@ async fn run() -> Result<(), StartupError> { } } - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + let cors = build_cors_layer(config.as_ref()); let plugins = plugins::PluginManager::new(pool.clone()) .register_builtin_plugins() @@ -354,3 +351,135 @@ async fn add_security_headers(mut res: Response) -> Response { res } + +fn build_cors_layer(config: &Config) -> CorsLayer { + let layer = CorsLayer::new().allow_methods(Any).allow_headers(Any); + + let allowed = config + .cors_allowed_origins + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()); + + let Some(allowed) = allowed else { + return layer.allow_origin(Any); + }; + + let origins: Vec = allowed + .split(',') + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .filter_map(|v| match HeaderValue::from_str(v) { + Ok(hv) => Some(hv), + Err(e) => { + tracing::warn!(origin = v, error = %e, "Invalid CORS origin; ignoring"); + None + } + }) + .collect(); + + if origins.is_empty() { + return layer.allow_origin(Any); + } + + layer.allow_origin(origins) +} + +#[cfg(test)] +mod cors_tests { + use super::build_cors_layer; + use axum::body::Body; + use axum::http::{header, Request, StatusCode}; + use axum::routing::get; + use axum::Router; + use tower::ServiceExt; + + use crate::config::Config; + + #[tokio::test] + async fn cors_default_allows_any_origin() { + let cfg = Config::default(); + let app = Router::new() + .route("/api/ping", get(|| async { "ok" })) + .layer(build_cors_layer(&cfg)); + + let req = Request::builder() + .method("GET") + .uri("/api/ping") + .header(header::ORIGIN, "https://example.com") + .body(Body::empty()) + .unwrap(); + + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), + Some("*") + ); + } + + #[tokio::test] + async fn cors_allowlist_only_allows_listed_origins() { + let mut cfg = Config::default(); + cfg.cors_allowed_origins = Some("https://a.example, https://b.example".to_string()); + let app = Router::new() + .route("/api/ping", get(|| async { "ok" })) + .layer(build_cors_layer(&cfg)); + + let allowed_req = Request::builder() + .method("GET") + .uri("/api/ping") + .header(header::ORIGIN, "https://a.example") + .body(Body::empty()) + .unwrap(); + let allowed_res = app.clone().oneshot(allowed_req).await.unwrap(); + assert_eq!(allowed_res.status(), StatusCode::OK); + assert_eq!( + allowed_res + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), + Some("https://a.example") + ); + + let denied_req = Request::builder() + .method("GET") + .uri("/api/ping") + .header(header::ORIGIN, "https://c.example") + .body(Body::empty()) + .unwrap(); + let denied_res = app.clone().oneshot(denied_req).await.unwrap(); + assert_eq!(denied_res.status(), StatusCode::OK); + assert!(denied_res + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none()); + } + + #[tokio::test] + async fn cors_preflight_responds_for_allowed_origin() { + let mut cfg = Config::default(); + cfg.cors_allowed_origins = Some("https://a.example".to_string()); + let app = Router::new() + .route("/api/ping", get(|| async { "ok" })) + .layer(build_cors_layer(&cfg)); + + let req = Request::builder() + .method("OPTIONS") + .uri("/api/ping") + .header(header::ORIGIN, "https://a.example") + .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert!(res.status().is_success()); + assert_eq!( + res.headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), + Some("https://a.example") + ); + } +} diff --git a/docs/admin/configuration.md b/docs/admin/configuration.md index 626d8e9..6232bc1 100644 --- a/docs/admin/configuration.md +++ b/docs/admin/configuration.md @@ -13,6 +13,7 @@ Likwid is configured through environment variables and database settings. | `SERVER_HOST` | No | `127.0.0.1` | Bind address | | `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) | | `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 | @@ -118,12 +119,18 @@ Disabling: ### CORS -By default, CORS allows all origins in development. For production: +By default, CORS allows any origin (development-friendly). In production, set `CORS_ALLOWED_ORIGINS` to a comma-separated allowlist. ```bash CORS_ALLOWED_ORIGINS=https://likwid.example.org ``` +Multiple origins: + +```bash +CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org +``` + ## Logging ### Log Levels diff --git a/docs/admin/security.md b/docs/admin/security.md index 3a536cb..b70c75e 100644 --- a/docs/admin/security.md +++ b/docs/admin/security.md @@ -5,36 +5,52 @@ Securing your Likwid instance. ## Authentication ### JWT Tokens + - Use a strong, random `JWT_SECRET` (64+ characters) - Tokens expire after 24 hours by default - Refresh tokens are not stored server-side ### Password Policy + - Minimum 8 characters (configurable) - Bcrypt hashing with cost factor 12 - 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 + Always use HTTPS in production: + - Obtain certificates (Let's Encrypt recommended) - Configure reverse proxy for TLS termination - Enable HSTS headers ### CORS + Restrict CORS in production: -``` + +```bash CORS_ALLOWED_ORIGINS=https://likwid.example.org ``` +For multiple origins, use a comma-separated list: + +```bash +CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org +``` + ### Rate Limiting + Protect against abuse: + - 300 requests/minute per IP (default) - 1200 requests/minute per authenticated user - 30 requests/minute per IP for auth endpoints @@ -42,11 +58,13 @@ Protect against abuse: ## Database Security ### Connection + - Use SSL for database connections - Dedicated database user with minimal privileges - Strong, unique password ### Backups + - Regular automated backups - Encrypted backup storage - Test restore procedures @@ -54,16 +72,20 @@ Protect against abuse: ## API Security ### Input Validation + All inputs are validated: + - Type checking - Length limits - Sanitization ### SQL Injection + - Parameterized queries only (SQLx) - No raw SQL string concatenation ### XSS Prevention + - HTML escaping in templates - Content Security Policy headers - No inline scripts in production @@ -71,6 +93,7 @@ All inputs are validated: ## Moderation Audit Trail All moderation actions are logged: + - Who performed the action - What action was taken - Why (reason required) @@ -81,6 +104,7 @@ Logs are immutable and tamper-evident. ## Updates Keep Likwid updated: + - Watch the repository for security announcements - Apply patches promptly - Test updates in staging first @@ -88,6 +112,7 @@ Keep Likwid updated: ## Incident Response If you discover a security issue: + 1. Document the incident 2. Assess impact 3. Contain the breach @@ -96,6 +121,6 @@ If you discover a security issue: ## Reporting Vulnerabilities -Report security issues to: security@likwid.org +Report security issues to: [security@likwid.org](mailto:security@likwid.org) We follow responsible disclosure practices.