bf3e9ba6cc
Introduces CsrfForm<T>: a FromRequest extractor that looks up the session's csrf_token and constant-time-compares it to a csrf_token field in the form body. Drop-in replacement for Form<T> on every state-changing /web/ POST handler. Missing or expired session → redirect to /web/login; malformed body → 400; token mismatch → 403. WebSession carries csrf_token, minted alongside the session token at login and magic-link consume. Not rotated per request — rotation would break multi-tab use and buys little over SameSite=Strict cookies. Handlers: bulk, purge-closed, reset, close, reopen, analyze, delete-recording, logout all now require CsrfForm<_>. Login stays unprotected (SameSite=Strict alone is sufficient — forced-login CSRF has no impact on this codebase). /api/... is header-auth, exempt. Constant-time compare via subtle::ConstantTimeEq avoids timing oracles on the token. Template work is NOT in this commit — browser forms still post without csrf_token, so the live web UI will 403 until Schritt 6 (templates render the hidden field). Tests: all 12 red specs in csrf_attack_test.rs now green, #[ignore] removed. Integration tests that POSTed to protected endpoints switched to a new create_router_and_session_store entrypoint which returns a handle to the store so tests can read csrf_token for the active session.
56 lines
1.8 KiB
Rust
56 lines
1.8 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use axum_extra::extract::cookie::{Cookie, SameSite};
|
|
use rand::Rng;
|
|
use rand::distributions::Alphanumeric;
|
|
use tokio::sync::RwLock;
|
|
|
|
pub struct WebSession {
|
|
pub slug: String,
|
|
pub role: String,
|
|
pub expires_at: Instant,
|
|
/// CSRF token — bound to the session for its full lifetime. Minted
|
|
/// alongside the session cookie via [`generate_token`] and validated
|
|
/// by the `CsrfForm` extractor on every state-changing POST under
|
|
/// `/web/`. Not rotated per request (would break multi-tab use).
|
|
pub csrf_token: String,
|
|
}
|
|
|
|
pub type SessionStore = Arc<RwLock<HashMap<String, WebSession>>>;
|
|
|
|
pub fn new_store() -> SessionStore {
|
|
Arc::new(RwLock::new(HashMap::new()))
|
|
}
|
|
|
|
/// Cookie name shared by login and magic-link flows.
|
|
pub const SESSION_COOKIE: &str = "session";
|
|
|
|
/// 43 alphanumeric chars ≈ 256 bits of entropy — enough for a session
|
|
/// or short-lived magic-link token.
|
|
pub const TOKEN_LEN: usize = 43;
|
|
|
|
/// CSPRNG-backed token. Used for both long-lived session cookies and
|
|
/// short-lived magic-link tokens; both need the same entropy.
|
|
pub fn generate_token() -> String {
|
|
rand::thread_rng()
|
|
.sample_iter(&Alphanumeric)
|
|
.take(TOKEN_LEN)
|
|
.map(char::from)
|
|
.collect()
|
|
}
|
|
|
|
/// Builds a `HttpOnly + SameSite=Strict + Path=/` cookie for the session
|
|
/// token. `secure` is wired from `Config::cookie_secure` so local HTTP dev
|
|
/// can opt out — browsers refuse `Secure` cookies on non-HTTPS origins.
|
|
pub fn build_session_cookie(value: String, max_age: Duration, secure: bool) -> Cookie<'static> {
|
|
Cookie::build((SESSION_COOKIE, value))
|
|
.http_only(true)
|
|
.secure(secure)
|
|
.same_site(SameSite::Strict)
|
|
.path("/")
|
|
.max_age(time::Duration::seconds(max_age.as_secs() as i64))
|
|
.build()
|
|
}
|