security: configurable CORS allowlist

This commit is contained in:
Marco Allegretti 2026-02-12 12:17:11 +01:00
parent aa2e7894b4
commit 4b4e2458e4
5 changed files with 180 additions and 7 deletions

View file

@ -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

View file

@ -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<String>,
/// 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(),

View file

@ -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<HeaderValue> = 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")
);
}
}

View file

@ -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

View file

@ -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.