feat(security): proxy-safe rate limiting + auth validation

This commit is contained in:
Marco Allegretti 2026-02-24 19:45:42 +01:00
parent 8007433d5f
commit d360182093
13 changed files with 243 additions and 41 deletions

View file

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

View file

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

View file

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

View file

@ -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());

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
}); });