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.
297 lines
9.7 KiB
Rust
297 lines
9.7 KiB
Rust
//! Listen-relevante Mutationen (Close, Reopen, Bulk-Close) müssen den
|
|
//! pro-User Watermark bumpen. Sonst bleibt der ETag von
|
|
//! `/api/oneliners` stehen, jeder Poll liefert 304, und der Client
|
|
//! läuft dauerhaft mit einem Marker für einen Fall, den der Server
|
|
//! bereits geschlossen hat (ursprünglicher Bugreport: "Polymyalgia
|
|
//! rheumatica" blieb im Client sichtbar, obwohl im Web-UI bereits
|
|
//! gelöscht).
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use doctate_common::constants::API_KEY_HEADER;
|
|
use doctate_common::oneliners::ONELINERS_PATH;
|
|
use doctate_server::config::{Config, User};
|
|
use tower::util::ServiceExt;
|
|
|
|
fn make_user(slug: &str, password_plain: &str) -> User {
|
|
User {
|
|
slug: slug.into(),
|
|
api_key: format!("key-{slug}"),
|
|
web_password: bcrypt::hash(password_plain, 4).unwrap(),
|
|
role: "doctor".into(),
|
|
whisper: Default::default(),
|
|
retention: Default::default(),
|
|
window_hours: 72,
|
|
preview_lines: 2,
|
|
}
|
|
}
|
|
|
|
fn make_admin(slug: &str, password_plain: &str) -> User {
|
|
let mut u = make_user(slug, password_plain);
|
|
u.role = "admin".into();
|
|
u
|
|
}
|
|
|
|
fn test_config_with_users(users: Vec<User>) -> Arc<Config> {
|
|
let data_path = std::env::temp_dir().join(format!(
|
|
"doctate-close-wm-test-{}-{}",
|
|
std::process::id(),
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let api_keys: HashMap<String, String> = users
|
|
.iter()
|
|
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
|
.collect();
|
|
Arc::new(Config {
|
|
data_path,
|
|
users,
|
|
api_keys,
|
|
..Config::test_default()
|
|
})
|
|
}
|
|
|
|
fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> {
|
|
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
|
|
let s = v.to_str().ok()?;
|
|
if let Some(pair) = s.split(';').next()
|
|
&& pair.starts_with("session=")
|
|
{
|
|
return Some(pair.to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn login_request(slug: &str, password: &str) -> Request<Body> {
|
|
let body = format!("slug={slug}&password={password}");
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/login")
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
}
|
|
|
|
fn oneliners_request(api_key: &str, if_none_match: Option<&str>) -> Request<Body> {
|
|
let mut b = Request::builder()
|
|
.method("GET")
|
|
.uri(ONELINERS_PATH)
|
|
.header(API_KEY_HEADER, api_key);
|
|
if let Some(tag) = if_none_match {
|
|
b = b.header(header::IF_NONE_MATCH, tag);
|
|
}
|
|
b.body(Body::empty()).unwrap()
|
|
}
|
|
|
|
/// Create `<data_path>/<slug>/<case_id>/<ts>.m4a` so the case is
|
|
/// visible to `enumerate_recent_oneliners` (window defaults to 16h;
|
|
/// the fake mtime is "now" because write happens right now).
|
|
fn seed_case(data_path: &std::path::Path, slug: &str, case_id: &str) {
|
|
let case_dir = data_path.join(slug).join(case_id);
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a"), b"fake audio").unwrap();
|
|
}
|
|
|
|
async fn capture_etag(
|
|
app: &axum::Router,
|
|
api_key: &str,
|
|
if_none_match: Option<&str>,
|
|
) -> (StatusCode, Option<String>) {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(oneliners_request(api_key, if_none_match))
|
|
.await
|
|
.unwrap();
|
|
let status = resp.status();
|
|
let etag = resp
|
|
.headers()
|
|
.get(header::ETAG)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(str::to_owned);
|
|
(status, etag)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn close_bumps_watermark() {
|
|
let config = test_config_with_users(vec![make_user("dr_a", "s")]);
|
|
let data_path = config.data_path.clone();
|
|
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
|
seed_case(&data_path, "dr_a", case_id);
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store(config);
|
|
|
|
let login = app
|
|
.clone()
|
|
.oneshot(login_request("dr_a", "s"))
|
|
.await
|
|
.unwrap();
|
|
let cookie = extract_session_cookie(&login).expect("login should set cookie");
|
|
let csrf = store
|
|
.read()
|
|
.await
|
|
.get(cookie.trim_start_matches("session="))
|
|
.unwrap()
|
|
.csrf_token
|
|
.clone();
|
|
|
|
// First GET: capture baseline ETag (watermark empty → seeded on 0).
|
|
let (s1, etag_before) = capture_etag(&app, "key-dr_a", None).await;
|
|
assert_eq!(s1, StatusCode::OK);
|
|
let etag_before = etag_before.expect("server must set ETag");
|
|
|
|
// Close via the web handler.
|
|
let del = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/close"))
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(format!("csrf_token={csrf}")))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
del.status().as_u16() / 100,
|
|
3,
|
|
"close should redirect (3xx)"
|
|
);
|
|
|
|
// Same If-None-Match now must NOT match — watermark was bumped.
|
|
let (s2, etag_after) = capture_etag(&app, "key-dr_a", Some(&etag_before)).await;
|
|
assert_eq!(
|
|
s2,
|
|
StatusCode::OK,
|
|
"watermark bump must invalidate cached ETag",
|
|
);
|
|
let etag_after = etag_after.expect("200 response must set ETag");
|
|
assert_ne!(
|
|
etag_after, etag_before,
|
|
"close handler must change the per-user ETag"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reopen_bumps_watermark() {
|
|
let config = test_config_with_users(vec![make_user("dr_a", "s")]);
|
|
let data_path = config.data_path.clone();
|
|
let case_id = "660e8400-e29b-41d4-a716-446655440000";
|
|
seed_case(&data_path, "dr_a", case_id);
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store(config);
|
|
|
|
let login = app
|
|
.clone()
|
|
.oneshot(login_request("dr_a", "s"))
|
|
.await
|
|
.unwrap();
|
|
let cookie = extract_session_cookie(&login).expect("login should set cookie");
|
|
let csrf = store
|
|
.read()
|
|
.await
|
|
.get(cookie.trim_start_matches("session="))
|
|
.unwrap()
|
|
.csrf_token
|
|
.clone();
|
|
|
|
// Close first so reopen has something to restore; grab the post-close ETag.
|
|
app.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/close"))
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(format!("csrf_token={csrf}")))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let (_, etag_after_close) = capture_etag(&app, "key-dr_a", None).await;
|
|
let etag_after_close = etag_after_close.expect("ETag must be set");
|
|
|
|
// Reopen.
|
|
let reopen = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(format!("/web/cases/{case_id}/reopen"))
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(format!("csrf_token={csrf}")))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(reopen.status().as_u16() / 100, 3, "reopen should redirect");
|
|
|
|
// Reopen must bump again → post-close ETag becomes stale.
|
|
let (s, etag_after_reopen) = capture_etag(&app, "key-dr_a", Some(&etag_after_close)).await;
|
|
assert_eq!(
|
|
s,
|
|
StatusCode::OK,
|
|
"reopen must change the ETag so clients re-fetch"
|
|
);
|
|
let etag_after_reopen = etag_after_reopen.expect("200 response must set ETag");
|
|
assert_ne!(etag_after_reopen, etag_after_close);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bulk_close_bumps_watermark() {
|
|
// Bulk actions are admin-only: UI hides them from non-admins and
|
|
// the handler rejects non-admin POSTs with 403.
|
|
let config = test_config_with_users(vec![make_admin("dr_a", "s")]);
|
|
let data_path = config.data_path.clone();
|
|
let case_a = "770e8400-e29b-41d4-a716-446655440000";
|
|
let case_b = "880e8400-e29b-41d4-a716-446655440000";
|
|
seed_case(&data_path, "dr_a", case_a);
|
|
seed_case(&data_path, "dr_a", case_b);
|
|
|
|
let (app, store) = doctate_server::create_router_and_session_store(config);
|
|
|
|
let login = app
|
|
.clone()
|
|
.oneshot(login_request("dr_a", "s"))
|
|
.await
|
|
.unwrap();
|
|
let cookie = extract_session_cookie(&login).expect("login should set cookie");
|
|
let csrf = store
|
|
.read()
|
|
.await
|
|
.get(cookie.trim_start_matches("session="))
|
|
.unwrap()
|
|
.csrf_token
|
|
.clone();
|
|
|
|
let (_, etag_before) = capture_etag(&app, "key-dr_a", None).await;
|
|
let etag_before = etag_before.expect("ETag must be set");
|
|
|
|
// Form-encoded bulk close: action=close&case_id=a&case_id=b.
|
|
let form = format!("action=close&case_id={case_a}&case_id={case_b}&csrf_token={csrf}");
|
|
let bulk = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/web/cases/bulk")
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
|
.body(Body::from(form))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bulk.status().as_u16() / 100, 3, "bulk should redirect");
|
|
|
|
let (s, etag_after) = capture_etag(&app, "key-dr_a", Some(&etag_before)).await;
|
|
assert_eq!(s, StatusCode::OK, "bulk-close must bump the ETag");
|
|
let etag_after = etag_after.expect("200 response must set ETag");
|
|
assert_ne!(etag_after, etag_before);
|
|
}
|