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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -%}
|
||||
Reference in New Issue
Block a user