Skip to content

Add CSRF protection to state-changing views - #496

Merged
benadida merged 2 commits into
masterfrom
claude/report-review-i6eich
Aug 15, 2026
Merged

benadida merged 2 commits into
masterfrom
claude/report-review-i6eich

Conversation

@benadida

@benadida benadida commented Aug 15, 2026 •

Copy link
Copy Markdown
Owner

Helios disables Django's CsrfViewMiddleware and protects views individually with check_csrf(). That is opt-in, and a number of state-changing views never opted in.

check_csrf()

On a non-POST request it returned an HttpResponseNotAllowed, but all 19 callers invoke it as a bare statement, so the response was discarded and the view carried on as if the check had passed. Both branches now raise SuspiciousOperation (Django → 400), which also lets it serve as a "this must be a POST" filter. The token comparison reads the session with .get() so a missing token fails closed instead of raising KeyError.

Views with side effects on GET

These were reachable by following a link, so a logged-in admin could be made to trigger them. Now POST-only, with the templates submitting inline forms that carry the token:

voter_delete, delete_trustee, trustee_send_url, new_trustee_helios, one_election_copy, one_election_archive, one_election_set_featured, one_election_set_reg, voters_upload_cancel, force_queue

one_election_set_reg is worth a look — it changes openreg via ?open_p=, and nothing in the UI links to it.

Views already POST-only but unchecked

Now call check_csrf(): voters_email, voters_eligibility, voters_upload, trustee_upload_pk, password_voter_login, one_election_set_result_and_proof, ldap_login_view, password_login_view, password_forgotten_view

Also

  • SESSION_COOKIE_SAMESITE set explicitly to Lax (env-overridable). Already the Django default, but it's load-bearing here and shouldn't be inherited silently.
  • The markup around these actions moved from p to div in election_view.html and stats.html, and out of the h5 in list_trustees.html. A form is flow content: the parser silently closes an open p when it reaches one (which would move these actions out of the block they appear to belong to), and neither a p nor a heading may contain one. election_view.html already had the p bug for the existing delete form.
  • Fixes a latent KeyError in voters_upload_cancel, which unconditionally deleted a session key that may not be set.
  • New helios/test_csrf_smoke.py covers three things: the views reject a GET, a tokenless POST and a badly-signed POST; the admin templates render their actions as POST forms carrying the token; and no rendered form ends up nested in a p or an h1-h6 (parsed with html5lib, added as a dev dependency).

Existing tests that drove these endpoints were updated to send a token. Full suite passes on top of current master: 214 tests, 1 skip (the LDAP test, which needs a live LDAP server — skipped on master too).

Left alone deliberately

post_audited_ballot and trustee_upload_decryption are @election_view() with no authentication at all — anyone can POST to them unauthenticated. CSRF is meaningless without a session privilege to ride, and a token check would break the booth without adding security. That's a missing-authorization issue, worth its own change.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens Helios’ opt-in CSRF protection by making check_csrf() fail closed (via exceptions) and converting previously state-changing GET endpoints into CSRF-protected POST flows, including UI/template updates to submit inline POST forms with the session CSRF token.

Changes:

  • Make check_csrf() raise SuspiciousOperation for non-POST and invalid/missing CSRF tokens, and avoid KeyError on missing session token.
  • Convert multiple admin/state-changing actions from GET to POST and update templates to render them as POST forms carrying csrf_token.
  • Add a CSRF regression smoke test suite and update existing tests to include CSRF tokens on POSTs; explicitly set SESSION_COOKIE_SAMESITE (env-overridable).

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
settings.py Explicitly sets SESSION_COOKIE_SAMESITE to support the app’s CSRF model.
server_ui/media/main.css Adds styling to make inline POST-form buttons look like links.
helios/views.py Adds check_csrf() to many state-changing views and switches parameters from GET to POST where needed.
helios/tests.py Updates existing tests to send csrf_token on POSTs and switches force-queue test to POST.
helios/test_csrf_smoke.py Adds regression tests to ensure state-changing endpoints reject GET/tokenless/bad-token requests and templates render protected forms.
helios/templates/voters_upload_confirm.html Converts upload-cancel actions from links to CSRF-protected POST forms.
helios/templates/voters_manage.html Converts voter delete from GET link to CSRF-protected POST form.
helios/templates/voters_list.html Converts voter delete from GET link to CSRF-protected POST form.
helios/templates/stats.html Converts force-queue from GET link to CSRF-protected POST form; uses <div> to allow forms.
helios/templates/list_trustees.html Converts trustee actions from GET links to CSRF-protected POST forms.
helios/templates/election_view.html Converts admin actions (archive/copy/set-featured) to CSRF-protected POST forms; avoids <p> auto-close issues.
helios/templates/election_keygenerator.html Adds csrf_token to trustee public-key upload POST form.
helios/stats_views.py Adds check_csrf() to force_queue.
helios_auth/tests.py Primes session and adds csrf_token to LDAP login POST test.
helios_auth/security/init.py Updates check_csrf() to raise SuspiciousOperation and fail closed on missing session token.
helios_auth/auth_systems/password.py Adds check_csrf() to password login/forgotten POST handlers.
helios_auth/auth_systems/ldapauth.py Adds check_csrf() to LDAP login POST handler.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread helios/templates/voters_upload_confirm.html Outdated
Comment thread helios/templates/list_trustees.html Outdated
Comment on lines 42 to 46
<h5> Trustee #{{forloop.counter}}: {{t.name}}
{% if admin_p %}
{% if t.secret_key %}
{% if not election.frozen_at %}[<a onclick="return confirm('Are you sure you want to remove Helios as a trustee?');" href="{% url "election@trustees@delete" election.uuid %}?uuid={{t.uuid}}">x</a>]{% endif %}
{% if not election.frozen_at %}[<form class="inline-action" method="post" action="{% url "election@trustees@delete" election.uuid %}" onsubmit="return confirm('Are you sure you want to remove Helios as a trustee?');"><input type="hidden" name="csrf_token" value="{{csrf_token}}" /><input type="hidden" name="uuid" value="{{t.uuid}}" /><button type="submit" class="link-button">x</button></form>]{% endif %}
{% else %}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7f94a82, though the stated mechanism isn't what happens.

A <form> start tag does not implicitly close an open heading. Per the HTML5 tree construction algorithm it closes an open <p> (in button scope) and nothing else; h1-h6 are only popped by another heading start tag. Checked against html5lib:

<h5>Name <form><button>x</button></form></h5>
  -> <h5> 'Name'
       <form>
         <button> 'x'          # stays nested, h5 not closed

<p>text <form><button>x</button></form></p>
  -> <p> 'text'
     <form>                    # p closed, form is a sibling
       <button> 'x'
     <p>                       # stray empty p

So there was no broken layout here. The real issue is the content model: a <form> is flow content and a heading only accepts phrasing content, which makes it invalid even though it parses and renders fine. That's worth fixing regardless, and it was introduced by this PR — before it, the heading held only <a> elements.

I didn't move the forms below the heading, since that would push [x] and send login onto their own line. Instead the heading and its actions are wrapped in a div.trustee-heading with the <h5> inlined, so the markup is valid and the layout is unchanged. Verified in Chromium: the <h5> and its forms share a baseline (y=363 vs 365), and the rendered line still reads Trustee #1: Alice Trustee (alice@example.com) [x] [send login].

The <p> half of your point was real, and it's why the same commit's election_view.html/stats.html changes moved those blocks to <div>. test_action_forms_are_not_nested_in_phrasing_only_elements now parses the rendered admin pages and asserts no <form> sits inside a <p> or an <h1>-<h6>.


Generated by Claude Code

claude added 2 commits August 15, 2026 19:46
Helios disables Django's CsrfViewMiddleware and instead protects views
individually with check_csrf(). That is opt-in, and a number of
state-changing views never opted in — several of them were reachable by
GET, so a logged-in admin could be made to perform them by following a
crafted link.

check_csrf() itself had a related hole: on a non-POST request it returned
an HttpResponseNotAllowed, but every caller invokes it as a bare statement,
so the response was discarded and the view carried on as though the check
had passed. Both branches now raise SuspiciousOperation, which Django turns
into a 400, so check_csrf() also works as a "this must be a POST" filter.
The token comparison reads the session with .get() so that a missing
session token fails closed rather than raising KeyError.

Views that had side effects on GET are now POST-only, and the templates
that drive them submit inline forms carrying the token instead of linking:

  voter_delete, delete_trustee, trustee_send_url, new_trustee_helios,
  one_election_copy, one_election_archive, one_election_set_featured,
  one_election_set_reg, voters_upload_cancel, force_queue

Views that were already POST-only but unchecked now call check_csrf():

  voters_email, voters_eligibility, voters_upload, trustee_upload_pk,
  password_voter_login, one_election_set_result_and_proof,
  ldap_login_view, password_login_view, password_forgotten_view

SESSION_COOKIE_SAMESITE is now set explicitly to 'Lax' rather than left to
the Django default, since it is doing real work here: it keeps the session
cookie off cross-site POSTs and off cross-site GET subresources.

Also fixes a latent KeyError in voters_upload_cancel, which unconditionally
deleted a session key that may not be set.

Adds helios/test_csrf_smoke.py, covering both halves of the contract: the
views reject a GET, a tokenless POST and a badly-signed POST, and the admin
templates render their actions as POST forms carrying the token.
Addresses review feedback on the CSRF conversion.

A <form> is flow content, so it cannot appear inside an <h5> or a <p>.
The trustee actions were placed inside the heading, which the earlier
commit made invalid; they now sit in a wrapper div alongside an inlined
<h5>, which keeps them on the same visual line. Also drops a stray </p>
in voters_upload_confirm.html that closed an already-closed paragraph.

Adds a regression test that parses the rendered admin pages with html5lib
and asserts no <form> is nested in a <p> or an <h1>-<h6>. The <p> half of
that matters beyond validity: the parser silently closes a <p> when it
reaches a <form>, which would move these actions out of the block they
appear to belong to.
@benadida
benadida force-pushed the claude/report-review-i6eich branch from 7f94a82 to ce03d3e Compare August 15, 2026 19:51
@benadida
benadida temporarily deployed to helios-development August 15, 2026 20:04 Inactive
@benadida
benadida merged commit 88621e3 into master Aug 15, 2026
1 check passed
hengeb added a commit to Mind-Hochschul-Netzwerk/helios-server that referenced this pull request Aug 23, 2026
Bringt drei sicherheitsrelevante Upstream-Commits ein:
- CSRF-Schutz für bisher ungeschützte zustandsändernde Views (benadida#496)
- Fix für verzerrtes Sampling in random_mpz_lt (benadida#494)
- ElGamal-Gruppenzugehörigkeitsprüfung beim Verifizieren gecasteter Stimmen (benadida#495)

Konfliktauflösung: bei den CSRF-Formularen (jetzt <form method="post"> statt
<a href>) wurde jeweils die deutsche MHN-Beschriftung beibehalten, die
Formular-/CSRF-Struktur von upstream übernommen. uv.lock bleibt wie in diesem
Fork üblich ungetrackt.

Nebenbei behoben: one_election_edit griff noch auf die Formularfelder
use_voter_aliases/private_p zu, die im "simplified usage"-Commit aus dem
ElectionForm entfernt wurden - führte zu einem KeyError beim Bearbeiten
einer Wahl, aufgedeckt durch den aktualisierten Upstream-Test.
gjimenexv added a commit to gjimenexv/helios-server that referenced this pull request Sep 2, 2026
origin/master had CSRF protection (benadida#496), which turned several state-changing
links into POST forms, in the same templates the Spanish translation had just
wrapped in trans tags.

Resolved by keeping the POST-form structure from origin/master and re-applying
the translation tags to its button labels, so both intents survive: the actions
still carry a CSRF token, and the UI is still translatable. The trustee heading
keeps its blocktrans msgid byte-for-byte, trailing space included, so the
existing Spanish translation still matches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvpMSYuxtyp5tb37tgCYAt

This branch was previously deployed

1 inactive deployment
helios-development — ce03d3ef Deployed Aug 15, 2026 by benadida
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants