Skip to content

fix(web): send CSRF token with bulk toolbar POSTs - #659

Merged
CybotTM merged 8 commits into
mainfrom
fix/652-bulk-toolbar-csrf
Aug 26, 2026
Merged

fix(web): send CSRF token with bulk toolbar POSTs#659
CybotTM merged 8 commits into
mainfrom
fix/652-bulk-toolbar-csrf

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 25, 2026

Copy link
Copy Markdown
Member

Fixes #652.

Root cause. The bulk-select toolbar (v2-bulk.js) builds its POST form dynamically with only target_dn + extras — it never included a csrf_token field, and the list pages it lives on never rendered a token anywhere in their DOM, so the JS had no token to send. Every toolbar action (add members, add to group, remove from group, disable, delete — all three scopes) therefore died in Fiber's CSRF middleware with 403 "CSRF token validation failed". The reporter's nginx setup was innocent: the failure is environment-independent and matches the screenshot URL /groups/bulk?action=add-members exactly.

Fix. The three list templates now expose the session's CSRF token as a data-csrf attribute on main[data-bulk-scope], and submitForm() adds it to the generated form as a hidden csrf_token input — the same token/extractor contract (form:csrf_token) the server-rendered drawer forms already use; a missing token now logs a console.error instead of failing silently. List pages are not affected by the template cache: only graph views are ever stored (RenderWithCache); a note in setupRoutes records that constraint and a test pins it.

Why no test caught it. setupFullTestApp registers routes without the CSRF middleware, and the existing e2e bulk test drives the drawer's Delete form, which is server-rendered with an embedded token — the JS-built toolbar form was never exercised.

Tests. Every guard was verified against an injected defect, not just green on the fixed tree:

  • internal/web/bulk_csrf_test.go — behind the real CSRF middleware in production order (RequireAuth → csrf → cache middleware): all three list pages must expose the identical session token as data-csrf (emptying the token argument in any handler reddens the guard at that path; a hardcoded wrong-but-non-empty token reddens the identity check), the token round-trips through a bulk POST, a token-less POST still 403s, and list pages are never served from the template cache (minutes-scale harness TTL so an expired entry can't fake a MISS).
  • internal/e2e/bulk_toolbar_csrf_test.go — drives the actual toolbar in Chromium against OpenLDAP: checkbox → "Add members…" → prompt → POST (ExpectNavigation-wrapped), then asserts the member tag's remove-form input — which renders only for actual members, so a silently failed LDAP add is caught (verified red against a handler mutant suppressing AddUserToGroup, red with only the JS fix reverted, and the unmutated test green both locally and in CI's e2e job at 608e188: https://github.com/netresearch/ldap-manager/actions/runs/32909180989/job/97999653848). The disposable group is deleted in t.Cleanup so later tests aren't polluted.

Three adversarial review passes ran over this branch; the third returned no major findings. All findings from all rounds (vacuous waits, a vacuous membership assertion, timing-coupled cache guard, single-page coverage, token-identity gap, e2e state pollution, silent missing-token path) are fixed and mutation-verified. The cache-refresh poll loops here and in bulk_delete_test.go now poll Locator.Count() instead of burning the 30s page timeout per miss.

Also in this PR — two pre-existing breakages of the local commit gate, fixed because they blocked committing anything: the pre-commit config referenced the nonexistent hook id go-fmt-goimports (the pinned rev ships go-imports), and .prettierrc.json still loaded Tailwind-era plugins that no longer resolve (no package.json in the repo). Plus go mod tidy drift left behind by the golangci-lint v2.13.1 bump (#657), which the now-working go-mod-tidy hook flags.

Assisted by claude-code:claude-fable-5 — Session

Two pre-existing breakages made every local commit fail before any
hook ran:

- tekwizely/pre-commit-golang v1.0.0-rc.1 ships `go-imports`, not the
  configured `go-fmt-goimports`, so pre-commit aborted environment
  setup with "hook not present in repository".
- .prettierrc.json still loaded prettier-plugin-tailwindcss and
  prettier-plugin-go-template, leftovers from the removed bun/Tailwind
  toolchain; with no package.json in the repo the mirrors-prettier
  hook cannot resolve them and errors out on every run.

Assisted-by: claude-code:claude-fable-5
Agent-Session: https://claude.ai/code/session_01D41i7TcscCHnuQ22AJzr4S
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
The v2.13.1 bump (#657) updated the tool version without re-running
go mod tidy, leaving go.mod/go.sum out of sync with the new dependency
graph. The pre-commit go-mod-tidy hook now flags this on every local
commit. Tidied under the pinned go1.27.0 toolchain; build and tests
pass, including the e2e-tagged package.

Assisted-by: claude-code:claude-fable-5
Agent-Session: https://claude.ai/code/session_01D41i7TcscCHnuQ22AJzr4S
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
The bulk-select toolbar (v2-bulk.js) built its POST form with only
target_dn + extras — no csrf_token field — and the list pages it lives
on never rendered a token anywhere in their DOM. Every toolbar action
(add members / add to group / remove from group / disable / delete,
across all three scopes) therefore died in the CSRF middleware with
403 "CSRF token validation failed", regardless of deployment setup.

Fix: the three list templates now expose the session's CSRF token as
a data-csrf attribute on main[data-bulk-scope], and submitForm() adds
it to the generated form as a hidden csrf_token input — the same
token/extractor contract the server-rendered drawer forms already use.

Why no test caught it: setupFullTestApp registers routes without the
CSRF middleware, and the e2e bulk test drives the drawer's Delete form
(server-rendered, token embedded), never the JS-built toolbar form.
New coverage closes both halves:

- internal/web/bulk_csrf_test.go runs the list GET + bulk POST behind
  the real CSRF middleware in production order and asserts the
  page-exposed token round-trips (verified red on the unfixed tree:
  "missing csrf token in form").
- internal/e2e/bulk_toolbar_csrf_test.go drives the actual toolbar in
  Chromium: checkbox → "Add members…" → prompt → POST, then asserts
  the membership change landed (verified red with only the JS fix
  reverted, green with it).

Fixes #652

Assisted-by: claude-code:claude-fable-5
Agent-Session: https://claude.ai/code/session_01D41i7TcscCHnuQ22AJzr4S
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
@github-actions github-actions Bot added dependencies Pull requests that update a dependency file tests frontend labels Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails
gomod/dev.gaijin.team/go/exhaustruct/v5 5.0.3 UnknownUnknown
gomod/github.com/dlclark/regexp2/v2 2.2.1 🟢 4.1
Details
CheckScoreReason
Maintained🟢 1016 commit(s) and 4 issue activity found in the last 90 days -- score normalized to 10
Packaging⚠️ -1packaging workflow not detected
Code-Review⚠️ 0Found 0/30 approved changesets -- score normalized to 0
Token-Permissions⚠️ -1No tokens found
Dangerous-Workflow⚠️ -1no workflows found
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Security-Policy⚠️ 0security policy file not detected
Pinned-Dependencies⚠️ -1no dependencies found
SAST⚠️ 0no SAST tool detected
License🟢 10license file detected
Fuzzing⚠️ 0project is not fuzzed
Binary-Artifacts🟢 10no binaries found in the repo
Signed-Releases⚠️ -1no releases found
Branch-Protection⚠️ -1internal error: error during branchesHandler.setup: internal error: some github tokens can't read classic branch protection rules: https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md

Scanned Files

  • go.mod

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Mutation Testing Results

Mutation Score: 0.00% (threshold: 60%)

⚠️ Score is below threshold. Consider improving test coverage or test quality.

What is mutation testing?

Mutation testing measures test quality by introducing small changes (mutations) to the code and checking if tests detect them. A higher score means better test effectiveness.

  • Killed mutants: Tests caught the mutation (good!)
  • Survived mutants: Tests missed the mutation (needs improvement)

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.21%. Comparing base (6749c0b) to head (ed7858c).

❌ Your project check has failed because the head coverage (66.97%) is below the target coverage (70.00%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #659      +/-   ##
==========================================
+ Coverage   74.07%   74.21%   +0.14%     
==========================================
  Files          36       36              
  Lines        3151     3157       +6     
==========================================
+ Hits         2334     2343       +9     
+ Misses        817      814       -3     
Flag Coverage Δ
e2e 67.56% <ø> (ø)
unittests 74.45% <100.00%> (+0.14%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Review follow-up on the #652 fix:

- e2e: wrap the "Add members…" click in ExpectNavigation. The page is
  already on /groups when the click fires, so the previous
  WaitForURL("**/groups") matched the current URL and resolved before
  the POST navigation — the 403 assertion could run against the
  pre-POST DOM and the follow-up Navigate could abort the in-flight
  POST on a slow runner.
- v2-bulk.js: console.error when main[data-bulk-scope] carries no
  data-csrf token, so a regression to the silent pre-fix 403 is
  diagnosable from the browser console. console.error for error
  conditions matches v2-graph.js / v2-palette.js.
- unit: new TestBulkToolbar_ListPageIsNeverServedFromCache pins the
  setupRoutes invariant that list pages (which now embed a
  session-scoped token) are never STORED by the template cache; the
  test harness gains templateCacheMiddleware to mirror production.

Assisted-by: claude-code:claude-fable-5
Agent-Session: https://claude.ai/code/session_01D41i7TcscCHnuQ22AJzr4S
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
The new toolbar test leaves its seeded group behind (its action under
test is add-members, not delete), and later tests in the package pick
groups dynamically: in CI, TestAddRemoveGroupMembership selected the
stale bulk-toolbar-csrf-* group (sorts before "developers") and failed
with LDAP error 20 "Attribute Or Value Exists" because testuser1 was
already a member. Remove the group via ldapdelete in t.Cleanup.

Verified locally by running the polluter + victim pair in CI order:
both pass with the cleanup in place.

Assisted-by: claude-code:claude-fable-5
Agent-Session: https://claude.ai/code/session_01D41i7TcscCHnuQ22AJzr4S
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Second adversarial review round; every finding verified by injecting
the defect it targets:

- e2e membership assertion was vacuous: the group detail page renders
  "testuser1" in the add-user datalist of UNASSIGNED users too, so a
  silently failed add (per-entry LDAP error, 303 redirect, no flash)
  kept the test green. Now asserts the member tag's remove-form input,
  which renders only for actual members — confirmed red against a
  handler mutant that suppresses AddUserToGroup, green when restored.
- The cache-refresh poll loops (here and in bulk_delete_test) called
  WaitFor-style helpers carrying the 30s page timeout per miss against
  a server-rendered, post-load-static DOM — the "45s, 1s ticks" loop
  really made ~2 attempts and could overrun or spuriously skip. Poll
  Locator.Count() instead.
- Cache-invariant guard was timing-coupled: with the harness's 100ms
  TTL, a stored-but-expired entry also reads as MISS, silently passing
  the very RenderWithCache mutation the guard exists to catch. TTL is
  now minutes-scale.
- Token-exposure and never-cached guards covered /groups only while
  /users and /computers received identical plumbing. Both guards now
  loop over all three list pages; emptying the token argument in
  either extra handler reddens the guard at that path (verified).

Assisted-by: claude-code:claude-fable-5
Agent-Session: https://claude.ai/code/session_01D41i7TcscCHnuQ22AJzr4S
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
Third review round returned no majors; one minor closed here: the
three-page token-exposure loop asserted each token non-empty but
POSTed only the last one, so a wrong-but-non-empty token on a single
page (hardcoded string in one handler) survived. CSRF storage is
session-backed — one token per session — so the loop now requires all
three pages to render the identical token. Verified red against a
hardcoded-token mutant in the computers handler ("token diverged on
/computers"), green restored.

Assisted-by: claude-code:claude-fable-5
Agent-Session: https://claude.ai/code/session_01D41i7TcscCHnuQ22AJzr4S
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
The previous cleanup deleted the group directly in LDAP, leaving a
ghost in the app's 30s cache. In CI timing that ghost became the first
entry in TestAddRemoveGroupMembership's addable-groups datalist
(bulk-toolbar-csrf-* sorts before developers); adding testuser1 to the
nonexistent group failed silently, left the user with zero
memberships, and cascaded into TestAddGroupAsNonAdminShowsFlash. The
earlier "benign by cache ordering" assessment was wrong — it held only
by refresh-timing luck.

Now the test's final step deletes the group via the bulk "Delete
groups" toolbar action, which removes it from LDAP and the app cache
in one stroke — and exercises submitForm's second groups-scope action
through the same CSRF plumbing this PR fixes. The direct ldapdelete
remains as a failure-path backstop and now waits out one cache refresh
cycle so a ghost cannot poison later tests even when this test dies
early.

Verified: full e2e suite green locally (101s), including the polluter
followed by both previously cascading tests in CI order.

Assisted-by: claude-code:claude-fable-5
Agent-Session: https://claude.ai/code/session_01D41i7TcscCHnuQ22AJzr4S
Agent-Host: 0493f0
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
@sonarqubecloud

Copy link
Copy Markdown

@CybotTM
CybotTM marked this pull request as ready for review August 25, 2026 23:46

@github-actions github-actions Bot 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.

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@CybotTM
CybotTM added this pull request to the merge queue Aug 25, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 25, 2026
@CybotTM
CybotTM added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit 1eeab5e Aug 26, 2026
34 checks passed
@CybotTM
CybotTM deleted the fix/652-bulk-toolbar-csrf branch August 26, 2026 00:44
@CybotTM CybotTM mentioned this pull request Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file frontend tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Potential Bug: CSRF token validation failed

2 participants