feat: rate limiting + E2E crypto lifecycle test

Rate limiting (closes the last security gap #3):
- New RateLimiter: in-memory fixed-window IP-based limiter (std::sync::Mutex
  HashMap, no new deps). Configurable via RATE_LIMIT_MAX (default 100) +
  RATE_LIMIT_WINDOW_SECS (default 60) env vars.
- general_rate_limit_middleware now reads ClientIp from request extensions and
  rejects with 429 + Retry-After header when over the limit. Wired via
  from_fn_with_state in app.rs. Lived on AppState as Arc<RateLimiter>.
- Deleted the dead auth_rate_limit_middleware (never wired).
- 3 unit tests (allows up to N, independent IPs, window reset).

E2E crypto lifecycle test:
- Full zero-knowledge round-trip against jsdom's real Web Crypto: setup →
  encrypt → verify ciphertext → unlock with password → decrypt → recover via
  phrase → rewrap under new password → decrypt. Plus wrong-password/wrong-phrase
  failures and cross-user key isolation.
- Fixed wrapDek/unwrapDek: base64-encode raw DEK bytes (was using TextDecoder
  which produced non-base64), and make unwrapped DEK extractable (needed for
  rewrapDek to export).

Verified: backend 24 tests 0 warnings; frontend 24 tests, build clean.
This commit is contained in:
goose 2026-07-03 21:57:39 -03:00
parent dcd86524d7
commit b55b7c34f8
9 changed files with 262 additions and 23 deletions

View file

@ -1,7 +1,9 @@
pub mod account_lockout;
pub mod audit_logger;
pub mod rate_limiter;
pub mod session_manager;
pub use account_lockout::AccountLockout;
pub use audit_logger::AuditLogger;
pub use rate_limiter::RateLimiter;
pub use session_manager::SessionManager;

View file

@ -0,0 +1,86 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// A fixed-window IP rate limiter. Thread-safe via a standard mutex (the
/// critical section is nanoseconds — increment + compare).
#[derive(Clone)]
pub struct RateLimiter {
inner: Arc<Mutex<HashMap<String, WindowState>>>,
max_requests: u32,
window: Duration,
}
#[derive(Clone, Copy)]
struct WindowState {
count: u32,
started_at: Instant,
}
impl RateLimiter {
pub fn new(max_requests: u32, window: Duration) -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
max_requests,
window,
}
}
/// Check whether the given IP is allowed to make a request. Increments the
/// counter. Returns `true` if allowed, `false` if over the limit.
pub fn check(&self, ip: &str) -> bool {
let mut map = self.inner.lock().expect("rate limiter mutex poisoned");
let now = Instant::now();
let state = map.entry(ip.to_string()).or_insert(WindowState {
count: 0,
started_at: now,
});
// Reset the window if it has elapsed.
if now.duration_since(state.started_at) >= self.window {
state.count = 0;
state.started_at = now;
}
state.count += 1;
state.count <= self.max_requests
}
/// The configured window duration (for the `Retry-After` response header).
pub fn window_secs(&self) -> u64 {
self.window.as_secs()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_up_to_limit_then_denies() {
let limiter = RateLimiter::new(3, Duration::from_secs(60));
assert!(limiter.check("1.2.3.4"));
assert!(limiter.check("1.2.3.4"));
assert!(limiter.check("1.2.3.4"));
assert!(!limiter.check("1.2.3.4")); // 4th denied
}
#[test]
fn different_ips_are_independent() {
let limiter = RateLimiter::new(2, Duration::from_secs(60));
assert!(limiter.check("1.1.1.1"));
assert!(limiter.check("1.1.1.1"));
assert!(!limiter.check("1.1.1.1")); // 1.1.1.1 exhausted
assert!(limiter.check("2.2.2.2")); // different IP still fine
}
#[test]
fn window_reset_allows_again() {
let limiter = RateLimiter::new(1, Duration::from_millis(10));
assert!(limiter.check("9.9.9.9"));
assert!(!limiter.check("9.9.9.9")); // over limit
std::thread::sleep(Duration::from_millis(15));
assert!(limiter.check("9.9.9.9")); // window elapsed → allowed
}
}