fix: per-IP auth rate limiting instead of one global bucket
All checks were successful
deploy / test-frontend (push) Successful in 10m44s
deploy / build-and-push (push) Successful in 11m35s
deploy / deploy (push) Successful in 12s
deploy / test-backend (push) Successful in 31m21s

A single global token bucket let one attacker at the sustained rate 429
every user's login/register/change-password. Key buckets by client IP: the
SvelteKit proxy now stamps the real peer address onto X-Forwarded-For
(overriding any client-supplied value, anti-spoof), and axum reads it via a
ClientIp extractor — but only when AUTH_TRUSTED_PROXY is set, else it falls
back to the shared bucket (today's behavior). The per-IP map is bounded
(10k IPs, idle buckets pruned) so a spoofed-IP spray can't grow it.

AUTH_TRUSTED_PROXY defaults false (safe for a directly-exposed backend);
compose sets it true since the proxy is the single trusted hop.

Bump to 0.124.12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-08 07:07:06 +02:00
parent bf425cf8e6
commit f879ce1866
11 changed files with 314 additions and 47 deletions

View File

@@ -51,6 +51,13 @@ SESSION_TTL_DAYS=30
# rate-limiting reverse proxy that already enforces a budget). # rate-limiting reverse proxy that already enforces a budget).
AUTH_RATE_PER_SEC=5 AUTH_RATE_PER_SEC=5
AUTH_RATE_BURST=10 AUTH_RATE_BURST=10
# Trust a proxy-supplied X-Forwarded-For as the client IP for per-IP auth
# rate limiting. Enable ONLY when the backend sits behind a trusted proxy
# that overrides the header (the compose deploy: SvelteKit forwards the real
# peer IP). When false, the header is ignored and a single shared bucket is
# used — a directly-exposed backend MUST keep this off or clients could spoof
# their IP to dodge the limit.
AUTH_TRUSTED_PROXY=false
# ----- CORS ----- # ----- CORS -----
# Comma-separated origins allowed to call the API with credentials. # Comma-separated origins allowed to call the API with credentials.

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.124.11" version = "0.124.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.124.11" version = "0.124.12"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::app::AppState; use crate::app::AppState;
use crate::auth::extractor::{CurrentUser, SESSION_COOKIE_NAME}; use crate::auth::extractor::{ClientIp, CurrentUser, SESSION_COOKIE_NAME};
use crate::auth::password::{hash_password_async, verify_password_async}; use crate::auth::password::{hash_password_async, verify_password_async};
use crate::auth::token::{generate_token, hash_token}; use crate::auth::token::{generate_token, hash_token};
use crate::config::AuthConfig; use crate::config::AuthConfig;
@@ -107,6 +107,7 @@ pub struct CreatedTokenResponse {
async fn register( async fn register(
State(state): State<AppState>, State(state): State<AppState>,
ClientIp(client_ip): ClientIp,
jar: CookieJar, jar: CookieJar,
Json(input): Json<Credentials>, Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
@@ -114,7 +115,7 @@ async fn register(
// the toggle can't be probed for the toggle state via timing — // the toggle can't be probed for the toggle state via timing —
// disabled and enabled paths both consume a token, and disabled // disabled and enabled paths both consume a token, and disabled
// returns 403 instead of running argon2. // returns 403 instead of running argon2.
check_auth_rate_limit(&state, "register")?; check_auth_rate_limit(&state, "register", client_ip)?;
// Private mode force-blocks self-registration regardless of // Private mode force-blocks self-registration regardless of
// ALLOW_SELF_REGISTER — operators of locked-down instances mint // ALLOW_SELF_REGISTER — operators of locked-down instances mint
// accounts via `POST /admin/users` instead. // accounts via `POST /admin/users` instead.
@@ -133,10 +134,11 @@ async fn register(
async fn login( async fn login(
State(state): State<AppState>, State(state): State<AppState>,
ClientIp(client_ip): ClientIp,
jar: CookieJar, jar: CookieJar,
Json(input): Json<Credentials>, Json(input): Json<Credentials>,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
check_auth_rate_limit(&state, "login")?; check_auth_rate_limit(&state, "login", client_ip)?;
let username = input.username.trim(); let username = input.username.trim();
if username.is_empty() || input.password.is_empty() { if username.is_empty() || input.password.is_empty() {
return Err(AppError::InvalidInput( return Err(AppError::InvalidInput(
@@ -214,10 +216,11 @@ async fn me(CurrentUser(user): CurrentUser) -> AppResult<Json<AuthResponse>> {
async fn change_password( async fn change_password(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(user): CurrentUser, CurrentUser(user): CurrentUser,
ClientIp(client_ip): ClientIp,
jar: CookieJar, jar: CookieJar,
Json(input): Json<ChangePassword>, Json(input): Json<ChangePassword>,
) -> AppResult<impl IntoResponse> { ) -> AppResult<impl IntoResponse> {
check_auth_rate_limit(&state, "change_password")?; check_auth_rate_limit(&state, "change_password", client_ip)?;
// Cap current_password before verify_password runs argon2 (same DoS // Cap current_password before verify_password runs argon2 (same DoS
// vector as login). new_password is bounded by validate_password below. // vector as login). new_password is bounded by validate_password below.
reject_oversized_password(&input.current_password)?; reject_oversized_password(&input.current_password)?;
@@ -417,9 +420,13 @@ fn build_expired_cookie(cfg: &AuthConfig) -> Cookie<'static> {
/// any one of them in a tight loop should trip the limit. `endpoint` /// any one of them in a tight loop should trip the limit. `endpoint`
/// is included in the rate-limit-hit log line so operators can tell /// is included in the rate-limit-hit log line so operators can tell
/// which endpoint is being probed. /// which endpoint is being probed.
fn check_auth_rate_limit(state: &AppState, endpoint: &'static str) -> AppResult<()> { fn check_auth_rate_limit(
state: &AppState,
endpoint: &'static str,
client_ip: Option<std::net::IpAddr>,
) -> AppResult<()> {
use crate::auth::rate_limit::AcquireResult; use crate::auth::rate_limit::AcquireResult;
match state.auth_limiter.try_acquire() { match state.auth_limiter.try_acquire(client_ip) {
AcquireResult::Allowed => Ok(()), AcquireResult::Allowed => Ok(()),
AcquireResult::Denied { retry_after_secs } => { AcquireResult::Denied { retry_after_secs } => {
tracing::warn!( tracing::warn!(

View File

@@ -70,6 +70,45 @@ impl FromRequestParts<AppState> for CurrentUser {
} }
} }
/// Client IP for per-IP auth rate limiting. Resolves to the first hop of
/// `X-Forwarded-For` **only** when [`crate::config::AuthConfig::trusted_proxy`]
/// is set (the backend is behind a proxy that overrides the header — the
/// compose deploy). Otherwise `None`, so the limiter uses its shared bucket.
/// Never fails: a missing or malformed header simply yields `None`.
pub struct ClientIp(pub Option<std::net::IpAddr>);
/// Parse the client IP from an `X-Forwarded-For` value: the left-most hop is
/// the original client (later hops are intermediary proxies). Factored out so
/// the parsing is unit-testable without constructing a request.
pub(crate) fn first_forwarded_ip(header: &str) -> Option<std::net::IpAddr> {
header
.split(',')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<std::net::IpAddr>().ok())
}
#[async_trait]
impl FromRequestParts<AppState> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
if !state.auth.trusted_proxy {
return Ok(ClientIp(None));
}
let ip = parts
.headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(first_forwarded_ip);
Ok(ClientIp(ip))
}
}
/// Cookie-only authentication. Bot/API tokens are explicitly NOT accepted /// Cookie-only authentication. Bot/API tokens are explicitly NOT accepted
/// here — this is the substrate for [`RequireAdmin`] and exists precisely /// here — this is the substrate for [`RequireAdmin`] and exists precisely
/// to keep admin authority out of bearer-token reach. /// to keep admin authority out of bearer-token reach.
@@ -120,3 +159,34 @@ impl FromRequestParts<AppState> for RequireAdmin {
Ok(RequireAdmin(user)) Ok(RequireAdmin(user))
} }
} }
#[cfg(test)]
mod tests {
use super::first_forwarded_ip;
use std::net::IpAddr;
#[test]
fn parses_left_most_client_hop() {
// The client is the first entry; later entries are intermediary proxies.
assert_eq!(
first_forwarded_ip("203.0.113.5, 10.0.0.1, 10.0.0.2"),
Some("203.0.113.5".parse::<IpAddr>().unwrap())
);
assert_eq!(
first_forwarded_ip(" 198.51.100.9 "),
Some("198.51.100.9".parse::<IpAddr>().unwrap())
);
assert_eq!(
first_forwarded_ip("2001:db8::1, 10.0.0.1"),
Some("2001:db8::1".parse::<IpAddr>().unwrap())
);
}
#[test]
fn rejects_empty_or_garbage() {
assert_eq!(first_forwarded_ip(""), None);
assert_eq!(first_forwarded_ip(" "), None);
assert_eq!(first_forwarded_ip("not-an-ip"), None);
assert_eq!(first_forwarded_ip(", 10.0.0.1"), None);
}
}

View File

@@ -15,9 +15,17 @@
//! tests run in isolated buckets and won't bleed across `#[sqlx::test]` //! tests run in isolated buckets and won't bleed across `#[sqlx::test]`
//! cases that share a process. //! cases that share a process.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Instant; use std::time::Instant;
/// Upper bound on distinct client IPs tracked at once, so a spray from many
/// spoofed/rotating source IPs can't grow the map without limit. When full,
/// idle (refilled-to-burst) buckets are pruned first; if none are idle a new
/// IP falls back to the shared global bucket for that request.
const MAX_TRACKED_IPS: usize = 10_000;
/// Tunable limits. `per_sec == 0` disables the limiter — used by the /// Tunable limits. `per_sec == 0` disables the limiter — used by the
/// test harness and by anyone who wants to opt out via env config. /// test harness and by anyone who wants to opt out via env config.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
@@ -50,6 +58,42 @@ struct Bucket {
last_refill: Instant, last_refill: Instant,
} }
impl Bucket {
fn new(burst: u32) -> Self {
Self {
tokens: f64::from(burst),
last_refill: Instant::now(),
}
}
/// Refill by elapsed time then try to consume one token.
fn try_take(&mut self, cfg: &RateLimitConfig, now: Instant) -> AcquireResult {
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
self.tokens =
(self.tokens + elapsed * f64::from(cfg.per_sec)).min(f64::from(cfg.burst));
self.last_refill = now;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
AcquireResult::Allowed
} else {
let deficit = 1.0 - self.tokens;
let wait_secs = (deficit / f64::from(cfg.per_sec)).ceil() as u64;
AcquireResult::Denied {
retry_after_secs: wait_secs.max(1),
}
}
}
/// Whether this bucket has fully refilled (i.e. the client has been idle).
/// Such buckets carry no state worth keeping — dropping and lazily
/// recreating one yields an identical full bucket — so they're the safe
/// eviction target under memory pressure.
fn is_idle(&self, cfg: &RateLimitConfig, now: Instant) -> bool {
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
(self.tokens + elapsed * f64::from(cfg.per_sec)) >= f64::from(cfg.burst)
}
}
/// Outcome of [`AuthRateLimiter::try_acquire`]. When `Denied`, the /// Outcome of [`AuthRateLimiter::try_acquire`]. When `Denied`, the
/// caller can use `retry_after_secs` for a `Retry-After: N` header /// caller can use `retry_after_secs` for a `Retry-After: N` header
/// (RFC 6585 §4) so well-behaved clients back off correctly rather /// (RFC 6585 §4) so well-behaved clients back off correctly rather
@@ -60,51 +104,64 @@ pub enum AcquireResult {
Denied { retry_after_secs: u64 }, Denied { retry_after_secs: u64 },
} }
/// Single-bucket token-bucket limiter. `try_acquire` is cheap (one /// Token-bucket limiter keyed by client IP, with a shared global bucket for
/// mutex acquire, no allocations) so the auth path doesn't pay a real /// requests whose IP is unknown (no trusted proxy).
/// cost for the check. ///
/// The old design was a single global bucket. That let one attacker at the
/// sustained rate deny auth to *every* user (the bucket stayed drained). With
/// the SvelteKit proxy now forwarding the peer IP (`X-Forwarded-For`, honoured
/// only when `AUTH_TRUSTED_PROXY` is set), each source IP gets its own bucket,
/// so an attacker only throttles themselves. When no trustworthy IP is
/// available the caller passes `None` and the shared `global` bucket applies —
/// exactly the previous behaviour.
pub struct AuthRateLimiter { pub struct AuthRateLimiter {
cfg: RateLimitConfig, cfg: RateLimitConfig,
bucket: Mutex<Bucket>, global: Mutex<Bucket>,
per_ip: Mutex<HashMap<IpAddr, Bucket>>,
} }
impl AuthRateLimiter { impl AuthRateLimiter {
pub fn new(cfg: RateLimitConfig) -> Self { pub fn new(cfg: RateLimitConfig) -> Self {
Self { Self {
cfg, cfg,
bucket: Mutex::new(Bucket { global: Mutex::new(Bucket::new(cfg.burst)),
tokens: cfg.burst as f64, per_ip: Mutex::new(HashMap::new()),
last_refill: Instant::now(),
}),
} }
} }
/// Consume one token if available. Returns `Denied` with a /// Consume one token from the bucket for `key` (per-IP when `Some`, the
/// rounded-up seconds-until-refill so the caller can emit a /// shared global bucket when `None`). Returns `Denied` with a rounded-up
/// `Retry-After` header. /// seconds-until-refill so the caller can emit a `Retry-After` header.
pub fn try_acquire(&self) -> AcquireResult { pub fn try_acquire(&self, key: Option<IpAddr>) -> AcquireResult {
if self.cfg.per_sec == 0 { if self.cfg.per_sec == 0 {
return AcquireResult::Allowed; return AcquireResult::Allowed;
} }
let now = Instant::now(); let now = Instant::now();
let mut bucket = self.bucket.lock().expect("rate limiter mutex poisoned"); let Some(ip) = key else {
let elapsed = now.duration_since(bucket.last_refill).as_secs_f64(); return self
bucket.tokens = .global
(bucket.tokens + elapsed * f64::from(self.cfg.per_sec)).min(f64::from(self.cfg.burst)); .lock()
bucket.last_refill = now; .expect("rate limiter mutex poisoned")
if bucket.tokens >= 1.0 { .try_take(&self.cfg, now);
bucket.tokens -= 1.0; };
AcquireResult::Allowed let mut map = self.per_ip.lock().expect("rate limiter mutex poisoned");
} else { if map.len() >= MAX_TRACKED_IPS && !map.contains_key(&ip) {
// ceil((1 - tokens) / per_sec), minimum 1 — a `Retry-After: 0` map.retain(|_, b| !b.is_idle(&self.cfg, now));
// would tell clients to retry immediately, which is what we're if map.len() >= MAX_TRACKED_IPS {
// actively trying to discourage. // Still saturated with active attackers — degrade to the shared
let deficit = 1.0 - bucket.tokens; // bucket rather than grow unbounded. Worst case is the old
let wait_secs = (deficit / f64::from(self.cfg.per_sec)).ceil() as u64; // global-bucket behaviour under an extreme distributed flood.
AcquireResult::Denied { drop(map);
retry_after_secs: wait_secs.max(1), return self
.global
.lock()
.expect("rate limiter mutex poisoned")
.try_take(&self.cfg, now);
} }
} }
map.entry(ip)
.or_insert_with(|| Bucket::new(self.cfg.burst))
.try_take(&self.cfg, now)
} }
} }
@@ -119,7 +176,7 @@ mod tests {
burst: 0, burst: 0,
}); });
for _ in 0..1000 { for _ in 0..1000 {
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
} }
} }
@@ -130,10 +187,10 @@ mod tests {
per_sec: 1, per_sec: 1,
burst: 3, burst: 3,
}); });
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
match rl.try_acquire() { match rl.try_acquire(None) {
AcquireResult::Denied { retry_after_secs } => { AcquireResult::Denied { retry_after_secs } => {
// Bucket is at ~0 tokens, refill rate 1/sec → ~1s wait. // Bucket is at ~0 tokens, refill rate 1/sec → ~1s wait.
assert!( assert!(
@@ -152,11 +209,11 @@ mod tests {
per_sec: 10, per_sec: 10,
burst: 1, burst: 1,
}); });
assert_eq!(rl.try_acquire(), AcquireResult::Allowed); assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(), AcquireResult::Denied { .. })); assert!(matches!(rl.try_acquire(None), AcquireResult::Denied { .. }));
std::thread::sleep(std::time::Duration::from_millis(150)); std::thread::sleep(std::time::Duration::from_millis(150));
assert_eq!( assert_eq!(
rl.try_acquire(), rl.try_acquire(None),
AcquireResult::Allowed, AcquireResult::Allowed,
"token should have refilled" "token should have refilled"
); );
@@ -170,10 +227,64 @@ mod tests {
per_sec: 1, per_sec: 1,
burst: 1, burst: 1,
}); });
slow.try_acquire(); slow.try_acquire(None);
match slow.try_acquire() { match slow.try_acquire(None) {
AcquireResult::Denied { retry_after_secs } => assert_eq!(retry_after_secs, 1), AcquireResult::Denied { retry_after_secs } => assert_eq!(retry_after_secs, 1),
_ => panic!("expected Denied"), _ => panic!("expected Denied"),
} }
} }
fn ip(s: &str) -> Option<IpAddr> {
Some(s.parse().unwrap())
}
#[test]
fn per_ip_buckets_are_independent() {
// The whole point of the fix: one IP draining its bucket must not deny
// a different IP.
let rl = AuthRateLimiter::new(RateLimitConfig {
per_sec: 1,
burst: 2,
});
let a = ip("203.0.113.7");
let b = ip("198.51.100.9");
assert_eq!(rl.try_acquire(a), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(a), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(a), AcquireResult::Denied { .. }));
// b is untouched.
assert_eq!(rl.try_acquire(b), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(b), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(b), AcquireResult::Denied { .. }));
}
#[test]
fn none_key_shares_the_global_bucket_independent_of_per_ip() {
let rl = AuthRateLimiter::new(RateLimitConfig {
per_sec: 1,
burst: 2,
});
// Drain the global (None) bucket.
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert_eq!(rl.try_acquire(None), AcquireResult::Allowed);
assert!(matches!(rl.try_acquire(None), AcquireResult::Denied { .. }));
// A real IP is on its own bucket, unaffected by the drained global one.
assert_eq!(rl.try_acquire(ip("203.0.113.1")), AcquireResult::Allowed);
}
#[test]
fn tracked_ip_map_is_bounded() {
let rl = AuthRateLimiter::new(RateLimitConfig {
per_sec: 1,
burst: 1,
});
// Distinct IPs beyond the cap must not grow the map without bound —
// excess requests fall back to the shared bucket instead.
for i in 0..(MAX_TRACKED_IPS as u64 + 50) {
let octet_a = (i >> 8) as u8;
let octet_b = (i & 0xff) as u8;
let addr = format!("10.20.{octet_a}.{octet_b}");
let _ = rl.try_acquire(Some(addr.parse().unwrap()));
}
assert!(rl.per_ip.lock().unwrap().len() <= MAX_TRACKED_IPS);
}
} }

View File

@@ -27,6 +27,14 @@ pub struct AuthConfig {
/// so a private instance is locked down with a single switch. /// so a private instance is locked down with a single switch.
/// Defaults to `false` (current public behaviour). /// Defaults to `false` (current public behaviour).
pub private_mode: bool, pub private_mode: bool,
/// Whether to trust a proxy-supplied `X-Forwarded-For` header as the
/// client IP for per-IP auth rate limiting. Enable ONLY when the backend
/// sits behind a trusted reverse proxy that overrides the header (the
/// compose deploy: SvelteKit's hooks.server.ts sets it from the real peer
/// address). When `false` (default), the header is ignored and the auth
/// limiter uses a single shared bucket — a directly-exposed backend must
/// keep this off or clients could spoof their IP to dodge the limit.
pub trusted_proxy: bool,
} }
impl Default for AuthConfig { impl Default for AuthConfig {
@@ -42,6 +50,7 @@ impl Default for AuthConfig {
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(), rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
allow_self_register: true, allow_self_register: true,
private_mode: false, private_mode: false,
trusted_proxy: false,
} }
} }
} }
@@ -584,6 +593,7 @@ impl Config {
}, },
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true), allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
private_mode: env_bool("PRIVATE_MODE", false), private_mode: env_bool("PRIVATE_MODE", false),
trusted_proxy: env_bool("AUTH_TRUSTED_PROXY", false),
}, },
upload: UploadConfig { upload: UploadConfig {
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024), max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),

View File

@@ -80,6 +80,10 @@ services:
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
AUTH_RATE_PER_SEC: ${AUTH_RATE_PER_SEC:-5} AUTH_RATE_PER_SEC: ${AUTH_RATE_PER_SEC:-5}
AUTH_RATE_BURST: ${AUTH_RATE_BURST:-10} AUTH_RATE_BURST: ${AUTH_RATE_BURST:-10}
# The SvelteKit container is the single trusted hop in front of the
# backend and stamps the real client IP, so per-IP rate limiting is on
# by default here (unlike the backend's safe-by-default `false`).
AUTH_TRUSTED_PROXY: ${AUTH_TRUSTED_PROXY:-true}
# CORS — same-origin by default; populate when serving the API on # CORS — same-origin by default; populate when serving the API on
# a different host than the frontend. # a different host than the frontend.
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.124.11", "version": "0.124.12",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -9,6 +9,7 @@ import {
} from 'vitest'; } from 'vitest';
import { import {
handle, handle,
setForwardedFor,
shouldBypassProxyTimeout, shouldBypassProxyTimeout,
stripHopByHopHeaders stripHopByHopHeaders
} from './hooks.server'; } from './hooks.server';
@@ -351,3 +352,29 @@ describe('stripHopByHopHeaders', () => {
expect(src.get('connection')).toBe('close'); expect(src.get('connection')).toBe('close');
}); });
}); });
describe('setForwardedFor', () => {
it('overrides any client-supplied x-forwarded-for with the real address', () => {
const headers = new Headers({ 'x-forwarded-for': '1.2.3.4', 'x-real-ip': '1.2.3.4' });
setForwardedFor(headers, '203.0.113.9');
expect(headers.get('x-forwarded-for')).toBe('203.0.113.9');
// x-real-ip is dropped so it can't be used to spoof either.
expect(headers.get('x-real-ip')).toBeNull();
});
it('strips the header entirely when no client address is available', () => {
const headers = new Headers({ 'x-forwarded-for': '9.9.9.9' });
setForwardedFor(headers, undefined);
expect(headers.get('x-forwarded-for')).toBeNull();
});
it('trims whitespace and treats blank as absent', () => {
const a = new Headers();
setForwardedFor(a, ' 198.51.100.2 ');
expect(a.get('x-forwarded-for')).toBe('198.51.100.2');
const b = new Headers({ 'x-forwarded-for': 'spoofed' });
setForwardedFor(b, ' ');
expect(b.get('x-forwarded-for')).toBeNull();
});
});

View File

@@ -47,6 +47,26 @@ export function stripHopByHopHeaders(src: Headers): Headers {
return out; return out;
} }
/**
* Stamp the real client address onto `x-forwarded-for` for the upstream
* request so axum can key its per-IP auth rate limiter on the actual client
* (the backend only honours this when `AUTH_TRUSTED_PROXY=true`). We
* **override** rather than append, and drop any incoming `x-forwarded-for` /
* `x-real-ip`, so a browser can't spoof its own IP to dodge the limit — this
* proxy is the single trusted hop. A missing/blank address leaves the headers
* untouched (backend then falls back to its shared bucket). Exported for
* unit-test coverage.
*/
export function setForwardedFor(headers: Headers, clientAddress: string | undefined): Headers {
headers.delete('x-real-ip');
if (clientAddress && clientAddress.trim() !== '') {
headers.set('x-forwarded-for', clientAddress.trim());
} else {
headers.delete('x-forwarded-for');
}
return headers;
}
/** /**
* Cap each proxied request at 5 minutes. The bound exists to surface * Cap each proxied request at 5 minutes. The bound exists to surface
* a wedged backend (stuck on a slow DB query, deadlocked, etc.) as a * a wedged backend (stuck on a slow DB query, deadlocked, etc.) as a
@@ -90,6 +110,17 @@ export const handle: Handle = async ({ event, resolve }) => {
const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`; const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`;
const headers = stripHopByHopHeaders(event.request.headers); const headers = stripHopByHopHeaders(event.request.headers);
// Forward the real client IP for the backend's per-IP auth rate
// limiter, overriding any client-supplied value (anti-spoof).
// `getClientAddress()` throws if the adapter can't determine it — fall
// back to stripping the header so no spoofed value survives.
let clientAddress: string | undefined;
try {
clientAddress = event.getClientAddress();
} catch {
clientAddress = undefined;
}
setForwardedFor(headers, clientAddress);
// AbortController times the upstream fetch out so a backend // AbortController times the upstream fetch out so a backend
// wedged on a slow DB query doesn't keep the browser request // wedged on a slow DB query doesn't keep the browser request