From 2e3b5efe8697c4d6394d9c1cf658532481723c75 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 22 Apr 2026 10:04:21 +0200 Subject: [PATCH] feat: render CSRF token into state-changing forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the csrf_token hidden field to every POST form in the web UI (logout, bulk, purge-closed, close, reopen, analyze, reset, delete-recording). Login stays without a token — SameSite=Strict already blocks the relevant attack shapes and forced-login CSRF has no impact here. Shape: - New askama macro partials/csrf_field.html renders the hidden input; 3 templates import + {% call csrf::field(csrf_token) %} inside each
. - AuthenticatedWebUser carries csrf_token (cloned out of the session once during extraction) so render handlers don't need a second store lookup. - 3 template structs (MyCasesTemplate, CasePageTemplate, CaseRecordingsTemplate) gain the field; render sites pass user.csrf_token through. Safety-net tests: - Each rendered page must contain the exact session csrf_token in a hidden input — catches anyone adding a new form without the macro. - Happy-path round-trip: fetch page, parse token from HTML, POST with token → 303. Catches drift between rendered and accepted token formats. --- server/src/auth.rs | 6 + server/src/routes/user_web.rs | 12 ++ server/templates/case_page.html | 7 + server/templates/case_recordings.html | 4 +- server/templates/my_cases.html | 5 +- server/templates/partials/csrf_field.html | 12 ++ server/tests/csrf_attack_test.rs | 195 ++++++++++++++++++++++ 7 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 server/templates/partials/csrf_field.html diff --git a/server/src/auth.rs b/server/src/auth.rs index fe471be..1f6704e 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -78,6 +78,11 @@ pub struct AuthenticatedWebUser { /// Mirrors [`AuthenticatedUser::window_hours`] so the same policy /// is available to web handlers without a second config lookup. pub window_hours: u32, + /// Session's CSRF token. Rendered into every state-changing form + /// as a hidden field so the `CsrfForm` extractor can validate it + /// on the matching POST. Cloned out of the session record here so + /// the template handlers don't need a second store lookup. + pub csrf_token: String, } impl AuthenticatedWebUser { @@ -146,6 +151,7 @@ where role: session.role.clone(), data_dir, window_hours, + csrf_token: session.csrf_token.clone(), }) } } diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 4c58c41..a17bedd 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -280,6 +280,9 @@ struct MyCasesTemplate { /// CSS `--preview-lines` custom property on ``; the browser /// applies it via `line-clamp`. Coming from `users.toml`. preview_lines: u32, + /// Session CSRF token — rendered as a hidden field into the bulk, + /// purge-closed, and logout forms. + csrf_token: String, } /// Query params for GET /web/cases and /web/cases/{case_id}. @@ -330,6 +333,9 @@ struct CasePageTemplate { /// a "?show_closed=1" to the form action so a freshly reopened /// case still lands on the detail page. is_closed: bool, + /// Session CSRF token — rendered as a hidden field into every + /// state-changing form on the page (close/reopen/analyze/reset/logout). + csrf_token: String, } #[derive(Template)] @@ -342,6 +348,9 @@ struct CaseRecordingsTemplate { recordings: Vec, transcribe_busy: bool, is_admin: bool, + /// Session CSRF token — rendered as a hidden field into the + /// per-recording delete forms and the logout form. + csrf_token: String, } /// Flags derived from filesystem state + config. @@ -535,6 +544,7 @@ pub async fn handle_my_cases( .map(|u| u.preview_lines) .unwrap_or(2); MyCasesTemplate { + csrf_token: user.csrf_token, slug: user.slug, groups, total, @@ -648,6 +658,7 @@ pub async fn handle_case_page( has_failed_recording, is_admin, is_closed, + csrf_token: user.csrf_token, } .render() .map(Html) @@ -697,6 +708,7 @@ pub async fn handle_case_recordings( let is_admin = user.is_admin(); CaseRecordingsTemplate { + csrf_token: user.csrf_token, slug: user.slug, case_id: case_id_str, case_id_short, diff --git a/server/templates/case_page.html b/server/templates/case_page.html index 692ec4e..5faefec 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -1,4 +1,5 @@ {%- import "partials/oneliner.html" as ol -%} +{%- import "partials/csrf_field.html" as csrf -%} @@ -252,6 +253,7 @@ view{% endif %} + {% call csrf::field(csrf_token) %} @@ -270,6 +272,7 @@ method="post" action="/web/cases/{{ case_id }}/reopen" > + {% call csrf::field(csrf_token) %} {% else if llm_missing && !has_document %} @@ -350,6 +355,7 @@ method="post" action="/web/cases/{{ case_id }}/reset" > + {% call csrf::field(csrf_token) %} {% endif %} @@ -360,6 +366,7 @@ {% if can_analyze %}
+ {% call csrf::field(csrf_token) %}
{% if is_admin %}{% endif %} - +
{% call csrf::field(csrf_token) %}

Aufnahmen

@@ -108,6 +109,7 @@ try { 0:00 / 0:00
+{% call csrf::field(csrf_token) %}
diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index b1d2f9f..3fd2892 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -1,4 +1,5 @@ {%- import "partials/oneliner.html" as ol -%} +{%- import "partials/csrf_field.html" as csrf -%} @@ -86,7 +87,7 @@ try {

{{ slug }} — Meine Fälle

{% if is_admin %}{% endif %} -
+
{% call csrf::field(csrf_token) %}
@@ -100,6 +101,7 @@ try {

{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}

{% else %}
+{% call csrf::field(csrf_token) %} {% for g in groups %}

{{ g.label }}{{ g.open_count }}/{{ g.total_count }} Fälle

@@ -139,6 +141,7 @@ try { {% if is_admin && show_closed && any_closed %}
+{% call csrf::field(csrf_token) %} Geschlossene Fälle: diff --git a/server/templates/partials/csrf_field.html b/server/templates/partials/csrf_field.html new file mode 100644 index 0000000..5f71ff6 --- /dev/null +++ b/server/templates/partials/csrf_field.html @@ -0,0 +1,12 @@ +{#- + CSRF hidden input for state-changing POST forms. Usage: + + {% import "partials/csrf_field.html" as csrf %} + + {% call csrf::field(csrf_token) %} + ... +
+-#} +{%- macro field(token) -%} + +{%- endmacro -%} diff --git a/server/tests/csrf_attack_test.rs b/server/tests/csrf_attack_test.rs index db281f8..b5a0f39 100644 --- a/server/tests/csrf_attack_test.rs +++ b/server/tests/csrf_attack_test.rs @@ -353,3 +353,198 @@ async fn bulk_with_injection_payload_returns_403_not_500() { "malformed token must yield 403, not 500" ); } + +// ---------- template rendering: the token must reach the browser ---------- +// +// The server-side CSRF check is useless if the templates forget to emit +// the hidden field — the legitimate user would get 403 on every button. +// These tests GET each authenticated page, grab the session's real +// csrf_token from the store, and assert it appears verbatim in a +// hidden input in the rendered HTML. If anyone adds a new state- +// changing form without the {% call csrf::field(csrf_token) %} macro, +// the browser flow breaks and one of these fires. + +async fn login_and_get_csrf( + app: &axum::Router, + store: &doctate_server::web_session::SessionStore, + slug: &str, + password: &str, +) -> (String, String) { + let resp = app + .clone() + .oneshot(login_request(slug, password)) + .await + .unwrap(); + let cookie = extract_session_cookie(&resp).expect("login cookie"); + let csrf = store + .read() + .await + .get(cookie.trim_start_matches("session=")) + .unwrap() + .csrf_token + .clone(); + (cookie, csrf) +} + +async fn html_body(app: &axum::Router, uri: &str, cookie: &str) -> String { + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed"); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +fn hidden_field(token: &str) -> String { + format!(r#"name="csrf_token" value="{token}""#) +} + +#[tokio::test] +async fn my_cases_page_renders_csrf_token_hidden_field() { + let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]); + // Seed a case so the bulk form is rendered (the template skips it + // when the list is empty) — otherwise only the logout form shows. + let case_dir = cfg + .data_path + .join("dr_admin/11111111-1111-1111-1111-111111111111"); + std::fs::create_dir_all(&case_dir).unwrap(); + std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); + + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_admin", "s").await; + + let body = html_body(&app, "/web/cases?show_closed=1", &cookie).await; + let expected = hidden_field(&csrf); + + // Logout form must carry the token. + assert!( + body.contains(&expected), + "/web/cases should render hidden csrf_token ({expected}), \ + first 200 chars of body: {}", + &body[..body.len().min(200)] + ); + // Logout form must specifically have it (present in the body). + assert!( + body.contains(r#"action="/web/logout""#) + && body.matches(r#"name="csrf_token""#).count() >= 2, + "expected at least 2 csrf_token hidden fields (logout + bulk); \ + got {} occurrences", + body.matches(r#"name="csrf_token""#).count() + ); +} + +#[tokio::test] +async fn case_page_renders_csrf_token_hidden_field() { + let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); + let case_id = "11111111-1111-1111-1111-111111111111"; + // Seed a minimal case so GET /web/cases/{id} returns 200 instead of 404. + let case_dir = cfg.data_path.join("dr_a").join(case_id); + std::fs::create_dir_all(&case_dir).unwrap(); + std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); + + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await; + + let body = html_body(&app, &format!("/web/cases/{case_id}"), &cookie).await; + let expected = hidden_field(&csrf); + + assert!( + body.contains(&expected), + "case page must render hidden csrf_token for close/analyze forms" + ); + // Close form plus logout form → at least 2 hidden fields. + assert!( + body.matches(r#"name="csrf_token""#).count() >= 2, + "case page should have ≥2 csrf_token inputs; got {}", + body.matches(r#"name="csrf_token""#).count() + ); +} + +#[tokio::test] +async fn case_recordings_page_renders_csrf_token_hidden_field() { + let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = cfg.data_path.join("dr_a").join(case_id); + std::fs::create_dir_all(&case_dir).unwrap(); + std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); + std::fs::write(case_dir.join("2026-04-14T10-00-00Z.transcript.txt"), b"hi").unwrap(); + + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await; + + let body = html_body(&app, &format!("/web/cases/{case_id}/recordings"), &cookie).await; + let expected = hidden_field(&csrf); + + assert!( + body.contains(&expected), + "recordings page must render hidden csrf_token for the delete form" + ); + // Logout form + one delete form per recording (one here) ≥ 2. + assert!( + body.matches(r#"name="csrf_token""#).count() >= 2, + "recordings page should have ≥2 csrf_token inputs; got {}", + body.matches(r#"name="csrf_token""#).count() + ); +} + +/// Full round-trip: fetch the page, extract the csrf_token from the +/// rendered HTML, POST with that token — expect 303 (success). This is +/// the legitimate-user path; if the server-side check or the template +/// ever drift out of sync, this test catches it. Protects against the +/// subtle regression where the token is rendered but with a value the +/// server won't accept (e.g. accidental trim, base64-encoded, etc.). +#[tokio::test] +async fn happy_path_post_with_rendered_token_accepted() { + let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = cfg.data_path.join("dr_a").join(case_id); + std::fs::create_dir_all(&case_dir).unwrap(); + std::fs::write(case_dir.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); + + let (app, store) = doctate_server::create_router_and_session_store(cfg); + let (cookie, session_csrf) = login_and_get_csrf(&app, &store, "dr_a", "s").await; + + // Render the case page, parse the csrf_token value out of any + // hidden input — do NOT use the session store directly; this tests + // the *rendered* token specifically. + let body = html_body(&app, &format!("/web/cases/{case_id}"), &cookie).await; + let needle = r#"name="csrf_token" value=""#; + let start = body.find(needle).expect("no csrf_token in HTML") + needle.len(); + let end = body[start..] + .find('"') + .expect("unterminated csrf_token value") + + start; + let rendered = &body[start..end]; + + // Rendered token must be the session's actual token. If these ever + // diverge we want the test to fail loudly. + assert_eq!( + rendered, session_csrf, + "rendered csrf_token must match session's csrf_token" + ); + + // POST close with the rendered token → must succeed. + let resp = app + .oneshot(form_post( + &format!("/web/cases/{case_id}/close"), + &cookie, + &format!("csrf_token={rendered}"), + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::SEE_OTHER, + "legitimate POST with rendered token must be accepted (303)" + ); +}