feat: Add CSRF and security headers tests

Adds comprehensive tests for CSRF protection and security headers to
ensure robust defense against common web attacks.
This commit is contained in:
2026-04-22 09:19:25 +02:00
parent ba6aea06e5
commit 34aa633d32
2 changed files with 698 additions and 0 deletions
+371
View File
@@ -0,0 +1,371 @@
//! Attack-confirming tests for CSRF protection on /web/ state-changing
//! endpoints.
//!
//! Each test crafts a request that simulates a real attack shape
//! (cross-site POST with valid cookie, wrong token, empty token, etc.)
//! and asserts the expected defense. Specs marked `#[ignore]` are red
//! today and turn green once the `CsrfForm<T>` extractor + session CSRF
//! token land. Remove the `#[ignore]` on each as it passes.
//!
//! Session-hygiene tests without `#[ignore]` (session fixation rotation)
//! document properties the server already has — they are regression
//! anchors.
use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
// ---------- fixtures ----------
fn make_user(slug: &str, password: &str, role: &str) -> User {
User {
slug: slug.into(),
api_key: format!("key-{slug}"),
web_password: bcrypt::hash(password, 4).unwrap(),
role: role.into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
preview_lines: 2,
}
}
fn test_config_with(users: Vec<User>) -> Arc<Config> {
let data_path = std::env::temp_dir().join(format!(
"doctate-csrf-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()
}
async fn login_as(app: &axum::Router, slug: &str, password: &str) -> String {
let resp = app
.clone()
.oneshot(login_request(slug, password))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16());
extract_session_cookie(&resp).expect("no session cookie after login")
}
fn form_post(path: &str, cookie: &str, body: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(path)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, cookie)
.body(Body::from(body.to_owned()))
.unwrap()
}
// ---------- session hygiene (green today) ----------
/// Session-fixation: attacker sets a known `session=...` cookie on the
/// victim's browser. If the server reused that value after login, the
/// attacker would be logged in as the victim. Defense: every successful
/// login mints a fresh token; the pre-set value must be overwritten.
#[tokio::test]
async fn session_fixation_login_rotates_cookie() {
let cfg = test_config_with(vec![make_user("dr_a", "secret", "doctor")]);
let app = doctate_server::create_router(cfg);
let attacker_fixed_value = "session=attacker-fixed-token-value-aaaaaaaaaaaaaaaa";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/web/login")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, attacker_fixed_value)
.body(Body::from("slug=dr_a&password=secret"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16());
let minted = extract_session_cookie(&resp).expect("no Set-Cookie on login");
assert!(
minted != attacker_fixed_value,
"login did not rotate the cookie — session fixation possible"
);
assert!(
minted.starts_with("session="),
"Set-Cookie shape unexpected: {minted}"
);
// Token portion has the full 43-char entropy, not the attacker's value.
let minted_token = minted.trim_start_matches("session=");
assert!(
minted_token.len() >= 40,
"minted token looks truncated: {minted_token:?}"
);
}
// ---------- CSRF defense — missing token ----------
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn bulk_without_csrf_token_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000";
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, body))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"bulk POST without csrf_token should be 403 — got {}",
resp.status()
);
}
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn purge_closed_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let resp = app
.oneshot(form_post("/web/cases/purge-closed", &cookie, "confirm=yes"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn reset_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/reset",
&cookie,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn close_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/close",
&cookie,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn reopen_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/reopen",
&cookie,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn analyze_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/analyze",
&cookie,
"",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn delete_recording_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post(
"/web/cases/11111111-1111-1111-1111-111111111111/recordings/delete",
&cookie,
"filename=2026-04-14T10-00-00Z.m4a",
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
/// Forced-logout CSRF: attacker page auto-POSTs /web/logout to sign the
/// victim out of their active session (annoyance / phishing setup where
/// victim re-enters password on a lookalike page).
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn logout_without_csrf_forbidden() {
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_a", "s").await;
let resp = app
.oneshot(form_post("/web/logout", &cookie, ""))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"forced-logout CSRF not blocked"
);
}
// ---------- CSRF defense — malformed token values ----------
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn bulk_with_wrong_csrf_token_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
// A 43-char alphanumeric that is structurally valid but does not
// match the session's token.
let wrong = "a".repeat(43);
let body =
format!("action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token={wrong}");
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, &body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn bulk_with_empty_csrf_token_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=";
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, body))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"empty csrf_token must not bypass the check"
);
}
/// Whitespace trick: some frameworks trim tokens. If the server trimmed,
/// an attacker could send `csrf_token=%0A%0A` and hit an early-return
/// `if token.is_empty() { skip }` branch. Defense: compare raw.
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn bulk_with_whitespace_padded_token_forbidden() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
// Leading+trailing spaces around a value that *would* match post-trim.
// Even if the real token were "VALID", this should still fail.
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%20VALID%20";
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
/// Paranoia: token-field value is a SQL-injection-looking string. The
/// server uses a HashMap, so SQL is a non-issue — this test just pins
/// down that the check returns a clean 403 rather than 500/panic on
/// unusual byte content.
#[tokio::test]
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
async fn bulk_with_injection_payload_returns_403_not_500() {
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
let app = doctate_server::create_router(cfg);
let cookie = login_as(&app, "dr_admin", "s").await;
// `' OR 1=1--` URL-encoded.
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000\
&csrf_token=%27+OR+1%3D1--";
let resp = app
.oneshot(form_post("/web/cases/bulk", &cookie, body))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"malformed token must yield 403, not 500"
);
}