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
+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"