fix: per-IP auth rate limiting instead of one global bucket
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:
@@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
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::token::{generate_token, hash_token};
|
||||
use crate::config::AuthConfig;
|
||||
@@ -107,6 +107,7 @@ pub struct CreatedTokenResponse {
|
||||
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
ClientIp(client_ip): ClientIp,
|
||||
jar: CookieJar,
|
||||
Json(input): Json<Credentials>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
@@ -114,7 +115,7 @@ async fn register(
|
||||
// the toggle can't be probed for the toggle state via timing —
|
||||
// disabled and enabled paths both consume a token, and disabled
|
||||
// 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
|
||||
// ALLOW_SELF_REGISTER — operators of locked-down instances mint
|
||||
// accounts via `POST /admin/users` instead.
|
||||
@@ -133,10 +134,11 @@ async fn register(
|
||||
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
ClientIp(client_ip): ClientIp,
|
||||
jar: CookieJar,
|
||||
Json(input): Json<Credentials>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
check_auth_rate_limit(&state, "login")?;
|
||||
check_auth_rate_limit(&state, "login", client_ip)?;
|
||||
let username = input.username.trim();
|
||||
if username.is_empty() || input.password.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
@@ -214,10 +216,11 @@ async fn me(CurrentUser(user): CurrentUser) -> AppResult<Json<AuthResponse>> {
|
||||
async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
ClientIp(client_ip): ClientIp,
|
||||
jar: CookieJar,
|
||||
Json(input): Json<ChangePassword>,
|
||||
) -> 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
|
||||
// vector as login). new_password is bounded by validate_password below.
|
||||
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`
|
||||
/// is included in the rate-limit-hit log line so operators can tell
|
||||
/// 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;
|
||||
match state.auth_limiter.try_acquire() {
|
||||
match state.auth_limiter.try_acquire(client_ip) {
|
||||
AcquireResult::Allowed => Ok(()),
|
||||
AcquireResult::Denied { retry_after_secs } => {
|
||||
tracing::warn!(
|
||||
|
||||
Reference in New Issue
Block a user