feat: rate-limit /auth/login, /register, /me/password (0.35.0)
A hand-rolled token-bucket limiter (5 req/sec, 10-request burst by default; AUTH_RATE_PER_SEC/AUTH_RATE_BURST env knobs) gates the three auth-mutation endpoints. One bucket per AppState so tests stay isolated. Tower-governor wasn't wired in because the reverse proxy doesn't yet forward client IPs — a global bucket gives equivalent brute-force protection until that lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -567,6 +567,81 @@ async fn user_a_cannot_delete_user_b_token(pool: PgPool) {
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
/// Brute-force / spray protection: at default production limits, a
|
||||
/// tight loop of /auth/login attempts should burst through the bucket
|
||||
/// and then 429 every subsequent request until the bucket refills.
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn login_rate_limited_under_burst_pressure(pool: PgPool) {
|
||||
let h = common::harness_with_auth_rate_limit(pool, 1, 3);
|
||||
|
||||
// Register a victim so the wrong-password branch is real work.
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json("/api/v1/auth/register", creds("victim")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Register consumed one token from the burst-3 bucket. Fire 30
|
||||
// wrong-password logins back-to-back; with per_sec=1 the refill
|
||||
// is too slow to keep up and at least one must come back 429.
|
||||
let mut saw_429 = false;
|
||||
for _ in 0..30 {
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": "victim", "password": "wrong" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
|
||||
// RFC 6585 §4: 429 SHOULD include a Retry-After header. The
|
||||
// value is in seconds; with per_sec=1 the bucket needs ~1s
|
||||
// to refill, so the header should be 1 or 2.
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get(axum::http::header::RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.expect("Retry-After header present and numeric");
|
||||
assert!(
|
||||
retry_after >= 1,
|
||||
"Retry-After must be at least 1s, got {retry_after}"
|
||||
);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "too_many_requests");
|
||||
saw_429 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_429,
|
||||
"expected at least one 429 within 30 rapid login attempts"
|
||||
);
|
||||
}
|
||||
|
||||
/// Default (test-harness) limits are disabled, so existing tests that
|
||||
/// fire multiple auth requests don't start failing.
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn default_test_harness_does_not_rate_limit(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
for i in 0..50 {
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": format!("nobody-{i}"), "password": "x" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// None of these should be 429 — only 401.
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "iter {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_unknown_token_is_404(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
|
||||
@@ -15,6 +15,7 @@ use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use mangalord::app::{router, AppState};
|
||||
use mangalord::auth::rate_limit::AuthRateLimiter;
|
||||
use mangalord::config::{AuthConfig, UploadConfig};
|
||||
use mangalord::storage::{LocalStorage, Storage, StorageError, StreamingFile};
|
||||
|
||||
@@ -49,20 +50,51 @@ fn harness_inner(
|
||||
storage: Arc<dyn Storage>,
|
||||
storage_dir: TempDir,
|
||||
) -> Harness {
|
||||
harness_with_auth_config(pool, storage, storage_dir, AuthConfig {
|
||||
cookie_secure: false,
|
||||
..AuthConfig::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn harness_with_auth_config(
|
||||
pool: PgPool,
|
||||
storage: Arc<dyn Storage>,
|
||||
storage_dir: TempDir,
|
||||
auth: AuthConfig,
|
||||
) -> Harness {
|
||||
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
|
||||
let state = AppState {
|
||||
db: pool,
|
||||
storage,
|
||||
auth: AuthConfig { cookie_secure: false, ..AuthConfig::default() },
|
||||
auth,
|
||||
upload: UploadConfig {
|
||||
// Keep file caps small in tests so the size-cap path is cheap to
|
||||
// exercise without producing tens of MBs of bytes.
|
||||
max_request_bytes: 4 * 1024 * 1024,
|
||||
max_file_bytes: 256 * 1024,
|
||||
},
|
||||
auth_limiter,
|
||||
};
|
||||
Harness { app: router(state), _storage_dir: storage_dir }
|
||||
}
|
||||
|
||||
/// Like [`harness`] but configures a tight auth rate limit. Used by
|
||||
/// the brute-force-rate-limiting test.
|
||||
pub fn harness_with_auth_rate_limit(
|
||||
pool: PgPool,
|
||||
per_sec: u32,
|
||||
burst: u32,
|
||||
) -> Harness {
|
||||
let storage_dir = tempfile::tempdir().expect("tempdir");
|
||||
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
|
||||
let auth = AuthConfig {
|
||||
cookie_secure: false,
|
||||
rate_limit: mangalord::auth::rate_limit::RateLimitConfig { per_sec, burst },
|
||||
..AuthConfig::default()
|
||||
};
|
||||
harness_with_auth_config(pool, storage, storage_dir, auth)
|
||||
}
|
||||
|
||||
/// Wraps a real `Storage` and fails on the N-th `put` call so tests can
|
||||
/// assert that handlers roll their DB writes back when storage errors
|
||||
/// mid-upload. Reads and other operations delegate to `inner`.
|
||||
|
||||
Reference in New Issue
Block a user