Skip to content

Merge 9.1.x (Spring Boot 4.2) into 9.0.x - #16937

Open
codeconsole wants to merge 174 commits into
apache:9.0.xfrom
codeconsole:merge/9.1.x-into-9.0.x
Open

codeconsole wants to merge 174 commits into
apache:9.0.xfrom
codeconsole:merge/9.1.x-into-9.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Spring Boot 4.2.0 is scheduled to be released November 19, 2026

This PR was created PR the discussion in the weekly meeting 11/11/26

Brings the Spring Boot 4.2 upgrade from 9.1.x into 9.0.x.

Everything on 9.1.x that is not already on 9.0.x comes from these three pull requests:

The only other commits on 9.1.x are:

  • 80a84cc659, 23807a04d2 and 5d7b6adc8d: these switch AOT specs to NameGenerator, the Spring Framework 7.1 name for ClassNameGenerator. Boot 4.2 brings in Framework 7.1, so 9.0.x needs them too.
  • d6bfb8ea56 (the data binding white list for a compiled parent): the same change is already on 9.0.x as ce2db7a52d, and the files are identical on both branches.
  • 829de6092e, which sets the branch version to 9.1.0-SNAPSHOT. The second commit of this PR restores the 9.0.x values in gradle.properties, GrailsUtilTests, THREAT_MODEL.md and threat-model.yaml.

Ten more commits come after it:

  • a merge of 9.0.x, for Keep the XML data binding sources in a List<DataBindingSource> #16938. It made the same GPathResultCollectionDataBindingSource change as 9bf4e00ba6 on 9.1.x, so the conflict is resolved with the 9.0.x side.
  • 55cfcda58f, which tags the Boot 4.2 API @since 9.0 instead of @since 9.1, because 9.0.0 is the first release it ships in.
  • 1d04adf7c5, which numbers the configuration key section of the Grails 9 upgrade guide 23. It had kept the number 21 from 9.0.x through an earlier merge-up.
  • 76dc3617b0, a122fe1f4f, e2664a51dc and e164d65bc6, which keep what traceNativeMetadata fills an email or URL field with within the field's maxlength and on example.com. Where nothing on example.com fits, the form is not submitted and the trace fails, naming the field. The bug is on 9.1.x too, so the merge back fixes it there.
  • 22fc54ca4f, 1c493e2244 and b6996ed8a0, which make the Spring Security user cache (cacheUsers) store users by value. It stored them by reference, so when the authentication manager erased the signed-in user's password, it erased the cached user too. The security interceptor from Spring Boot 4.2.0-RC2 #16871 authenticates a Digest user on every request, so every request then missed the cache. Each lookup now gets a copy, which the plugin's AOT hints let a native image make. Section 24 of the Grails 9 upgrade guide describes the change. The bug is on 9.1.x too.

After this PR, the 9.0.x tree is the 9.1.x tree except for the four version files and those ten commits.

Merge-up note: once this lands, 9.1.x is an ancestor of 9.0.x, so a plain git merge 9.0.x on 9.1.x fast-forwards and sets 9.1.x to 9.0.0-SNAPSHOT. Merge 9.0.x back into 9.1.x right after this lands, before anything else is pushed to 9.1.x, and reverse-apply the version commit inside that merge:

git switch 9.1.x
git merge --no-ff --no-commit origin/9.0.x
git show a1a2265c9f7 | git apply -R --index
git commit

That keeps projectVersion=9.1.0-SNAPSHOT, githubBranch=9.1.x, the GrailsUtilTests value and the 9.1.x threat-model binding, and 9.1.x still gets the @since 9.0 tags and the guide numbering. From then on both branches share those lines, so later merge-ups keep the 9.1.x version.

Please merge with a merge commit, not a squash, so that 9.1.x stays an ancestor of 9.0.x.

codeconsole and others added 30 commits September 25, 2026 20:22
Spring Framework 7.1 renamed org.springframework.aot.generate.ClassNameGenerator
to NameGenerator (it now also generates resource paths). The constructors are
unchanged, so the AOT specs only need the new type name.
…ionContext

Spring Framework 7.1 adds it to BeanFactory as an abstract method. The mock
resolves the reference to its raw class and applies the same name and type
checks as getBean(String, Class).
graphql-java 26 (selected by Spring Boot 4.2) narrowed
GraphQLSchema.Builder.additionalTypes to Set<? extends GraphQLNamedType>.
GraphQLSchemaInterceptor.interceptSchema keeps handing interceptors a
Set<GraphQLType>, so existing implementations still override it; Schema
unwraps list and non-null wrappers before building, which adds the same
named type graphql-java 25 reached by traversing the wrapper.
graphql-java-extended-scalars has no release newer than 24.0; the
grails-data-graphql core and plugin test suites pass with 24.0 against
graphql-java 26.1.
Spring Boot 4.2 manages Jedis 8, which depends on org.json:json:20260719
instead of 20251224. The SBOM license override is keyed by version, so
grails-redis failed cyclonedxDirectBom until the entry moved with it.
Spring Boot 4.2.0-M2 manages the versions these pins overrode Spring Boot
4.1 with - Jackson 3.1.6, Logback 1.6.3 and Commons Codec 1.22.1 - so
grails-bom no longer needs to redeclare them. The Spring Dependency
Management end-to-end fixture only mirrors the logback and jackson3
overrides when they are present.
Jackson 2.21.6, which Spring Boot 4.2 manages, already carries the
CVE-2026-68497 and CVE-2026-19032 fixes, so the 2.22.2 pin is only needed
for Groovy YAML. MongoDB 5.12.0 now overrides 5.11.1, which already has
the CVE-2026-18710 fix; it stays so the upgrade does not downgrade the
driver.
Both coordinates are version-pinned. Spring Boot 4.2.0-M2 manages H2
2.5.250 (Sonatype still flags every H2 version for CVE-2018-14335) and
Spring Security 7.2.0-M2 (outside the range Spring's CVE-2026-47838
advisory lists).
Names the Spring Security, Spring Data and Micrometer lines Spring Boot
4.2 manages and links its release notes and Spring Framework 7.1's. The
structured logging note no longer ties the format IDs to Spring Boot 4.1.
Resolve the dependency and agent-guide conflicts while preserving Spring Boot 4.2-M2, the upstream BOM property names, and the relocated Grails developer skill.
Preserve the Spring Boot 4.2-M2 upgrade in the relocated vulnerability-scan plugin and integrate the latest target-branch updates.

Validation: 9 SbomPluginSpec tests, vulnerability-scan compilation, and grails-base-bom/grails-bom property validation passed.
Spring Framework 7.1 renamed ClassNameGenerator to NameGenerator. The
grails-core AOT specs already use the new type; grails-openapi still
imported the old one, so every workflow that compiled its tests failed.

Assisted-by: opencode:grok-4.7
The 9.0.x merge brought back this PR's older "Current Grails is built on"
wording, which 9.x had already shortened.
The rule holds for whichever spring-boot-dependencies version the build
imports, so it no longer needs editing on every Spring Boot bump.
Spring Boot 4.2 moves grails-data-graphql from graphql-java 25 to 26.1, and
26.0 has breaking changes. A new "Upgrading from Grails 9.0 to Grails 9.1"
section lists the ones a Grails application can meet: query complexity
limits enforced by default (100 levels, 100,000 fields) and how to change
them with QueryComplexityLimits.setDefaultLimits, the deprecated non-null
argument and input field validation that code-built schemas now get, and
the named-type rule for additional schema types. What's New links to it.

QueryComplexityLimitsSpec runs those limits against the schema the plugin
generates: a 101-level query fails by default, raised and disabled limits
let deeper queries through, and parse errors count as failures, since
graphql-java's parser stops past about 165 levels on its own.
graphql-java only rejects deprecating a non-null argument or input field
that has no default value (DeprecatedInputObjectAndArgumentsAreValid
checks hasSetDefaultValue); a non-null one with a default can still be
deprecated.
groovy-yaml 6.0.0 declares Jackson 2.22.2 as an ordinary dependency, which
wins over the 2.21.6 Spring Boot 4.2 manages; nothing marks 2.22 as a
minimum. The pin exists because the BOM must manage the winning version.
The PR now targets 9.0.x. Conflicts resolved as follows:

- dependencies.gradle: 9.0.x pins Jackson 3 at 3.2.3, Jackson 2 at 2.22.3
  and Logback at 1.6.5 for CVEs that the 3.1.6, 2.21.6 and 1.6.3 Spring Boot
  4.2.0-M2 manages do not carry, so those pins stay, described against Spring
  Boot 4.2's versions. The jackson-core, jackson-databind, logback-classic
  and logback-core entries this branch had dropped come back with them.
  Guava 33.7.2 is taken from 9.0.x.
- VulnerabilityScanPlugin.groovy: taken from 9.0.x, whose scan keys its
  exclusions by CVE and coordinate without a version.
- AGENTS.md and the grails-developer skill: Groovy 6.0.x and Spock
  2.5-groovy-6.0 from 9.0.x alongside Spring Boot 4.2.x and Spring
  Framework 7.1.x from this branch.
…sions

9.0.x pins Tomcat 11.0.26 as a security override of the 11.0.24 Spring
Boot 4.1 manages, and SLF4J 2.0.19 above 4.1's 2.0.18 for Logback 1.6.5.
Spring Boot 4.2.0-M2 manages exactly those versions, so validateBomProperties
fails on the repeated pins and they and their entries go; the Tomcat CVE
fixes and the SLF4J version Logback needs come from Spring Boot itself.
The reason still named 7.1.1 and Spring Boot 4.1.1 and asked to be
reassessed on a Spring Security upgrade. Spring's advisory lists only 7.1.0
on the 7.1 line as affected; 7.2.0-M2, which Spring Boot 4.2.0-M2 manages,
is not listed and contains the AesCbcBytesEncryptor and AesGcmBytesEncryptor
replacements the 7.1.1 fix introduced, so the exclusion still holds.
With the PR on 9.0.x (9.0.0-SNAPSHOT), applications get Spring Boot 4.2 and
graphql-java 26 when they upgrade from Grails 8, not from 9.0 to 9.1. The
section moves to upgrading90x, "Upgrading from Grails 8 to Grails 9", above
the Grails 7 to Grails 8 entry, and opens by describing the move from Grails
8 on Spring Boot 4.1. What's New points its introduction and its Spring Boot
4.2 section at it.
Both getBean(String, ParameterizedTypeReference) and getBeanProvider
spelled the type out in full, while ResolvableType beside it is imported.
Renaming ClassNameGenerator in place left NameGenerator ahead of
DefaultGenerationContext; the grails-core specs already list it in order.
Spring Framework 7.1 deprecates ForwardedHeaderUtils.adaptFromForwardedHeaders
for removal in favor of parseStandardHeader and parseXForwardedHeaders, so
callers choose a header format. GrailsSecurityHeadersFilter, which only
needs the scheme for its HSTS decision, keeps the precedence the old method
applied: the RFC 7239 Forwarded header when the request has one, otherwise
X-Forwarded-Proto and X-Forwarded-Ssl.

Two edge cases change, both only for HSTS, which user agents ignore on a
plain connection: X-Forwarded-Ssl is no longer consulted next to a Forwarded
header without proto, and a malformed for or by address in the Forwarded
header now invalidates it like any other malformed forwarded value. The
spec covers both, and that RFC 7239 obfuscated and quoted IPv6 for values
still parse.
The upgrade guide now lists the Spring Boot 4.2 and Spring Framework 7.1
changes a Grails application is most likely to notice:

- server.forward-headers-strategy=framework reads only X-Forwarded-* by
  default, and the filter now comes from WebMvcAutoConfiguration, so an
  application declaring @EnableWebMvc no longer gets it
- embedded Tomcat keeps relative redirect locations relative, which
  changes the Location header of chain() but not of redirect()
- TestRestTemplate is deprecated for removal in favor of RestTestClient
- Spring MVC no longer auto-detects the JAXB converter (Grails render
  and respond are unaffected)
- tests load static nested @configuration classes from superclasses and
  enclosing classes

The deployment guide's reverse-proxy section names the header-format
property for proxies that send the RFC 7239 Forwarded header.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei thanks, all addressed at 1d04adf:

  1. Merge back into 9.1.x: the description's merge-up note now has your recipe: merge 9.0.x into 9.1.x right after this lands, before anything else goes to 9.1.x, with a1a2265c9f reverse-applied inside the merge. That also covers GrailsUtilTests and the bomSnapshotNotPublished comment.
  2. @since 9.1: all 19 tags are now @since 9.0, in their own commit (55cfcda58f). a1a2265c9f is unchanged, so the reverse apply restores only the versions, and 9.1.x gets the @since 9.0 tags too.
  3. Upgrade guide: "A Configuration Key Below a Value That Is Not a Map Is Absent" is now section 23 (1d04adf7c5). The sections now run 1 to 23 with no gaps or repeats.
  4. Boot 4.2.0 GA: agreed, no milestone or RC gets cut from 9.0.x before the 4.2.0 bump. The CVE-2026-47842 exclusion text in VulnerabilityScanPlugin will be updated in that bump.

Also, 9.0.x moved after your review: #16938 made the same GPathResultCollectionDataBindingSource fix as 9bf4e00ba6, and the two conflicted. I merged 9.0.x in (91e777a66f) and took the 9.0.x side, so grails-databinding-core is now identical to 9.0.x.

@codeconsole
codeconsole requested a review from matrei November 12, 2026 16:11
@jamesfredley
jamesfredley dismissed their stale review November 13, 2026 02:29

The approval no longer holds: a filled email or URL can be longer than the field allows, so the trace records a form that failed validation as a success.

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

Not approving. A filled email or URL can be longer than the field allows, so the trace records a form that failed validation as a success.

return within(String.format(Locale.ROOT, '%d-W%02d',
now.get(IsoFields.WEEK_BASED_YEAR), now.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)), tag)
case 'email':
return traced(maxLength == null ? null : Math.max(maxLength - EMAIL_DOMAIN.length(), 1)) + EMAIL_DOMAIN

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.

An empty email field with maxlength 10 is filled with at least 13 characters, because the domain is always appended. A URL cannot be shorter than the base that is always prepended. The value fails the field's length constraint, the form is shown again with a success status, and the save is never traced. Fit the value to the declared length.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 76dc361. The email and URL values now fit the declared maxlength:

  • Email: traced-…@example.com when the length leaves room for a name. Otherwise the shorter @x.io domain, which still passes the email constraint, so maxlength="10" gets 12345@x.io.
  • URL: https://example.com/<digits> when there is room for a path. Otherwise the longest of https://example.com/, https://example.com and http://x.io that fits.
  • Below 6 characters for an email, or 11 for a URL, no valid value fits, so the value is cut to the length.

The typed-form feature in TraceNativeMetadataTaskSpec now covers fields at maxlength 10 and 4 for email, and 24, 19, 12 and 5 for URL.

This code is also on 9.1.x. The merge back of 9.0.x into 9.1.x after this lands takes the fix there.

An empty email field was filled with at least the example domain plus one
character, and a URL with at least its base, so a field whose maxlength was
shorter failed its length constraint. The form was shown again with a 200, and
the trace read it as a save that was never recorded.

An email now falls back to a shorter domain, and a URL to the site without a
path or a shorter site, whichever is the longest that fits. Where no valid
value fits, the value is cut to the length.

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

Not approving. An email now fits the field. A URL fitted to a short maxlength can be a link the URL check rejects, so the save is still not traced.

if (maxLength == null || maxLength > URL_BASE.length()) {
return URL_BASE + traced(maxLength == null ? null : maxLength - URL_BASE.length())
}
SHORT_URLS.find { String url -> url.length() <= maxLength } ?: cut(SHORT_URLS.last(), maxLength)

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.

An empty URL field with maxlength 10 is filled with http://x.i. That is ten characters, and the URL check rejects it. ftp://x.io is also ten characters and passes. The form is shown again with a success status, and the save is never traced. Use a link that both fits and passes the URL check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a122fe1, though not with ftp://x.io. As @matrei pointed out, x.io is a registered domain with live mail and web servers, so any short value on it could send a traced save's mail or link fetch to a third party. Every value now stays on example.com. The fallbacks are https://example.com, http://example.com and ftp://example.com, so 17 is the shortest URL field that gets a link. Below that the field gets the traced name, which fails the url constraint, so that save isn't traced. I don't cut values anymore, because a cut value can name another host (ftp://example.co).

The spec now runs every filled value through the validator the url constraint builds and through the default UrlValidator. It also checks that the fields too short for a link fail.

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

This an AI assisted review

Reviewed at 76dc3617b0, base 9.0.x. The earlier reviews covered the merge itself in depth, so this one checks what changed after them.

What I checked:

  • The tree: 9.1.x is an ancestor of the head. The head differs from 9.1.x in 22 files: the four version files, the 19 @since 9.0 tags (55cfcda58f), the guide section number (1d04adf7c5), the GPathResultCollectionDataBindingSource change taken from #16938, and the TraceNativeMetadataTask fix (76dc3617b0). grails-databinding-core is identical to 9.0.x.
  • Base: 9.0.x has moved by one commit since the head (a74c662ef0, #16941, which changes only .github/workflows/gradle.yml). It still merges with no conflicts.
  • Merge-back recipe: I ran the recipe from the description in a scratch worktree on the head: git show a1a2265c9f | git apply -R --index. It applies cleanly and touches only gradle.properties, GrailsUtilTests, THREAT_MODEL.md and threat-model.yaml. The 9.1.x tree that results keeps the @since 9.0 tags, the guide numbering, the #16938 change and the traceNativeMetadata fix, as intended.
  • CI: 90 checks pass. The only failures are the three Groovy Snapshot Canary shards, which also fail on the 9.0.x tip.

1. A URL field with maxlength 10 still gets a URL that fails validation

This is the issue @jamesfredley raised at TraceNativeMetadataTask.groovy:682, and it is still there at 76dc3617b0. When no entry in SHORT_URLS fits, link() cuts the last entry, so a field with maxlength 10 gets http://x.i. A URL validator rejects that because i is not a TLD. The form is shown again with a 200, and the trace reads it as a save that was never recorded. That is the failure the commit set out to fix.

ftp://x.io is 10 characters and passes the default UrlValidator schemes (http, https, ftp). Adding it as the last entry of SHORT_URLS would cover maxlength 10. Below 10, no valid URL fits, so cutting the value is the right fallback there.

The typed-form spec tests URL maxlength 24, 19, 12 and 5, which leaves this case out. A maxlength="10" URL field in TracedRunFixture, checked against a valid URL, would cover it.

2. The description no longer lists everything the head adds to 9.1.x (minor)

The description says that after this PR "the 9.0.x tree is the 9.1.x tree except for the four version files and those three commits". 76dc3617b0 now also changes TraceNativeMetadataTask and its spec and fixture. Please add it to the list, so whoever does the merge back knows the full set of changes 9.1.x receives.

Once 1 is fixed, this looks good to me. Please merge with a merge commit, as the description asks, so that 9.1.x stays an ancestor of 9.0.x.

matrei
matrei previously requested changes Nov 13, 2026

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

Second review of #16937 at 76dc3617b0 (base 9.0.x). Since my first review there are four new commits: the merge of 9.0.x for #16938 (91e777a66f), 55cfcda58f, 1d04adf7c5 and 76dc3617b0. It merges cleanly with the current tip, a74c662ef0.

@codeconsole, thanks. Everything from my first review is fixed:

  • All 19 tags are @since 9.0, in their own commit. No later commit touches the four version files, so reverse-applying a1a2265c9f still restores only the versions.
  • The sections of the Grails 9 upgrade guide now run from 1 to 23.
  • The description has the merge-back recipe, and you've agreed not to cut a 9.0.0 milestone or RC before the Boot 4.2.0 bump.

What I checked and ran:

  • The merge of 9.0.x: git show --remerge-diff 91e777a66f shows a single conflict, in GPathResultCollectionDataBindingSource, resolved with the 9.0.x side. grails-databinding-core is identical to 9.0.x.
  • The tree: the head differs from 9.1.x in 22 files: the four version files, the 13 files with the @since tags, the guide, the #16938 fix and its spec, and TraceNativeMetadataTask with its spec and fixture.
  • The values 76dc3617b0 fills in: I checked them against commons-validator 1.10.1, the version behind Grails' email and url constraints. I used both the UrlValidator that the url constraint builds (ALLOW_ALL_SCHEMES) and the default one. I also looked up the DNS records of the domains involved.
  • CI on this head: every build and test job passes, including "Build Gradle Plugins" on Java 21 and 25. The three Groovy Snapshot Canary shards fail, as they do on 9.0.x.

1. The short email and URL values point at a real domain

x.io is a registered domain that receives mail: it has an A record and Google Workspace MX records. example.com is reserved for examples (RFC 2606) and publishes a null MX (RFC 7505), so mail to it is never delivered. The trace submits forms to the running application. If an application sends mail on a save, as a sign-up or contact form does, an email field with maxlength 6 to 12 now sends it to an address on x.io, such as 12345@x.io at 10, which reaches a real mail server. A URL field with maxlength 11 to 18 now gets http://x.io, a live host, if the application fetches the link. ftp://x.io, suggested for maxlength 10, would add another link to that host.

None of the reserved names fits in less space. With commons-validator 1.10.1, 1@x.test, 1@x.example, 1@x.invalid and 1@localhost are all rejected, and so are http://x.test, http://x.invalid and http://localhost. The shortest values that stay on example.com and pass are 1@example.com (13 characters) and ftp://example.com (17).

I'd stay on example.com:

private static final List<String> SHORT_URLS = ['https://example.com/', 'https://example.com', 'http://example.com', 'ftp://example.com']

Below those lengths, I'd send the traced name alone, traced(maxLength), rather than cutting a value. It has no @ and no scheme, so neither validator accepts it, and it names no host. Cutting is not safe: 1@example.com cut to 12 is 1@example.co, and ftp://example.com cut to 16 is ftp://example.co. Both pass validation, and example.co is a registered domain with MX records.

A save behind an email field shorter than 13 characters, or a URL field shorter than 17, then isn't traced. That seems a better trade than sending mail to a third party.

2. http://x.i fails the url constraint

I can confirm what @jamesfredley and @sbglasius found:

Value Length url constraint Default UrlValidator
http://x.i (maxlength 10 today) 10 rejected rejected
ftp://x.io 10 accepted accepted
http://x.io 11 accepted accepted
ab://x.io 9 accepted rejected
a://x.io 8 accepted rejected

Because the constraint accepts any scheme, "below 10 no valid URL fits" holds only for the default validator. With 1, this case goes away: maxlength 10 is below 17, so the field gets the traced name and fails on purpose.

3. Smaller points

  • Tests: the typed-form feature checks the shape and the length of each value, not whether a validator accepts it. That is how http://x.i got through. Passing the email and URL values through EmailValidator.getInstance() and the UrlValidator the url constraint builds would catch this kind of mistake. That needs commons-validator as a test dependency of the plugins project.
  • Guide: deploymentAot.adoc:150 says an email field gets "an address", and only a text field is said to stay within maxlength. URL fields aren't mentioned. Now that both kinds of field honor maxlength, something like "an address or a link on example.com for an email or URL field, ... each no longer than the field's maxlength" would describe it. Naming the domain also tells readers where a traced save's mail would go.
  • Description: +1 to @sbglasius: "except for the four version files and those three commits" should also mention 76dc3617b0, so that whoever merges back into 9.1.x knows what it receives.

Verified as correct

  • Email values on example.com: from maxlength 13, the address is a tail of the traced name on @example.com, for example 1@example.com at 13. Every such value passes EmailValidator.
  • URL values on example.com: from 20 characters up the link is https://example.com/…, and at 19 it is https://example.com. All of these pass both validators.
  • Merge-back recipe: a1a2265c9f is unchanged, and none of 55cfcda58f, 1d04adf7c5 or 76dc3617b0 touches its four files. The recipe in the description still restores only the 9.1.x version values.

The short fallbacks, @x.io and http://x.io, are a registered domain with
live mail servers, so a traced save that sends mail could deliver it to a
third party, and http://x.i, cut to fit a maxlength of 10, fails the url
constraint. An email is now sent on example.com from maxlength 13, and a URL
is one of https://example.com/..., https://example.com, http://example.com
or ftp://example.com, the longest that fits. Below those lengths the traced
name is sent alone, which no validator accepts and which names no host,
since a cut value can name another domain (1@example.co).

The spec checks each value with the validators behind the email and url
constraints, from commons-validator as a test dependency, and the guide says
which domain a traced save uses and which short fields it does not trace.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei @sbglasius thanks, everything from both reviews is fixed in a122fe1.

The domains (matrei 1, 2; sbglasius 1). I went with matrei's approach. Every email and URL value now stays on example.com:

  • Email fields of maxlength 13 or more get a tail of the traced name followed by @example.com, for example 1@example.com at 13.
  • URL fields of maxlength 21 or more get a path under https://example.com/.
  • Shorter URL fields get the longest of https://example.com/, https://example.com, http://example.com and ftp://example.com that fits.
  • Below 13 for an email, or below 17 for a URL, the field gets the traced name alone. It has no @ and no scheme, so it fails both constraints and that save isn't traced.

No value is cut anymore. @x.io and http://x.io are removed.

This differs from sbglasius's ftp://x.io suggestion for maxlength 10. A value there that passes would have to be on someone else's domain.

Tests (matrei 3). commons-validator is now a test dependency of the plugins project. Its version is read from the root gradle.properties, so it matches grails-datamapping-validation, and it is declared with transitive = false.

The typed-form feature now checks:

  • every email value against EmailValidator;
  • every URL value against the validator the url constraint builds (ALLOW_ALL_SCHEMES + ALLOW_2_SLASHES) and the default UrlValidator;
  • that each of these values is on example.com;
  • that the maxlength 12 email field and the maxlength 16 URL field, which are too short, fail.

The fixture has fields at 13 and 12 for email, and at 24, 19, 18, 17 and 16 for URL.

Guide (matrei 3). deploymentAot.adoc now says that email and URL fields get an address or a link on example.com, each no longer than the field's maxlength, and which short fields are left untraced.

Description (matrei 3; sbglasius 2). It now lists 76dc3617b0 and a122fe1f4f with the other commits that 9.1.x receives in the merge back.

I ran the full :grails-gradle-plugins:test locally, with no failures, plus CodeNarc and Checkstyle for the module. a122fe1f4f doesn't touch the four version files, so reverse-applying a1a2265c9f still restores only the versions.

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

This an AI assisted review

Approving at a122fe1f4f. Both points from my first review are fixed, and keeping every value on example.com is the right call. One non-blocking nit: grails-gradle/plugins/build.gradle could use $commonsValidatorVersion directly, since org.apache.grails.buildsrc.properties already loads the root gradle.properties.

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

Third review of #16937 at a122fe1f4f (base 9.0.x). There is one new commit since my second review. It still merges cleanly with the 9.0.x tip, a74c662ef0.

@codeconsole, thanks. Everything from my second review is fixed:

  • Every email and URL value stays on example.com: an address from maxlength 13, and a link from 17. Below that the field gets the traced name, and no value is cut anymore.
  • The typed-form feature runs every address and link through EmailValidator, through the UrlValidator the url constraint builds, and through the default UrlValidator. It checks that each is on example.com and that the two fields too short for one fail. The fixture has fields on both sides of each boundary, at 13 and 12 for email and at 17 and 16 for URL.
  • deploymentAot.adoc names example.com and says which fields aren't traced.
  • The description lists 76dc3617b0 and a122fe1f4f with the commits that 9.1.x receives.

What I checked and ran:

  • The values: I ported traced, address and link to Java and ran them with no maxlength and with every maxlength from 0 to 30, against commons-validator 1.10.1. Every value that passes is on example.com, every value that fails has no @ and no scheme, and none is longer than its field.
  • The spec: TraceNativeMetadataTaskSpec passes locally on Java 21, all 30 tests.
  • The test dependency: commons-validator resolves at 1.10.1, the version grails-datamapping-validation uses. I changed commonsValidatorVersion in the root gradle.properties as a test. The grails-gradle build then discarded its configuration cache entry and resolved the new version, so a later bump won't be missed.
  • The tree: the head differs from 9.1.x in 24 files: the 22 from my second review, plus deploymentAot.adoc and grails-gradle/plugins/build.gradle.
  • Merge-back recipe: a122fe1f4f doesn't touch the four version files, and git show a1a2265c9f7 | git apply -R --check passes on the head.
  • CI on this head: "Build Gradle Plugins" passes on Java 21 and 25. The three Groovy Snapshot Canary shards fail with the same compile errors as in my first review. The rest was still running when I posted this.

Optional: say when a field is too short to trace

The guide says a form that answers with itself "looks to the trace like a save that succeeded". So when an email field is shorter than 13 characters, or a URL field shorter than 17, the trace finishes as usual, but that save is not traced. Nothing in the output says so. A logger.warn in submit would tell the developer to give the field a value in nativeMetadata.forms. It would name the form and the field, as the warning for a value naming a missing field does, and it would skip a field that already has a value from the query string. This isn't a blocker, and it can be a follow-up.

From my side this is ready once CI passes. In the weekly meeting we also decided to get input from @jdaugherty before 9.1.x is merged into 9.0.x, so please hold the merge until we have it.

An email field shorter than 13 characters, or a URL field shorter than 17,
is sent text it rejects, so the page answers with the form again and the
trace finishes as if the save had been recorded. The trace now warns, naming
the form and the field and how to give it a value in nativeMetadata.forms. A
field given a value there is not warned about.

The commons-validator test dependency takes its version from the shared root
property, which the build's properties plugin already loads.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@matrei @sbglasius thanks for the approvals. I've done both of your optional points in e2664a5:

  • The warning (matrei): when an email field shorter than 13 or a URL field shorter than 17 is filled with text it rejects, traceNativeMetadata now logs a warning. It names the form and the field and shows how to give the field a value, for example /typed?tinyHomepage=... in nativeMetadata.forms. A field that was given a value in the query string isn't warned about. The typed-form feature checks both: the URL field is warned about, and the email field, which now gets its value from the query string, isn't. The guide mentions the warning.
  • The version property (sbglasius): grails-gradle/plugins/build.gradle now uses $commonsValidatorVersion directly, and I removed the code that read the root gradle.properties itself.

The full :grails-gradle-plugins:test run, CodeNarc and Checkstyle all pass locally. The description now lists the commit. I'll hold the merge until @jdaugherty has weighed in, as agreed at the meeting.

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

Not approving. A field too short for an address is still sent text it rejects, and that answer is recorded as a traced save. Digest reauthentication also clears the password on the cached user.

}
// A field too short for any address or link on example.com is sent text it rejects, so the page answers
// with the form again, which reads as a save that succeeded. Said here, as nothing after this can tell.
(tooShortFields(markup) - given.keySet()).each { String name ->

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.

An empty URL field with maxlength 10 is filled with traced- and three digits. That fits, and the URL check rejects it. The warning says the save is not traced, then the submit still runs. The form comes back with status 200, and that status is recorded as a successful trace. Do not submit a field that cannot hold a valid address, and do not record that response as a trace.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e164d65. A form with an email field shorter than 13 or a URL field shorter than 17 is no longer submitted. Its answer is recorded as not traced, so the trace fails with FORM /typed -> not submitted: tinyHomepage too short for an address or link on example.com. Give each a value in nativeMetadata.forms, as in /typed?tinyHomepage=.... A field given a value in forms doesn't count. The spec checks that the trace fails, that the message names only the field without a value, and that nothing was posted. The guide now says the trace fails, not that it warns.

if (authentication.authenticated && (!alwaysReauthenticate || authentication.credentials == null)) {
return authentication
}
Authentication authenticated = authenticationManager.authenticate(authentication)

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.

Digest authentication is on, and users are cached. This authenticates the cached principal. The authentication manager erases its credentials, and the cache holds that same instance, so the password is gone. The next digest request misses the cache and loads the user again. Authenticate a copy, and leave the cached user with its password.

@codeconsole codeconsole Nov 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and fixed in 1c493e2, at the cache rather than by copying in the interceptor. A copy there wouldn't help. With cacheUsers, the digest filter and daoAuthenticationProvider share userCache, and that cache stored users by reference. The provider looks the user up by name and returns the cached instance as the principal, so the authentication manager erased the cached user whatever token it was given.

The cache now stores users by value, which is the JCache default. Each lookup gets a serialized copy (UserDetails is Serializable), and the manager erases that copy as usual. The cached user keeps its stored hash, and the user in the security context and the session holds no password. This also fixes form sign-in with cacheUsers, which erased the cached user in the same way. A user that can't be serialized is left out of the cache with a warning, so its sign-in still works.

An intermediate fix, 22fc54c, skipped erasing a principal the cache holds. I replaced it because that left the hash on the session's user. DigestDaoAuthenticationProvider is back to how it was before.

Tests:

  • CachedDigestAuthenticationSpec (cacheUsers on, real filter chain): the cached user keeps its hash after a Digest sign-in; a second sign-in is answered from the cache, with one load for both; the signed-in user has no password while the cached one keeps it. The first two failed before the fix.
  • SpringUserCacheFactoryBeanSpec, against Ehcache: erasing a looked-up user leaves the cached one intact; a GrailsUser copy keeps its class, id and authorities; a user that can't be serialized isn't cached and doesn't throw.
  • All :grails-spring-security tests pass.

The Grails 9 upgrade guide has a new section 24 on the change, including how to go back to storing by reference. The upgrade skill and the plugin's user cache docs are updated too.

This code came in from 9.1.x (#16871), so the merge back fixes it there too.

With cacheUsers, DigestAuthenticationFilter and the daoAuthenticationProvider
share the user cache, which holds users by reference. The security interceptor
authenticates what the filter leaves on every request, the provider returns the
cached user as the principal, and the authentication manager erases the
credentials of what it returns, so the cached user lost its stored password.
The next request then missed the cache and loaded the user again.

DigestDaoAuthenticationProvider now returns a token whose erased credentials
leave a principal the user cache holds alone. Its own credentials are still
erased, and a principal nothing else holds is erased as before. Copying the
token the interceptor authenticates would not help, as the provider looks the
user up in the cache by name.
A form with an email field shorter than 13 characters, or a URL field shorter
than 17, was submitted with text the field rejects, after a warning. The page
answered with the form again and a 200, which was recorded as a traced save.

Such a form is no longer submitted. Its answer is recorded as not traced, so
the trace fails, naming each field and how to give it a value in
nativeMetadata.forms. A field given a value there does not count.
…ed-in user

22fc54c kept the cached user's password by not erasing a principal the
user cache holds, which left the stored password, or Digest's hash, on the
user in the security context and the session.

The user cache stored users by reference, so the cached user, the principal
the provider returned and the one in the session were the same instance, and
the password was either erased from the cache or kept in the session. The
cache now stores users by value, as JCache does by default: each lookup gets a
serialized copy, the authentication manager erases that copy as usual, and the
cached user keeps what it is checked against. This also stops a form sign-in
with cacheUsers from erasing the cached user. A user that cannot be serialized
is left out of the cache with a warning rather than failing the sign-in.

DigestDaoAuthenticationProvider no longer needs its own token, and is back to
what it was before 22fc54c. The upgrade guide, the Grails 9 upgrade skill
and the plugin's user cache docs describe the change.
Storing cached users by value (1c493e2) makes Ehcache create its key
copier, Eh107IdentityCopier, reflectively when the user cache is created,
and copy each user by Java serialization. No library ships metadata for
either, so a native image with cacheUsers failed at startup, and with the
copier registered every sign-in failed: a class without serialization
metadata raises MissingReflectionRegistrationError, a LinkageError the
cache's catch of RuntimeException let through.

SpringSecurityRuntimeHints now registers the copier's constructor and, for
serialization, the users the plugin creates: User and GrailsUser with a Long
id, SimpleGrantedAuthority, and the sorted set User keeps them in. The user
cache also catches a LinkageError, so a user of the application's own that
an image cannot copy is left uncached with a warning, as on the JVM.

A spec serializes a GrailsUser and checks every class written is registered,
so a change in what Spring Security writes fails on the JVM. Verified in a
native image whose trace had no sign-in, with the traced copier entry
removed: it starts, five Digest sign-ins load the user once, and a user
holding an unregistered type is warned about and still signs in.
The UI plugin's user cache page reads userCache.cache.nativeCache.
SpringCacheBasedUserCache keeps its cache in a private field with no
getter, which Groovy reached only on that class itself, so with the
cache's subclass from 1c493e2 the page no longer found it, and
DefaultSecurityInfoSpec and ExtendedSecurityInfoSpec failed. The subclass
now exposes the cache as a property.
@testlens-app

testlens-app Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

🚨 All tests passed but jobs failed 🚨

Failed Jobs without Test Failures

❌ Groovy Snapshot Canary Build / Build Grails (shard 0)
❌ Groovy Snapshot Canary Build / Build Grails (shard 1)
❌ Groovy Snapshot Canary Build / Build Grails (shard 2)

🏷️ Commit: e7e2c17
▶️ Tests: 136440 executed
⚪️ Checks: 100/100 completed

Rerun Controls

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app/docs.

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

A field too short for an address is no longer submitted or recorded as a trace. The cache stores a copy, so digest reauthentication does not clear the cached password.

This branch has not been deployed

No deployments
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.

5 participants