feat: render CSRF token into state-changing forms

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 <form method="POST">.
- 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.
This commit is contained in:
2026-04-22 10:04:21 +02:00
parent bf3e9ba6cc
commit 2e3b5efe86
7 changed files with 239 additions and 2 deletions
+6
View File
@@ -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(),
})
}
}
+12
View File
@@ -280,6 +280,9 @@ struct MyCasesTemplate {
/// CSS `--preview-lines` custom property on `<html>`; 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<RecordingView>,
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,
+7
View File
@@ -1,4 +1,5 @@
{%- import "partials/oneliner.html" as ol -%}
{%- import "partials/csrf_field.html" as csrf -%}
<!doctype html>
<html lang="de">
<head>
@@ -252,6 +253,7 @@
view</label
>{% endif %}
<form method="post" action="/web/logout">
{% call csrf::field(csrf_token) %}
<button type="submit">Logout</button>
</form>
</div>
@@ -270,6 +272,7 @@
method="post"
action="/web/cases/{{ case_id }}/reopen"
>
{% call csrf::field(csrf_token) %}
<button
type="submit"
class="delete-btn"
@@ -300,6 +303,7 @@
method="post"
action="/web/cases/{{ case_id }}/close"
>
{% call csrf::field(csrf_token) %}
<button
type="submit"
class="delete-btn"
@@ -340,6 +344,7 @@
<div class="actions">
{% if can_analyze && !has_document %}
<form method="post" action="/web/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<button type="submit">Analysieren</button>
</form>
{% else if llm_missing && !has_document %}
@@ -350,6 +355,7 @@
method="post"
action="/web/cases/{{ case_id }}/reset"
>
{% call csrf::field(csrf_token) %}
<button type="submit">Reset</button>
</form>
{% endif %}
@@ -360,6 +366,7 @@
{% if can_analyze %}
<div class="doc-actions-top">
<form method="post" action="/web/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<button
type="submit"
class="copy-btn"
+3 -1
View File
@@ -1,3 +1,4 @@
{%- import "partials/csrf_field.html" as csrf -%}
<!DOCTYPE html>
<html lang="de">
<head>
@@ -92,7 +93,7 @@ try {
</div>
<div class="header-right">
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
<form method="post" action="/web/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
</div>
</header>
<h1>Aufnahmen</h1>
@@ -108,6 +109,7 @@ try {
<span class="time">0:00 / 0:00</span>
</div>
<form method="post" action="/web/cases/{{ case_id }}/recordings/delete" class="rec-delete-form" data-ts="{{ rec.recorded_at_iso }}">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="filename" value="{{ rec.filename }}">
<button type="submit" class="delete-btn" title="Aufnahme entfernen" aria-label="Aufnahme entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button>
</form>
+4 -1
View File
@@ -1,4 +1,5 @@
{%- import "partials/oneliner.html" as ol -%}
{%- import "partials/csrf_field.html" as csrf -%}
<!DOCTYPE html>
<html lang="de" style="--preview-lines: {{ preview_lines }}">
<head>
@@ -86,7 +87,7 @@ try {
<h1>{{ slug }} — Meine Fälle</h1>
<div class="header-right">
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
<form method="post" action="/web/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
</div>
</header>
@@ -100,6 +101,7 @@ try {
<section><p class="empty">{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}</p></section>
{% else %}
<form method="post" action="/web/cases/bulk">
{% call csrf::field(csrf_token) %}
{% for g in groups %}
<section>
<h2 class="date-group" data-iso="{{ g.label }}"><span class="date-label">{{ g.label }}</span><span class="count">{{ g.open_count }}/{{ g.total_count }} Fälle</span></h2>
@@ -139,6 +141,7 @@ try {
</form>
{% if is_admin && show_closed && any_closed %}
<form class="bulk-bar purge admin-only" method="post" action="/web/cases/purge-closed" onsubmit="return confirm('Alle geschlossenen Fälle UNWIDERRUFLICH löschen? Diese Aktion kann nicht rückgängig gemacht werden.')">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="confirm" value="yes">
<span>Geschlossene Fälle:</span>
<button type="submit">Alle geschlossenen unwiderruflich löschen</button>
+12
View File
@@ -0,0 +1,12 @@
{#-
CSRF hidden input for state-changing POST forms. Usage:
{% import "partials/csrf_field.html" as csrf %}
<form method="post" action="...">
{% call csrf::field(csrf_token) %}
...
</form>
-#}
{%- macro field(token) -%}
<input type="hidden" name="csrf_token" value="{{ token }}">
{%- endmacro -%}
+195
View File
@@ -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)"
);
}