mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-03-26 19:03:08 +00:00
feat(security): proxy-safe rate limiting + auth validation
This commit is contained in:
parent
8007433d5f
commit
d360182093
13 changed files with 243 additions and 41 deletions
|
|
@ -26,6 +26,12 @@ JWT_SECRET=change-me-in-production
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# The backend applies a global fixed-window rate limiter (60s window).
|
# 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 RATE_LIMIT_ENABLED=false to disable all rate limiting.
|
||||||
# - Set any *_RPM variable to 0 to disable that specific limiter.
|
# - Set any *_RPM variable to 0 to disable that specific limiter.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,42 @@ async fn register(
|
||||||
Extension(config): Extension<Arc<Config>>,
|
Extension(config): Extension<Arc<Config>>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
) -> Result<Json<AuthResponse>, (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
|
// Check registration settings
|
||||||
let settings = sqlx::query!(
|
let settings = sqlx::query!(
|
||||||
"SELECT registration_enabled, registration_mode FROM instance_settings LIMIT 1"
|
"SELECT registration_enabled, registration_mode FROM instance_settings LIMIT 1"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ pub struct Config {
|
||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cors_allowed_origins: Option<String>,
|
pub cors_allowed_origins: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub trusted_proxy_ips: Option<String>,
|
||||||
/// Enable demo mode - restricts destructive actions and enables demo accounts
|
/// Enable demo mode - restricts destructive actions and enables demo accounts
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub demo_mode: bool,
|
pub demo_mode: bool,
|
||||||
|
|
@ -77,6 +79,7 @@ impl Default for Config {
|
||||||
server_host: "127.0.0.1".to_string(),
|
server_host: "127.0.0.1".to_string(),
|
||||||
server_port: 3000,
|
server_port: 3000,
|
||||||
cors_allowed_origins: None,
|
cors_allowed_origins: None,
|
||||||
|
trusted_proxy_ips: None,
|
||||||
demo_mode: false,
|
demo_mode: false,
|
||||||
jwt_secret: default_jwt_secret(),
|
jwt_secret: default_jwt_secret(),
|
||||||
rate_limit_enabled: default_rate_limit_enabled(),
|
rate_limit_enabled: default_rate_limit_enabled(),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ use crate::config::Config;
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RateLimitState {
|
pub struct RateLimitState {
|
||||||
config: Arc<Config>,
|
config: Arc<Config>,
|
||||||
|
trusted_proxies: Vec<IpAddr>,
|
||||||
ip: Arc<FixedWindowLimiter>,
|
ip: Arc<FixedWindowLimiter>,
|
||||||
user: Arc<FixedWindowLimiter>,
|
user: Arc<FixedWindowLimiter>,
|
||||||
auth: Arc<FixedWindowLimiter>,
|
auth: Arc<FixedWindowLimiter>,
|
||||||
|
|
@ -26,6 +27,7 @@ impl RateLimitState {
|
||||||
pub fn new(config: Arc<Config>) -> Self {
|
pub fn new(config: Arc<Config>) -> Self {
|
||||||
let window = Duration::from_secs(60);
|
let window = Duration::from_secs(60);
|
||||||
Self {
|
Self {
|
||||||
|
trusted_proxies: parse_trusted_proxy_ips(&config),
|
||||||
ip: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_ip_rpm)),
|
ip: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_ip_rpm)),
|
||||||
user: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_user_rpm)),
|
user: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_user_rpm)),
|
||||||
auth: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_auth_rpm)),
|
auth: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_auth_rpm)),
|
||||||
|
|
@ -121,6 +123,43 @@ fn parse_ip_from_connect_info<B>(request: &Request<B>) -> Option<IpAddr> {
|
||||||
.map(|ci| ci.0.ip())
|
.map(|ci| ci.0.ip())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_trusted_proxy_ips(config: &Config) -> Vec<IpAddr> {
|
||||||
|
let mut out: Vec<IpAddr> = 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::<IpAddr>() {
|
||||||
|
Ok(ip) => out.push(ip),
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(value = part, "Invalid TRUSTED_PROXY_IPS entry; ignoring");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_trusted_proxy(peer_ip: Option<IpAddr>, 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<Uuid> {
|
fn parse_user_from_auth(headers: &HeaderMap, secret: &str) -> Option<Uuid> {
|
||||||
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
||||||
let mut pieces = auth.split_whitespace();
|
let mut pieces = auth.split_whitespace();
|
||||||
|
|
@ -162,7 +201,12 @@ pub async fn rate_limit_middleware(
|
||||||
}
|
}
|
||||||
|
|
||||||
let headers = request.headers();
|
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
|
let ip_key = ip
|
||||||
.map(|v| format!("ip:{}", v))
|
.map(|v| format!("ip:{}", v))
|
||||||
|
|
@ -212,9 +256,11 @@ pub async fn rate_limit_middleware(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{rate_limit_middleware, FixedWindowLimiter, RateLimitState};
|
use super::{rate_limit_middleware, FixedWindowLimiter, RateLimitState};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
|
use axum::extract::ConnectInfo;
|
||||||
use axum::http::{header, Request, StatusCode};
|
use axum::http::{header, Request, StatusCode};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -295,22 +341,26 @@ mod tests {
|
||||||
));
|
));
|
||||||
|
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/ping")
|
.uri("/api/ping")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::OK);
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/ping")
|
.uri("/api/ping")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||||
|
|
@ -333,21 +383,25 @@ mod tests {
|
||||||
rate_limit_middleware,
|
rate_limit_middleware,
|
||||||
));
|
));
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/auth/login")
|
.uri("/api/auth/login")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::OK);
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/auth/login")
|
.uri("/api/auth/login")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||||
|
|
@ -374,23 +428,108 @@ mod tests {
|
||||||
rate_limit_middleware,
|
rate_limit_middleware,
|
||||||
));
|
));
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/ping")
|
.uri("/api/ping")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::OK);
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/ping")
|
.uri("/api/ping")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.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();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ DB_PORT=5433
|
||||||
JWT_SECRET=demo_jwt_secret_not_for_production
|
JWT_SECRET=demo_jwt_secret_not_for_production
|
||||||
BACKEND_PORT=3001
|
BACKEND_PORT=3001
|
||||||
BACKEND_BIND_HOST=0.0.0.0
|
BACKEND_BIND_HOST=0.0.0.0
|
||||||
|
TRUSTED_PROXY_IPS=
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_PORT=4322
|
FRONTEND_PORT=4322
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
# Production Environment Configuration
|
# Production Environment Configuration
|
||||||
# Copy to .env.production and fill in values
|
# Copy to .env.production and fill in values
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
POSTGRES_USER=likwid
|
POSTGRES_USER=likwid
|
||||||
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
||||||
POSTGRES_DB=likwid_prod
|
POSTGRES_DB=likwid_prod
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING
|
JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING
|
||||||
BACKEND_PORT=3000
|
TRUSTED_PROXY_IPS=
|
||||||
|
BACKEND_PORT=3000
|
||||||
# Frontend
|
|
||||||
FRONTEND_PORT=4321
|
# Frontend
|
||||||
API_BASE=https://your-domain.com
|
FRONTEND_PORT=4321
|
||||||
INTERNAL_API_BASE=http://backend:3000
|
API_BASE=https://your-domain.com
|
||||||
|
INTERNAL_API_BASE=http://backend:3000
|
||||||
# Note: DEMO_MODE is always false for production
|
|
||||||
|
# Note: DEMO_MODE is always false for production
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-likwid_demo}:${POSTGRES_PASSWORD:-demo_secret_change_me}@postgres:5432/${POSTGRES_DB:-likwid_demo}
|
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}
|
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_HOST: 0.0.0.0
|
||||||
SERVER_PORT: 3000
|
SERVER_PORT: 3000
|
||||||
DEMO_MODE: "true"
|
DEMO_MODE: "true"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-likwid}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-likwid_prod}
|
DATABASE_URL: postgres://${POSTGRES_USER:-likwid}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-likwid_prod}
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
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_HOST: 0.0.0.0
|
||||||
SERVER_PORT: 3000
|
SERVER_PORT: 3000
|
||||||
DEMO_MODE: "false"
|
DEMO_MODE: "false"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ Likwid is configured through environment variables and database settings.
|
||||||
| `SERVER_PORT` | No | `3000` | HTTP port |
|
| `SERVER_PORT` | No | `3000` | HTTP port |
|
||||||
| `DEMO_MODE` | No | `false` | Enable demo features |
|
| `DEMO_MODE` | No | `false` | Enable demo features |
|
||||||
| `CORS_ALLOWED_ORIGINS` | No | - | Comma-separated allowlist of CORS origins for browsers (recommended in production) |
|
| `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_ENABLED` | No | `true` | Enable API rate limiting |
|
||||||
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
|
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
|
||||||
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
|
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
|
||||||
|
|
@ -102,9 +103,12 @@ Behavior:
|
||||||
|
|
||||||
IP detection order:
|
IP detection order:
|
||||||
|
|
||||||
- `x-forwarded-for` (first IP in list)
|
- If the TCP peer is a trusted proxy (loopback by default, plus `TRUSTED_PROXY_IPS`):
|
||||||
- `x-real-ip`
|
- `x-forwarded-for` (first IP in list)
|
||||||
- TCP peer address (Axum `ConnectInfo`)
|
- `x-real-ip`
|
||||||
|
- TCP peer address (Axum `ConnectInfo`)
|
||||||
|
- Otherwise:
|
||||||
|
- TCP peer address (Axum `ConnectInfo`)
|
||||||
|
|
||||||
Responses when limited:
|
Responses when limited:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ For production, use a reverse proxy (nginx, Caddy) with:
|
||||||
- Proper headers
|
- Proper headers
|
||||||
- HSTS (set on the reverse proxy)
|
- 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:
|
Example nginx config:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ cp ~/likwid/compose/.env.production.example ~/likwid/compose/.env.production
|
||||||
- `POSTGRES_PASSWORD`
|
- `POSTGRES_PASSWORD`
|
||||||
- `JWT_SECRET`
|
- `JWT_SECRET`
|
||||||
- `API_BASE` (should be your public URL, e.g. `https://your.domain`)
|
- `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:
|
1. Start services:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,10 @@ Securing your Likwid instance.
|
||||||
|
|
||||||
### Password Policy
|
### Password Policy
|
||||||
|
|
||||||
- Minimum 8 characters (configurable)
|
- Minimum 8 characters
|
||||||
- Bcrypt hashing with cost factor 12
|
- Argon2 password hashing (server-side)
|
||||||
- No password in logs or error messages
|
- 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
|
## Network Security
|
||||||
|
|
||||||
### HTTPS
|
### HTTPS
|
||||||
|
|
@ -67,6 +60,8 @@ Protect against abuse:
|
||||||
- 1200 requests/minute per authenticated user
|
- 1200 requests/minute per authenticated user
|
||||||
- 30 requests/minute per IP for auth endpoints
|
- 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
|
## Database Security
|
||||||
|
|
||||||
### Connection
|
### Connection
|
||||||
|
|
@ -104,15 +99,13 @@ All inputs are validated:
|
||||||
|
|
||||||
## Moderation Audit Trail
|
## Moderation Audit Trail
|
||||||
|
|
||||||
All moderation actions are logged:
|
Moderation actions are recorded:
|
||||||
|
|
||||||
- Who performed the action
|
- Who performed the action
|
||||||
- What action was taken
|
- What action was taken
|
||||||
- Why (reason required)
|
- Why (reason required)
|
||||||
- When it happened
|
- When it happened
|
||||||
|
|
||||||
Logs are immutable and tamper-evident.
|
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
Keep Likwid updated:
|
Keep Likwid updated:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
const url = new URL(context.request.url);
|
const url = new URL(context.request.url);
|
||||||
const path = url.pathname;
|
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') {
|
if (path === '/demo' && url.searchParams.get('enter') === '1') {
|
||||||
let nextPath = url.searchParams.get('next') || '/communities';
|
let nextPath = url.searchParams.get('next') || '/communities';
|
||||||
if (!nextPath.startsWith('/')) {
|
if (!nextPath.startsWith('/')) {
|
||||||
|
|
@ -27,6 +30,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
|
secure: isHttps,
|
||||||
maxAge: 60 * 60 * 12,
|
maxAge: 60 * 60 * 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue