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:
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user