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).
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -63,6 +63,42 @@ async fn register(
|
|||
Extension(config): Extension<Arc<Config>>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> 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
|
||||
let settings = sqlx::query!(
|
||||
"SELECT registration_enabled, registration_mode FROM instance_settings LIMIT 1"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ pub struct Config {
|
|||
pub server_port: u16,
|
||||
#[serde(default)]
|
||||
pub cors_allowed_origins: Option<String>,
|
||||
#[serde(default)]
|
||||
pub trusted_proxy_ips: Option<String>,
|
||||
/// 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(),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use crate::config::Config;
|
|||
#[derive(Clone)]
|
||||
pub struct RateLimitState {
|
||||
config: Arc<Config>,
|
||||
trusted_proxies: Vec<IpAddr>,
|
||||
ip: Arc<FixedWindowLimiter>,
|
||||
user: Arc<FixedWindowLimiter>,
|
||||
auth: Arc<FixedWindowLimiter>,
|
||||
|
|
@ -26,6 +27,7 @@ impl RateLimitState {
|
|||
pub fn new(config: Arc<Config>) -> 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<B>(request: &Request<B>) -> Option<IpAddr> {
|
|||
.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> {
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ DB_PORT=5432
|
|||
|
||||
# Backend
|
||||
JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING
|
||||
TRUSTED_PROXY_IPS=
|
||||
BACKEND_PORT=3000
|
||||
|
||||
# Frontend
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
- 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue