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:
parent
dcd86524d7
commit
b55b7c34f8
9 changed files with 262 additions and 23 deletions
|
|
@ -92,7 +92,7 @@ pub fn build_app(state: AppState) -> Router {
|
|||
|
||||
public_routes
|
||||
.merge(protected_routes)
|
||||
.with_state(state)
|
||||
.with_state(state.clone())
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
// Resolve the client IP once and stash it for handlers/middleware
|
||||
|
|
@ -104,8 +104,9 @@ pub fn build_app(state: AppState) -> Router {
|
|||
.layer(axum::middleware::from_fn(
|
||||
middleware::security_headers_middleware,
|
||||
))
|
||||
// General rate limiting (currently a stub)
|
||||
.layer(axum::middleware::from_fn(
|
||||
// IP-based rate limiting (reads ClientIp from extensions).
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::general_rate_limit_middleware,
|
||||
))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ pub struct AppState {
|
|||
/// P0: persisted (hashed) refresh tokens, used for rotation, revocation,
|
||||
/// and surviving process restarts.
|
||||
pub refresh_token_repo: Option<Arc<crate::models::refresh_token::RefreshTokenRepository>>,
|
||||
|
||||
/// IP-based rate limiter (in-memory fixed-window).
|
||||
pub rate_limiter: Arc<crate::security::RateLimiter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
|
|
@ -61,6 +64,13 @@ pub struct Config {
|
|||
pub encryption: EncryptionConfig,
|
||||
pub cors: CorsConfig,
|
||||
pub environment: Environment,
|
||||
pub rate_limit: RateLimitConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RateLimitConfig {
|
||||
pub max_requests: u32,
|
||||
pub window_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
|
|
@ -174,6 +184,14 @@ impl Config {
|
|||
.collect(),
|
||||
},
|
||||
environment,
|
||||
rate_limit: RateLimitConfig {
|
||||
max_requests: std::env::var("RATE_LIMIT_MAX")
|
||||
.unwrap_or_else(|_| "100".to_string())
|
||||
.parse()?,
|
||||
window_secs: std::env::var("RATE_LIMIT_WINDOW_SECS")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()?,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,12 @@ async fn main() -> anyhow::Result<()> {
|
|||
let interaction_service = Arc::new(services::InteractionService::new());
|
||||
eprintln!("Interaction service initialized (Phase 2.8)");
|
||||
|
||||
// IP-based rate limiter (in-memory fixed-window).
|
||||
let rate_limiter = Arc::new(security::RateLimiter::new(
|
||||
config.rate_limit.max_requests,
|
||||
std::time::Duration::from_secs(config.rate_limit.window_secs),
|
||||
));
|
||||
|
||||
// Create application state
|
||||
let state = config::AppState {
|
||||
db,
|
||||
|
|
@ -124,6 +130,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
interaction_service: Some(interaction_service),
|
||||
token_version_cache,
|
||||
refresh_token_repo: Some(refresh_token_repo),
|
||||
rate_limiter,
|
||||
};
|
||||
|
||||
eprintln!("Building router with security middleware...");
|
||||
|
|
|
|||
|
|
@ -1,20 +1,42 @@
|
|||
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
|
||||
/// Middleware for general rate limiting
|
||||
/// NOTE: Currently a stub implementation. TODO: Implement IP-based rate limiting
|
||||
use crate::{config::AppState, middleware::ClientIp};
|
||||
|
||||
/// IP-based rate limiting. Reads the resolved client IP from request extensions
|
||||
/// (populated by `client_ip_middleware`). Returns 429 Too Many Requests if the
|
||||
/// IP has exceeded the configured limit within the window.
|
||||
pub async fn general_rate_limit_middleware(
|
||||
State(state): State<AppState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// TODO: Implement proper rate limiting with IP-based tracking
|
||||
// For now, just pass through
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
) -> Result<Response, Response> {
|
||||
let ip = req
|
||||
.extensions()
|
||||
.get::<ClientIp>()
|
||||
.map(|c| c.as_str().to_string())
|
||||
.unwrap_or_else(|| "0.0.0.0".to_string());
|
||||
|
||||
if !state.rate_limiter.check(&ip) {
|
||||
let retry_after = state.rate_limiter.window_secs();
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[
|
||||
("Retry-After", retry_after.to_string()),
|
||||
("Content-Type", "application/json".to_string()),
|
||||
],
|
||||
Json(serde_json::json!({
|
||||
"error": "rate limit exceeded",
|
||||
"retry_after": retry_after,
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
/// Middleware for auth endpoint rate limiting
|
||||
/// NOTE: Currently a stub implementation. TODO: Implement IP-based rate limiting
|
||||
pub async fn auth_rate_limit_middleware(req: Request, next: Next) -> Result<Response, StatusCode> {
|
||||
// TODO: Implement proper rate limiting with IP-based tracking
|
||||
// For now, just pass through
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
86
backend/src/security/rate_limiter.rs
Normal file
86
backend/src/security/rate_limiter.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue