fix(rate-limit): key the guest-facing limiters per user, not per IP
At a venue every guest is behind one NAT, so an IP-keyed limiter hands the
whole party a single bucket. On a fresh deploy 12 guests arriving together
meant 5 joined and 7 were turned away, with no Retry-After telling them when
to retry. `/feed` (60/min) and `/export` (3 per DAY — the fourth guest to
fetch their keepsake locked out until tomorrow) had the same defect.
`feed_delta` was already keyed per-user and its comment states the exact
rationale ("so one client can't starve others behind a shared NAT"); this
makes its siblings match.
- feed:{ip} -> feed:{user_id} (auth was already in scope)
- export:{ip} -> export:{user_id} (resolved from the download ticket's
session, which was previously looked up and discarded)
- join:{ip}: pre-auth, so there is no user to key on. Split in two — a loose
per-IP ceiling that only bounds raw volume (new `join_ip_rate_per_min`,
default 60, migration 017), plus the real 5/60s anti-spam bucket keyed
per (ip, name), mirroring the existing `recover:{ip}:{name}`.
admin_login / recover / pin_reset_req stay IP-keyed on purpose and are now
commented as such: they guard credential guessing, where a per-user or
per-name key would just hand an attacker a fresh bucket per guess.
Retry-After: the machinery existed but 7 of 8 sites called `check()` and
hard-coded `None`, so a throttled client was told to back off but never for
how long. Delete the bool `check()` wrapper entirely so `check_with_retry`
is the only entry point and the delay cannot be discarded by accident. Also
surface it for the PIN lockout, where the deadline was already known.
Fix the "unknown" fallback while here: every client_ip() caller passed that
literal, so any request without X-Forwarded-For — anything reaching the app
directly rather than through Caddy — shared ONE global bucket. Serve with
connect-info and use the peer address.
Tests: the reseed forces every limiter toggle off before each test, which is
why this whole class was invisible. Add 01-auth/rate-limit-shared-nat, which
enables them and asserts 12 guests share an IP without collision, that one
guest hammering their own name IS still throttled (so the fix re-keys rather
than removes the limit), and that feed/export buckets are per-user. Retarget
the ddos join test at the new per-IP ceiling — it asserted the defect.
Also seed `admin_login_rate_enabled` (read by the handler, seeded by no
migration and no reseed) and register `join_ip_rate_per_min` in the admin
config allowlist. Unrelated pre-existing red test fixed: 01-auth/join
asserted a "Willkommen!" heading the wedding redesign removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
backend/migrations/017_join_ip_rate.down.sql
Normal file
1
backend/migrations/017_join_ip_rate.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DELETE FROM config WHERE key IN ('join_ip_rate_per_min', 'admin_login_rate_enabled');
|
||||
18
backend/migrations/017_join_ip_rate.up.sql
Normal file
18
backend/migrations/017_join_ip_rate.up.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Per-IP flood ceiling for /join, and the `admin_login_rate_enabled` toggle that
|
||||
-- every prior migration forgot to seed.
|
||||
--
|
||||
-- Rationale: /join was throttled at 5 requests per 60s keyed on the client IP. At a
|
||||
-- venue every guest is behind one NAT, so the whole party shared a single bucket —
|
||||
-- 12 guests scanning the QR code within a few seconds meant 5 got in and 7 were
|
||||
-- turned away. The handler now keys the real anti-spam bucket per (ip, name), the
|
||||
-- same shape as `recover:{ip}:{name}`, and keeps only a loose per-IP ceiling to bound
|
||||
-- raw volume. 60/min comfortably covers a whole wedding arriving at once while still
|
||||
-- capping a flood from a single source.
|
||||
--
|
||||
-- `admin_login_rate_enabled` is read by auth::handlers::admin_login with a code
|
||||
-- default of `true`, but no migration ever inserted it, so it was invisible to the
|
||||
-- admin config UI and to the e2e reseed. Seed it explicitly.
|
||||
INSERT INTO config (key, value) VALUES
|
||||
('join_ip_rate_per_min', '60'),
|
||||
('admin_login_rate_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::extract::{ConnectInfo, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::jwt;
|
||||
@@ -33,22 +34,32 @@ pub struct JoinResponse {
|
||||
|
||||
pub async fn join(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<JoinRequest>,
|
||||
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
|
||||
if rate_limits_on
|
||||
&& join_rate_on
|
||||
&& !state
|
||||
.rate_limiter
|
||||
.check(format!("join:{ip}"), 5, Duration::from_secs(60))
|
||||
{
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
|
||||
// Coarse per-IP flood ceiling. `/join` is pre-auth so there is no user to key on, and
|
||||
// at a venue EVERY guest arrives from one public IP — a tight per-IP bucket meant the
|
||||
// 6th person through the door was turned away by the 5 ahead of them. So the per-IP
|
||||
// limit here only bounds raw volume; the real anti-spam bucket is per-name below.
|
||||
// Cheap enough to run before validation, which keeps a flood of malformed bodies from
|
||||
// being free.
|
||||
if rate_limits_on && join_rate_on {
|
||||
let ip_ceiling = config::get_usize(&state.config_cache, "join_ip_rate_per_min", 60).await;
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("join_ip:{ip}"),
|
||||
ip_ceiling,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let display_name = body.display_name.trim();
|
||||
@@ -66,6 +77,23 @@ pub async fn join(
|
||||
));
|
||||
}
|
||||
|
||||
// Per-guest bucket, keyed like the `recover:{ip}:{name}` limiter below. This carries
|
||||
// the original 5/60s anti-spam intent, but one guest retrying can no longer consume
|
||||
// the allowance of everyone else sharing the venue's NAT.
|
||||
if rate_limits_on && join_rate_on {
|
||||
let name_key = display_name.to_lowercase();
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("join:{ip}:{name_key}"),
|
||||
5,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let event = Event::find_or_create(
|
||||
&state.pool,
|
||||
&state.config.event_slug,
|
||||
@@ -149,6 +177,7 @@ fn dummy_pin_hash() -> &'static str {
|
||||
|
||||
pub async fn recover(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<RecoverRequest>,
|
||||
) -> Result<Json<RecoverResponse>, AppError> {
|
||||
@@ -159,19 +188,19 @@ pub async fn recover(
|
||||
// burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
|
||||
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
|
||||
// softens that into a real cost.
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
|
||||
if rate_limits_on && recover_rate_on {
|
||||
let name_key = display_name.to_lowercase();
|
||||
if !state.rate_limiter.check(
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("recover:{ip}:{name_key}"),
|
||||
5,
|
||||
Duration::from_secs(15 * 60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -200,9 +229,12 @@ pub async fn recover(
|
||||
// is effectively permanently fragile.
|
||||
if let Some(locked_until) = user.pin_locked_until {
|
||||
if Utc::now() < locked_until {
|
||||
// The exact deadline is known, so surface it as Retry-After instead of
|
||||
// making the client guess at the "15 Minuten" in the copy.
|
||||
let retry_after_secs = (locked_until - Utc::now()).num_seconds().max(1) as u64;
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
||||
None,
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
// Lockout window expired — wipe the counter and the timestamp.
|
||||
@@ -274,6 +306,7 @@ pub struct AdminLoginResponse {
|
||||
|
||||
pub async fn admin_login(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<AdminLoginRequest>,
|
||||
) -> Result<Json<AdminLoginResponse>, AppError> {
|
||||
@@ -287,19 +320,23 @@ pub async fn admin_login(
|
||||
// verify) but with no IP-level limit a determined attacker can still mount
|
||||
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
||||
// honest typos.
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let admin_rate_on =
|
||||
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
||||
// Stays keyed by IP on purpose: this guards a single shared credential, so a per-user
|
||||
// or per-name key would just hand an attacker a fresh bucket per guess.
|
||||
if rate_limits_on
|
||||
&& admin_rate_on
|
||||
&& !state
|
||||
.rate_limiter
|
||||
.check(format!("admin_login:{ip}"), 5, Duration::from_secs(60))
|
||||
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("admin_login:{ip}"),
|
||||
5,
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
{
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -390,22 +427,23 @@ pub struct PinResetRequestBody {
|
||||
/// feed already exposes.
|
||||
pub async fn request_pin_reset(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<PinResetRequestBody>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let display_name = body.display_name.trim();
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
if rate_limits_on {
|
||||
let name_key = display_name.to_lowercase();
|
||||
if !state.rate_limiter.check(
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("pin_reset_req:{ip}:{name_key}"),
|
||||
3,
|
||||
Duration::from_secs(15 * 60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::middleware::RequireAdmin;
|
||||
use crate::error::AppError;
|
||||
use crate::services::config;
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||
@@ -120,6 +120,10 @@ pub async fn patch_config(
|
||||
("upload_rate_per_hour", true, 1.0, 100_000.0),
|
||||
("feed_rate_per_min", true, 1.0, 100_000.0),
|
||||
("export_rate_per_day", true, 1.0, 100_000.0),
|
||||
// Loose per-IP ceiling on /join. The real anti-spam bucket is per (ip, name); this
|
||||
// only bounds raw volume from one source, so it must stay well above the size of a
|
||||
// party arriving at once (see migration 017).
|
||||
("join_ip_rate_per_min", true, 1.0, 100_000.0),
|
||||
("quota_tolerance", false, 0.0, 1.0),
|
||||
("estimated_guest_count", true, 1.0, 1_000_000.0),
|
||||
];
|
||||
@@ -320,25 +324,26 @@ pub async fn export_ticket(
|
||||
}
|
||||
|
||||
/// Validate a download ticket (single-use) and confirm its session still exists.
|
||||
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<(), AppError> {
|
||||
/// Resolve a single-use download ticket to the user who minted it. The caller needs the
|
||||
/// id to key the export rate limit per-user (see `enforce_export_rate`).
|
||||
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<Uuid, AppError> {
|
||||
let token_hash = state
|
||||
.sse_tickets
|
||||
.consume(ticket)
|
||||
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
|
||||
crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
|
||||
let session = crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?
|
||||
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
|
||||
Ok(())
|
||||
Ok(session.user_id)
|
||||
}
|
||||
|
||||
pub async fn download_zip(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<DownloadQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, user_id).await?;
|
||||
|
||||
let path =
|
||||
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
|
||||
@@ -389,10 +394,9 @@ async fn resolve_export_file(
|
||||
pub async fn download_html(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<DownloadQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, &headers).await?;
|
||||
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
|
||||
enforce_export_rate(&state, user_id).await?;
|
||||
|
||||
let path =
|
||||
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
|
||||
@@ -476,21 +480,24 @@ pub async fn export_status(
|
||||
/// Centralised guard for the export rate limit. Same pattern as upload/feed: master
|
||||
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on
|
||||
/// each request.
|
||||
async fn enforce_export_rate(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
|
||||
async fn enforce_export_rate(state: &AppState, user_id: Uuid) -> Result<(), AppError> {
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let export_rate_on = config::get_bool(&state.config_cache, "export_rate_enabled", true).await;
|
||||
if !(rate_limits_on && export_rate_on) {
|
||||
return Ok(());
|
||||
}
|
||||
let ip = client_ip(headers, "unknown");
|
||||
let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
|
||||
if !state
|
||||
.rate_limiter
|
||||
.check(format!("export:{ip}"), limit, Duration::from_secs(86400))
|
||||
{
|
||||
// Keyed per-user. This was the worst of the IP-keyed limiters: 3 downloads per DAY
|
||||
// shared across every guest behind the venue's public IP, so the fourth person to
|
||||
// fetch their keepsake was locked out until the next day.
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("export:{user_id}"),
|
||||
limit,
|
||||
Duration::from_secs(86400),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -2,7 +2,6 @@ use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -10,7 +9,6 @@ use uuid::Uuid;
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::services::config;
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -61,21 +59,23 @@ struct FeedRow {
|
||||
pub async fn feed(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<FeedQuery>,
|
||||
) -> Result<Json<FeedResponse>, AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
||||
if rate_limits_on && feed_rate_on {
|
||||
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
||||
if !state
|
||||
.rate_limiter
|
||||
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
|
||||
{
|
||||
// Keyed per-user, exactly like `feed_delta` below: at a venue every guest shares
|
||||
// one public IP, so an IP key gave the whole party a single 60/min bucket and the
|
||||
// fastest scroller starved everyone else.
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("feed:{}", auth.user_id),
|
||||
rate_limit,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -225,14 +225,14 @@ pub async fn feed_delta(
|
||||
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
||||
if rate_limits_on && feed_rate_on {
|
||||
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
||||
if !state.rate_limiter.check(
|
||||
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||
format!("feed_delta:{}", auth.user_id),
|
||||
rate_limit,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ use crate::auth::middleware::RequireAdmin;
|
||||
use crate::error::AppError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Truncates every event-scoped table, wipes media on disk, and reseeds the
|
||||
/// `config` table from migration defaults. Requires an admin JWT — even with
|
||||
/// `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously.
|
||||
/// Truncates every event-scoped table, wipes media on disk, and reseeds the `config`
|
||||
/// table: numeric values from the migration defaults, but every feature toggle forced
|
||||
/// OFF (production seeds them ON — see the note at the reseed below). Requires an admin
|
||||
/// JWT — even with `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously.
|
||||
pub async fn truncate_all(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
@@ -40,8 +41,19 @@ pub async fn truncate_all(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Reseed config — mirrors migrations 005, 009 and 015. Kept in sync by hand
|
||||
// because pulling SQL out of the migration files at runtime is fragile.
|
||||
// Reseed config. The NUMERIC values mirror migrations 005/015/016; the BOOLEAN
|
||||
// toggles deliberately do NOT — migration 009 seeds every one of them `true`
|
||||
// (production), and this forces them `false` so the suite isn't fighting rate limits
|
||||
// and quotas it isn't testing.
|
||||
//
|
||||
// Be aware of what that costs: this runs as an auto-fixture before EVERY test, so no
|
||||
// test starts from production's config unless it explicitly turns a toggle back on
|
||||
// (02-upload/rate-limit, 07-adversarial/ddos, 01-auth/rate-limit-nat, …). That blind
|
||||
// spot is exactly why an entire class of per-IP limiter bugs went unnoticed: the
|
||||
// limiters were simply off. When adding a limiter or quota, add a spec that enables it.
|
||||
//
|
||||
// Kept in sync by hand because pulling SQL out of the migration files at runtime is
|
||||
// fragile — if you add a config key in a migration, add it here too.
|
||||
sqlx::query(
|
||||
r#"INSERT INTO config (key, value) VALUES
|
||||
('max_image_size_mb', '20'),
|
||||
@@ -49,6 +61,7 @@ pub async fn truncate_all(
|
||||
('upload_rate_per_hour', '100'),
|
||||
('feed_rate_per_min', '60'),
|
||||
('export_rate_per_day', '3'),
|
||||
('join_ip_rate_per_min', '60'),
|
||||
('quota_tolerance', '0.75'),
|
||||
('estimated_guest_count', '100'),
|
||||
('compression_concurrency', '2'),
|
||||
@@ -57,6 +70,7 @@ pub async fn truncate_all(
|
||||
('feed_rate_enabled', 'false'),
|
||||
('export_rate_enabled', 'false'),
|
||||
('join_rate_enabled', 'false'),
|
||||
('admin_login_rate_enabled', 'false'),
|
||||
('quota_enabled', 'false'),
|
||||
('storage_quota_enabled', 'false'),
|
||||
('upload_count_quota_enabled', 'false'),
|
||||
|
||||
@@ -252,9 +252,15 @@ async fn main() -> Result<()> {
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
|
||||
tracing::info!("listening on {}", listener.local_addr()?);
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
// `into_make_service_with_connect_info` is required by the pre-auth handlers, which
|
||||
// extract `ConnectInfo<SocketAddr>` to use the peer address as the rate-limit key when
|
||||
// X-Forwarded-For is absent. Without it those extractors fail at runtime.
|
||||
axum::serve(
|
||||
listener,
|
||||
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@ impl RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the request is allowed, `false` if rate-limited.
|
||||
pub fn check(&self, key: impl Into<String>, max: usize, window: Duration) -> bool {
|
||||
self.check_with_retry(key, max, window).is_ok()
|
||||
}
|
||||
|
||||
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
|
||||
/// `retry_after_secs` is how long until the oldest slot in the window expires.
|
||||
///
|
||||
/// This is deliberately the ONLY entry point. There used to be a `check()` wrapper
|
||||
/// returning a plain bool, and 7 of the 8 call sites used it and then hard-coded
|
||||
/// `None` for the response's `Retry-After` — so a throttled client was told to back
|
||||
/// off but never for how long. Forcing every caller through the `Result` makes the
|
||||
/// retry delay impossible to discard by accident.
|
||||
pub fn check_with_retry(
|
||||
&self,
|
||||
key: impl Into<String>,
|
||||
@@ -84,6 +85,11 @@ impl RateLimiter {
|
||||
/// appends is the real client. A client can prepend arbitrary spoofed values to
|
||||
/// the left of XFF to dodge throttles — those are ignored here. This assumes
|
||||
/// exactly one trusted proxy (Caddy); revisit if that changes.
|
||||
///
|
||||
/// Pass the peer address as `fallback`, never a constant. Every caller used to pass
|
||||
/// the literal `"unknown"`, so any request that arrived without XFF — i.e. anything
|
||||
/// reaching the app directly rather than through Caddy — shared ONE bucket with every
|
||||
/// other such request, turning the limiter into a self-inflicted global throttle.
|
||||
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
@@ -104,29 +110,35 @@ mod tests {
|
||||
#[test]
|
||||
fn allows_up_to_max_then_blocks() {
|
||||
let rl = RateLimiter::new();
|
||||
assert!(rl.check("k", 3, MIN));
|
||||
assert!(rl.check("k", 3, MIN));
|
||||
assert!(rl.check("k", 3, MIN));
|
||||
assert!(!rl.check("k", 3, MIN), "the 4th request must be blocked");
|
||||
assert!(rl.check_with_retry("k", 3, MIN).is_ok());
|
||||
assert!(rl.check_with_retry("k", 3, MIN).is_ok());
|
||||
assert!(rl.check_with_retry("k", 3, MIN).is_ok());
|
||||
assert!(
|
||||
rl.check_with_retry("k", 3, MIN).is_err(),
|
||||
"the 4th request must be blocked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_independent() {
|
||||
let rl = RateLimiter::new();
|
||||
assert!(rl.check("a", 1, MIN));
|
||||
assert!(!rl.check("a", 1, MIN));
|
||||
assert!(rl.check("b", 1, MIN), "a different key has its own window");
|
||||
assert!(rl.check_with_retry("a", 1, MIN).is_ok());
|
||||
assert!(rl.check_with_retry("a", 1, MIN).is_err());
|
||||
assert!(
|
||||
rl.check_with_retry("b", 1, MIN).is_ok(),
|
||||
"a different key has its own window"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_slides_and_allows_again_after_expiry() {
|
||||
let rl = RateLimiter::new();
|
||||
let w = Duration::from_millis(40);
|
||||
assert!(rl.check("k", 1, w));
|
||||
assert!(!rl.check("k", 1, w));
|
||||
assert!(rl.check_with_retry("k", 1, w).is_ok());
|
||||
assert!(rl.check_with_retry("k", 1, w).is_err());
|
||||
std::thread::sleep(Duration::from_millis(55));
|
||||
assert!(
|
||||
rl.check("k", 1, w),
|
||||
rl.check_with_retry("k", 1, w).is_ok(),
|
||||
"the slot should expire once the window passes"
|
||||
);
|
||||
}
|
||||
@@ -191,10 +203,13 @@ mod tests {
|
||||
#[test]
|
||||
fn clear_resets_every_window() {
|
||||
let rl = RateLimiter::new();
|
||||
assert!(rl.check("k", 1, MIN));
|
||||
assert!(!rl.check("k", 1, MIN));
|
||||
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
|
||||
assert!(rl.check_with_retry("k", 1, MIN).is_err());
|
||||
rl.clear();
|
||||
assert!(rl.check("k", 1, MIN), "clear() must free the window");
|
||||
assert!(
|
||||
rl.check_with_retry("k", 1, MIN).is_ok(),
|
||||
"clear() must free the window"
|
||||
);
|
||||
}
|
||||
|
||||
/// `prune()` is a memory-leak guard: without it a long-lived process keeps one HashMap
|
||||
@@ -216,7 +231,7 @@ mod tests {
|
||||
.insert("stale".to_string(), vec![ancient]);
|
||||
|
||||
// ...alongside a key that is still inside its window.
|
||||
assert!(rl.check("live", 5, MIN));
|
||||
assert!(rl.check_with_retry("live", 5, MIN).is_ok());
|
||||
assert_eq!(rl.windows.lock().unwrap().len(), 2);
|
||||
|
||||
rl.prune();
|
||||
@@ -239,13 +254,13 @@ mod tests {
|
||||
// prune() dropped live keys, every background sweep would hand attackers a fresh
|
||||
// budget.
|
||||
let rl = RateLimiter::new();
|
||||
assert!(rl.check("k", 1, MIN));
|
||||
assert!(!rl.check("k", 1, MIN));
|
||||
assert!(rl.check_with_retry("k", 1, MIN).is_ok());
|
||||
assert!(rl.check_with_retry("k", 1, MIN).is_err());
|
||||
|
||||
rl.prune();
|
||||
|
||||
assert!(
|
||||
!rl.check("k", 1, MIN),
|
||||
rl.check_with_retry("k", 1, MIN).is_err(),
|
||||
"prune() must not clear a window that is still active"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user