Skip to content

Harden synchronizer token consumption#15803

Open
jamesfredley wants to merge 3 commits into
8.0.xfrom
fix/synchronizer-token-hardening
Open

Harden synchronizer token consumption#15803
jamesfredley wants to merge 3 commits into
8.0.xfrom
fix/synchronizer-token-hardening

Conversation

@jamesfredley

@jamesfredley jamesfredley commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

The Problem

The synchronizer-token holder (backing withForm) kept token state in the HTTP session with no per-session bounds, and withForm validation reset token state as a separate step from validating it. That left two hardening gaps:

  • Unbounded growth - a client could keep growing synchronizer-token session state across many URLs or repeated token generations.
  • Non-atomic consume - concurrent submits could both validate the same token before either request reset it, weakening the one-time-token guarantee (double submit).

Copilot also flagged an upgrade edge case: if a holder was already oversized, single-entry eviction would leave it above the new cap for many future token generations.

The Fix

Bound the storage and make validate-and-reset a single atomic operation.

  • Bound synchronizer-token storage by URL count and by token count per URL (100 URL buckets / 100 tokens per URL).
  • Atomic consume - add a holder-level validate-and-reset, and route withForm through it, so a valid token cannot be accepted twice under concurrency.
  • Preserve the public contract - existing generateToken(url), isValid(url, token), resetToken(url, token), and the TOKEN_URI request parameter are unchanged. Tokens are not made more URL-specific than they already were; the existing URL-keyed storage is simply bounded.
  • Keep sibling tokens for the same URL intact when one token is consumed.
  • Malformed token payloads are treated as invalid instead of being allowed to affect holder state.
  • Trim already-oversized URL maps and per-URL token sets in a single generation call (the upgrade case), refreshing the URL receiving the new token before trimming so the returned token stays valid.

Files

  • SynchronizerTokensHolder.groovy - bounds, atomic consume, oversized-state trimming.
  • Controller.groovy - withForm uses the atomic consume.
  • SynchronizerTokensHolderTests.groovy - bounds, atomicity, trim, and malformed-payload coverage.
  • formtokens.adoc, withForm.adoc - document single-use atomic consumption and the session bounds.

Testing

  • SynchronizerTokensHolderTests, WithFormMethodTests, and the demo33 + hibernate7/demo33 test form token synchronization example specs pass; :grails-web-mvc:check clean.

Open discussion

Whether tokens should be URL-scoped at all is an existing-contract question left open for maintainers. A rare pre-existing race (a concurrent <g:form useToken> render racing empty-holder session removal after a consume) predates this PR and is unchanged by it.

Bound the per-session synchronizer token holder by URL and per-URL token count, and consume withForm tokens through a single holder-level atomic validate-and-reset operation. This prevents unbounded token growth and closes the race where concurrent submits could validate the same token before reset.

Assisted-by: opencode:gpt-5.5
Copilot AI review requested due to automatic review settings July 1, 2026 08:32

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 strengthens Grails’ synchronizer-token (double-submit) mechanism by bounding per-session token storage and making withForm token consumption a single atomic validate-and-reset operation, with expanded regression coverage across unit and example app tests.

Changes:

  • Add bounded token storage by URL count and tokens-per-URL, and introduce an atomic isValidAndResetToken() operation in SynchronizerTokensHolder.
  • Update Controller.withForm(...) to consume tokens via the new holder-level atomic operation and remove the session holder when empty.
  • Add/extend regression tests for bounded storage and one-time token consumption behavior (including concurrent consumption).

Reviewed changes

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

Show a summary per file
File Description
grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/SynchronizerTokensHolder.groovy Adds synchronized validation/consumption, bounded storage, and ordered eviction of URLs/tokens.
grails-test-suite-web/src/test/groovy/org/grails/web/servlet/mvc/SynchronizerTokensHolderTests.groovy Adds unit tests for bounds, one-time consumption, malformed tokens, sibling tokens, and concurrent consume.
grails-test-examples/hibernate7/demo33/src/test/groovy/demo/TestControllerSpec.groovy Extends example spec to assert withForm reuse/double-submit results in invalid token handling.
grails-test-examples/demo33/src/test/groovy/demo/TestControllerSpec.groovy Same as above for the non-hibernate7 demo example.
grails-controllers/src/main/groovy/grails/artefact/Controller.groovy Switches withForm to consume-and-reset atomically via the holder, and clears the holder from session when empty.

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

@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 29.41176% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.4886%. Comparing base (7150c7b) to head (5130d3c).
⚠️ Report is 17 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ls/web/servlet/mvc/SynchronizerTokensHolder.groovy 29.4118% 22 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15803        +/-   ##
==================================================
+ Coverage     49.4679%   49.4886%   +0.0207%     
- Complexity      16684      16702        +18     
==================================================
  Files            1947       1947                
  Lines           92468      92500        +32     
  Branches        16150      16158         +8     
==================================================
+ Hits            45742      45777        +35     
+ Misses          39620      39611         -9     
- Partials         7106       7112         +6     
Files with missing lines Coverage Δ
.../src/main/groovy/grails/artefact/Controller.groovy 0.0000% <ø> (ø)
...ls/web/servlet/mvc/SynchronizerTokensHolder.groovy 37.5000% <29.4118%> (-12.5000%) ⬇️

... and 9 files with indirect coverage changes

🚀 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.

@jdaugherty jdaugherty 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.

I'm missing something - aren't session tokens supposed to be valid for all URLs?

@jdaugherty

Copy link
Copy Markdown
Contributor

This PR doesn't adhere to our PR summary guidelines. Please update it.

@jamesfredley

Copy link
Copy Markdown
Contributor Author

Thanks. I updated the PR body to follow the summary policy more explicitly: it now separates "What was found", "What was fixed", and "Verification".

On URL scope: this PR preserves the existing holder contract, which already stores and validates tokens by URL (generateToken(url), isValid(url, token), resetToken(url, token), and the TOKEN_URI request parameter used by withForm). The change only bounds that existing URL-keyed storage and makes validation plus reset atomic so a token cannot be accepted twice under concurrency.

Ensure existing oversized synchronizer token holders are reduced to the configured URL and per-URL token limits in a single token generation. Cover upgraded-session states where older unbounded holders may already exceed the new limits.

Assisted-by: Hephaestus:openai/gpt-5.5
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Maintenance pass complete. The two Copilot threads were already replied to and marked resolved, and I confirmed that status through the review-thread API. I reran the focused SynchronizerTokensHolderTests and WithFormMethodTests successfully. I also documented the Codecov/TestLens bot feedback in the PR body: Codecov's low patch coverage is covered by focused tests, and TestLens flagged unrelated scaffolding test flakiness while reporting checks completed.

Document single-use atomic token consumption for withForm and the bounded
session token storage (100 URL buckets, 100 tokens per URL) introduced by
the synchronizer token hardening, in the form tokens guide and the withForm
reference page.

Assisted-by: opencode:gpt-5.5
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Pre-release review pass (2026-07-02, dual-reviewer: GPT-5.5 oracle + Codex CLI): NEEDS-IMPROVEMENT, now addressed.

Findings: the new bounded token retention (100 URL buckets / 100 tokens per URL, oldest trimmed) is user-visible but was undocumented (med) - fixed in 5130d3c4ac (form tokens guide + withForm reference). The reviewers confirmed the atomic consume path closes the concurrent double-submit race and that public holder methods and serialization remain compatible. One low-severity observation (pre-existing, unchanged by this PR): a concurrent same-session form render can race the empty-holder session removal and produce a rare false invalidToken.

@testlens-app

testlens-app Bot commented Jul 2, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 5130d3c
▶️ Tests: 42476 executed
⚪️ Checks: 46/46 completed


Learn more about TestLens at testlens.app.

@jdaugherty

Copy link
Copy Markdown
Contributor

Thanks. I updated the PR body to follow the summary policy more explicitly: it now separates "What was found", "What was fixed", and "Verification".

On URL scope: this PR preserves the existing holder contract, which already stores and validates tokens by URL (generateToken(url), isValid(url, token), resetToken(url, token), and the TOKEN_URI request parameter used by withForm). The change only bounds that existing URL-keyed storage and makes validation plus reset atomic so a token cannot be accepted twice under concurrency.

Can you fix the formatting on this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants