Skip to content

Plumb CSRF token to admin templates so POST forms work#26

Merged
murbard merged 4 commits into
mainfrom
fix/csrf-token-in-admin-forms
Jun 18, 2026
Merged

Plumb CSRF token to admin templates so POST forms work#26
murbard merged 4 commits into
mainfrom
fix/csrf-token-in-admin-forms

Conversation

@murbard

@murbard murbard commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Every POST form on the admin web UI was failing at the middleware with csrf token missing or invalid — reproduced when trying to add a GitLab connection but affects all 33 POST forms (Add LLM, Rotate passphrase, Create token, Add Slack OAuth credentials, etc.). Templates emitted no csrf_token field and the render path didn't expose the plaintext token to the page.

Fix

  • session.go — set a NON-HttpOnly sieve_csrf cookie at Issue time carrying the plaintext token. HttpOnly: false is the documented synchronizer-token pattern — the page script needs to read it. SameSite=Lax matches the session cookie so OAuth callback flows still receive both. Logout clears it.
  • web/auth.go — login + setup handlers SetCookie the new sieve_csrf alongside sieve_session. The requireOperatorSession middleware reads the cookie and stashes the plaintext on sess.CSRFToken for downstream use.
  • web/server.go render() — injects CSRFToken into template data from the session in context. Supports map[string]any (most handlers) and structs with a string CSRFToken field (just connectionEditData today, which now declares it).
  • web/templates/nav.html — single inline <script> seeds window.SIEVE_CSRF from {{.CSRFToken}} (Go html/template JS-string-escapes the value), and a delegated submit handler on the document injects an idempotent <input name="csrf_token"> into every same-origin POST form before submit. No per-form change required because nav.html is included by every admin page.

Tests

  • csrf_form_integration_test.go:
    • TestPOSTConnectionsAddPassesCSRF proves a POST carrying the form-field token reaches the handler (303 redirect).
    • TestPOSTConnectionsAddMissingCSRFRejected (negative control) asserts a POST without it still 403s — pins the security property that this didn't accidentally relax the gate.
  • Full go test ./... green.

Security stance

  • Cookie is non-HttpOnly because the synchronizer-token pattern requires it. The plaintext is only useful if the attacker already has the session cookie (server verifies the pair). SameSite=Lax blocks cross-site delivery for state-changing requests; the in-form token defends GET-then-POST attacks Lax doesn't cover.
  • No env-var paths, no behavior change for already-authenticated agents (this is admin-UI only, port 19816).

Test plan

  • ./sieve → log in → Add GitLab connection succeeds (no CSRF error).
  • Same for Add LLM (Anthropic/OpenAI/Gemini/Bedrock), Add Slack workspace, Rotate passphrase, Create token, Create policy, Add role.
  • Negative: clear the sieve_csrf cookie in DevTools → next form POST fails 403 (proves the middleware is still enforcing).
  • Log out → both sieve_session and sieve_csrf cookies cleared.

🤖 Generated with Claude Code

Every POST form on the admin web UI (Add GitLab connection, Add LLM,
Rotate passphrase, Create token, etc.) was failing at the middleware
with "csrf token missing or invalid". The templates didn't emit a
csrf_token form field and the render path didn't expose the plaintext
token to the page. The middleware only had the SHA-256 hash from
Lookup(), and the plaintext from Issue() was thrown away after the
session cookie was set.

Fix:

- session: set a NON-HttpOnly sieve_csrf cookie at Issue time carrying
  the plaintext token (HttpOnly off is the documented synchronizer-
  token pattern — the page script needs to read it). SameSite=Lax to
  match the session cookie so OAuth callback flows still receive both.
  /logout clears the cookie alongside the session cookie.

- web/auth.go: login + setup handlers SetCookie the new
  sieve_csrf alongside sieve_session. The requireOperatorSession
  middleware reads the sieve_csrf cookie and stashes the plaintext on
  sess.CSRFToken so handlers/templates can echo it back. Missing
  cookie is not fatal — the next POST fails closed and the operator
  re-logs in.

- web/server.go: render() now injects CSRFToken into template data
  from the session in context. Two shapes are supported:
    * map[string]any: m["CSRFToken"] = token (when absent).
    * *struct or struct with a string field named CSRFToken: set via
      reflection (when zero). connectionEditData declares the field
      to opt in.

- web/templates/nav.html: a single inline <script> seeds
  window.SIEVE_CSRF from {{.CSRFToken}} (html/template JS-string-
  escapes the value automatically), and a delegated submit handler
  on the document injects an idempotent <input name="csrf_token">
  into every same-origin POST form before submit. No per-form change
  required; nav.html is included by every admin page so the handler
  is always present.

Tests:

- csrf_form_integration_test.go: TestPOSTConnectionsAddPassesCSRF
  proves a POST with the form-field token succeeds; the negative
  control asserts a POST without it still 403s. Pins the security
  property that this plumbing didn't accidentally relax the gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

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 fixes broken CSRF handling in the admin UI by plumbing the plaintext CSRF token through the operator session → template render path → shared admin navigation template, enabling POST forms to consistently include csrf_token without per-form template edits.

Changes:

  • Add a dedicated non-HttpOnly sieve_csrf cookie to carry the plaintext CSRF token and clear it on logout.
  • Expose the plaintext CSRF token on the session-in-context and inject it into template data during render.
  • Add a delegated JS submit handler in nav.html to inject a hidden csrf_token input on same-origin POST forms; add integration tests for CSRF acceptance/rejection.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/web/templates/nav.html Seeds window.SIEVE_CSRF from template data and injects csrf_token into POST forms at submit time.
internal/web/server.go Updates render() signature and injects CSRF token into template data (map/struct).
internal/web/passphrase_rotation.go Updates call site to new render(w, r, ...) signature.
internal/web/csrf_form_integration_test.go Adds regression tests for CSRF acceptance/rejection on /connections/add.
internal/web/connection_edit.go Adds CSRFToken field to view-model and updates render call signature.
internal/web/auth.go Reads CSRF token from cookie into session context; sets/clears new CSRF cookie on login/setup/logout.
internal/session/session.go Defines sieve_csrf cookie helpers for issuing and clearing plaintext CSRF cookie.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/web/server.go Outdated
Comment thread internal/web/templates/nav.html
Comment thread internal/web/csrf_form_integration_test.go Outdated
Comment thread internal/web/auth.go
Four threads, four fixes:

1. render() now ALWAYS sets data["CSRFToken"], even to "" when no
   session is in context (older session, lost cookie). nav.html
   embeds the value as a JS string literal in
   `window.SIEVE_CSRF = "{{.CSRFToken}}"`; leaving the key absent
   would produce invalid JS (`= ;`) and break every script on the
   page. The empty case is harmless: the submit handler skips
   injection when SIEVE_CSRF is falsy, and the middleware fails
   closed at the next POST anyway. injectCSRFToken docstring
   updated to describe the new contract.

2. nav.html submit handler now actually verifies the form's action
   resolves to the same origin as the page (the previous comment
   claimed this, but the code didn't check). Uses URL() to resolve
   action against window.location.href and compares .origin to
   window.location.origin; malformed actions fail closed (skip).
   A form with no action attribute resolves to the document URL
   and passes — the common case for our forms.

3. csrf_form_integration_test.go file header rewritten to describe
   what the tests actually do (POST + assertions) rather than the
   misleading cookie/render claims from the first draft. Points
   readers at auth_test.go for the cookie lifecycle coverage.

4. auth_test.go: TestLogin_HappyPath, TestLogin_WrongCredential, and
   TestLogout_DeletesCookieAndSession now assert that
   session.CSRFCookieName ("sieve_csrf") is set on successful login
   (non-HttpOnly, non-empty value), NOT set on failed login, and
   carries a deletion directive on logout. Pins the cookie
   lifecycle alongside the existing sieve_session assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread internal/web/server.go Outdated
Comment thread internal/web/templates/nav.html
- nav.html: wrapped window.fetch so admin pages' fetch()-style POSTs
  (/api/generate-script, /api/save-script, and any future callers)
  automatically include X-CSRF-Token on same-origin state-changing
  requests. Idempotent: a caller's existing X-CSRF-Token header is
  left alone. Same-origin gate matches the form-submit handler's
  resolution rule: input URL is resolved against the current document
  and the request is skipped if the resulting .origin differs.
  Non-state-changing methods (GET/HEAD/OPTIONS) and cross-origin
  requests bypass the wrapper entirely so the token never leaks
  off-site.

- server.go render() comment: the previous wording claimed nav.html
  embeds the token as `window.SIEVE_CSRF = "{{...}}";` (with quotes
  inside the template). nav.html actually writes
  `window.SIEVE_CSRF = {{.CSRFToken}};` and lets html/template's
  JS-context escaper emit a quoted literal automatically. Updated
  the comment to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread internal/web/connection_edit.go
Comment thread internal/session/session.go Outdated
Two fixes — one real bug, one docstring nit.

1. connection_edit.go: renderEditError(WithConfig) called
   t.ExecuteTemplate directly to set a 400 status, bypassing
   s.render() and therefore the CSRFToken injection. On validation /
   save errors the re-rendered edit page would carry an empty
   window.SIEVE_CSRF, and the operator's retry POST would 403 at the
   middleware. Plumbed *http.Request through both helpers and now
   inject data.CSRFToken from the session-in-context manually before
   executing the template. Annotated the bypass with a comment so
   future readers see why s.render is skipped here.

2. session.go: ClearCSRFCookie's docstring claimed the function
   returns a "Max-Age=0 cookie", which is true at the wire-header
   level but confusing given the implementation sets MaxAge=-1.
   (Go's net/http maps MaxAge<0 to the "Max-Age=0" Set-Cookie
   attribute — the field value is the deletion sentinel, not the
   wire value.) Reworded to spell out both layers so the next
   reader doesn't mistake the field for a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@murbard murbard merged commit 7e73411 into main Jun 18, 2026
1 check passed
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.

2 participants