Skip to content

Validate dynamic finder and HQL list sort property names - #16333

Open
jamesfredley wants to merge 6 commits into
8.0.xfrom
fix/dynamic-finder-sort-validation
Open

Validate dynamic finder and HQL list sort property names#16333
jamesfredley wants to merge 6 commits into
8.0.xfrom
fix/dynamic-finder-sort-validation

Conversation

@jamesfredley

Copy link
Copy Markdown
Contributor

Same-repo continuation of #16312. Head is apache/grails-core:fix/dynamic-finder-sort-validation (includes jdaugherty's follow-up). The previous PR used the archive fork as head, which is not allowed.

Summary

ASF security review follow-up: validate sort names in DynamicFinder and the Hibernate HQL list builder.

Book.list(sort: params.sort) and HqlListQueryBuilder interpolated the sort key into query / HQL order-by clauses. A client-supplied value such as name, e.id or a non-property token was not checked against the persistent mapping.

This is hardening, not an advisory, unless the threat model is expanded. Apps that concatenate HQL themselves remain out of scope.

Changes

  • DynamicFinder rejects sort keys that are not identifier-shaped property paths, and when a mapping is available, keys that do not resolve to a persistent property (including nested associations and identity).
  • HqlListQueryBuilder applies the same property-path check, requires the property to exist on the Hibernate mapping, and only allows asc / desc.

Testing

  • :grails-datamapping-core:test
  • :grails-data-hibernate7-core:test --tests org.grails.orm.hibernate.query.HqlListQueryBuilderSpec
  • codeStyle on those modules

jamesfredley and others added 2 commits September 3, 2026 17:35
Sort keys from list(sort:) and Hibernate HQL list queries were interpolated
without checking that they were identifier-shaped persistent properties.
Reject injected or unknown sort names, and only allow asc or desc for HQL
sort direction.
Fixes the regression and the gaps found in review of the dynamic finder
and HQL list sort validation:

- Sorting by a criteria or where-query alias regressed: the validator
  rejected any first path segment that is not a persistent property,
  which is exactly what an alias is, so WhereQueryWithAssociationSortSpec
  failed in both Hibernate modules. A first segment that is not a
  persistent property is now accepted on the shape check alone and left
  to the query implementation; segments beneath a known property must
  still resolve through the mapping.
- fetch keys in HqlListQueryBuilder were still concatenated into HQL
  unchecked and are now validated the same way as sort keys.
- Composite identities are recognised explicitly instead of relying on
  the by-name property map, and the identity fallback no longer depends
  on the segment position.
- validateSortProperty is private, the duplicated pattern is replaced by
  a shared NameUtils.isValidPropertyPath that follows Java identifier
  rules ($ and non-ASCII letters), and messages no longer echo the
  untrusted value or distinguish malformed from unknown names.
- Blank sort properties are rejected instead of producing an empty
  order-by part, and sort directions are trimmed before checking.
- The BuildableCriteria overload takes each sort map entry's direction
  from its value with the same asc fallback as applySortForMap, and the
  entity resolution helper is reused for the identity default.

SortArgumentValidationSpec exercises every entry point against real
Hibernate mappings: identity and version, inherited, embedded and
association paths, composite identities, the mapping default sort,
alias roots, map directions, and the rejected sort, order and fetch
values. The datamapping-core, NameUtils and mock builder specs cover
the remaining branches. The list() reference and finder docs describe
the accepted values.
Copilot AI lite review requested due to automatic review settings September 9, 2026 14:30

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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Hardens GORM sorting by validating caller-supplied sort property paths (and Hibernate list sort directions) to prevent query/HQL order-by interpolation with non-property tokens.

Changes:

  • Added NameUtils.isValidPropertyPath plus unit tests to validate identifier-shaped dotted property paths.
  • Updated DynamicFinder to validate sort keys (including traversing mapped associations/identities when mapping metadata is available).
  • Updated Hibernate HqlListQueryBuilder to require mapped sort/fetch properties and to normalize/validate sort direction; added/expanded specs and documentation.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
grails-doc/src/en/ref/Domain Classes/list.adoc Documents new validation rules for sort/order/fetch in list() arguments.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/NameUtils.java Adds isValidPropertyPath for identifier-shaped dotted property paths.
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/NameUtilsSpec.groovy Adds coverage for valid/invalid property-path shapes.
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java Validates sort keys against shape + mapping traversal (assoc/identity handling) and centralizes order building.
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy Expands coverage for sort acceptance/rejection (including mapping traversal and composite ids).
grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java Requires mapped sort/fetch properties and strictly normalizes/validates order to asc/desc.
grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilderSpec.groovy Adds tests for rejecting injected sort keys/directions and invalid fetch keys.
grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/SortArgumentValidationSpec.groovy New integration-style spec exercising validation against real Hibernate mappings across entry points.
grails-data-hibernate7/docs/src/docs/asciidoc/querying/finders.adoc Documents sort-path validation and alias behavior for Hibernate 7 finders.
grails-data-hibernate5/docs/src/docs/asciidoc/querying/finders.adoc Documents sort-path validation and alias behavior for Hibernate 5 finders.

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

Comment thread grails-doc/src/en/ref/Domain Classes/list.adoc
@jdaugherty
jdaugherty requested a balanced review from Copilot September 9, 2026 15:21
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.87611% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.1275%. Comparing base (e9cd005) to head (a7647b2).

Files with missing lines Patch % Lines
...g/grails/datastore/gorm/finders/DynamicFinder.java 74.1936% 10 Missing and 6 partials ⚠️
...orm/hibernate/query/GrailsHibernateQueryUtils.java 56.2500% 7 Missing ⚠️
...ernate/query/AbstractHibernateCriteriaBuilder.java 75.0000% 0 Missing and 1 partial ⚠️
...ails/datastore/gorm/finders/ListOrderByFinder.java 66.6667% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16333        +/-   ##
==================================================
+ Coverage     55.0810%   55.1275%   +0.0465%     
- Complexity      20804      20842        +38     
==================================================
  Files            2111       2111                
  Lines          101378     101443        +65     
  Branches        18005      18016        +11     
==================================================
+ Hits            55840      55923        +83     
+ Misses          37490      37478        -12     
+ Partials         8048       8042         -6     
Files with missing lines Coverage Δ
...ails/data/testing/tck/tests/ListOrderBySpec.groovy 100.0000% <100.0000%> (ø)
...rg/grails/datastore/mapping/reflect/NameUtils.java 86.3636% <100.0000%> (+8.5859%) ⬆️
...ernate/query/AbstractHibernateCriteriaBuilder.java 38.3866% <75.0000%> (+0.3113%) ⬆️
...ails/datastore/gorm/finders/ListOrderByFinder.java 86.2069% <66.6667%> (ø)
...orm/hibernate/query/GrailsHibernateQueryUtils.java 38.5366% <56.2500%> (+8.1883%) ⬆️
...g/grails/datastore/gorm/finders/DynamicFinder.java 80.1339% <74.1936%> (+3.3953%) ⬆️

... and 2 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.

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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Suppressed comments (1)

grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java:92

  • Casting sort to Map<String, String> can throw ClassCastException at runtime when callers supply a map with non-String values (common with request params or Groovy GString values). Treat the map as Map<?, ?> (or Map<String, ?>) and convert prop/direction via toString() (with null-handling) before calling buildSortPart so invalid input fails with the intended IllegalArgumentException instead of a cast error.
        } else if (sort instanceof Map) {
            List<String> parts = new ArrayList<>();
            ((Map<String, String>) sort).forEach((prop, direction) -> {
                parts.add(buildSortPart(prop, direction, isIgnoreCase));
            });

Comment on lines 59 to 65
Map<String, Object> fetchMap = (Map<String, Object>) fetchObj;
fetchMap.forEach((prop, type) -> {
if (HibernateQueryArgument.JOIN.value().equals(type) || HibernateQueryArgument.EAGER.value().equals(type)) {
requireMappedProperty(prop, HibernateQueryArgument.FETCH.value());
hql.append(" join fetch e.").append(prop);
}
});
Comment thread grails-doc/src/en/ref/Domain Classes/list.adoc
DynamicFinder.buildOrder treated any order other than exact
equalsIgnoreCase("desc") as ascending, so values like " DESC "
silently sorted ascending. Trim whitespace, accept asc/desc
case-insensitively, and reject anything else with
IllegalArgumentException("Invalid sort direction"), matching
HqlListQueryBuilder.normalizeDirection.

Assisted-by: opencode:xai/grok-4.6

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

Review Findings (round 2)

Head 42e951fc1c on 8.0.x base 0980623481. This is the same-repo continuation of #16312 and carries the earlier follow-up (035f9197) plus the direction fix 91885f07ed from the Copilot thread. The branch is at the tip of 8.0.x and git merge-tree is clean. This round is a status check of the round-1 findings on #16312 plus a fresh pass over the entry points the direction change is meant to cover.

Re-ran on this head with --continue, including codeStyle on grails-datastore-core, grails-datamapping-core and grails-data-hibernate7-core: no violations.

Module Tests Failures Skipped
grails-datastore-core (NameUtilsSpec) 36 0 0
grails-datamapping-core 962 0 1
grails-data-hibernate7-core 3132 0 29
grails-data-hibernate5-core 838 0 39

The TestLens failure on CI is UserControllerSpec > User list in the scaffolding example, tracked as flaky in #16030 and unrelated to these files.

Round-1 status

Round-1 finding Status
Direction trimming differs between entry points Fixed in 91885f07ed. DynamicFinder.normalizeDirection now mirrors the HQL builder, and the behaviour is pinned for list(), dynamic finders and where queries in DynamicFinderCoverageSpec and SortArgumentValidationSpec. Note that the fix goes further than the nit asked for: a direction other than asc/desc now throws IllegalArgumentException on finders and where queries where it used to sort ascending silently. That is the right call, but it is a user-visible change and belongs in the PR description.
Alias pass-through is wider than it needs to be Not addressed and not answered. A single-segment sort key that is not a property still reaches Hibernate and fails with the raw NPE rather than Invalid sort property, see below. Still non-blocking, but it deserves an explicit yes or no.
fetch wording in list.adoc is Hibernate 7 specific Not addressed. Fine for the 8.0.x guide, but the scope is wider than fetch: Hibernate 5 list() goes through GrailsHibernateQueryUtils.populateArgumentsForCriteria (grails-data-hibernate5/core/.../query/GrailsHibernateQueryUtils.java:128-141), which validates neither sort names nor direction, so the whole new paragraph in list.adoc describes Hibernate 7 only.
PR description Still describes the original behaviour ("keys that do not resolve to a persistent property" are rejected). It should mention the first-segment pass-through for aliases, the sort-map direction change, and the new direction rejection.

Confirmed on this head, with a throwaway spec against the real Hibernate 7 mapping (SavClub), so the figures below are observed, not inferred:

findAllByNameLike('%', [sort: 'notAProperty'])   NPE: Expression.getJavaType() because "expression" is null
where { name != null }.list(sort: 'notAProperty') NPE (same)
createCriteria().list(sort: 'notAProperty') { }   NPE (same)

Findings on this head

None of these block the merge. Every one is pre-existing behaviour on an entry point the PR did not touch, but two of them contradict what the Copilot thread was closed with ("matching Hibernate normalization" across entry points), so they should either be folded in, which is small, or filed as a follow-up and referenced from the PR.

Low: the criteria builder is the one entry point left out

References:

  • grails-data-hibernate7/core/src/main/groovy/grails/orm/CriteriaMethodInvoker.java:118-133
  • grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/query/AbstractHibernateCriteriaBuilder.java:1759-1768 (same shape)

createCriteria().list(sort:, order:) { } does not go through DynamicFinder at all. CriteriaMethodInvoker reads sort and order from the argument map and builds Query.Order directly, so neither validateSortProperty nor normalizeDirection runs:

createCriteria().list(sort: 'name', order: 'sideways') { }*.name  == [Arsenal, United]   (silently asc)
createCriteria().list(sort: 'name', order: ' DESC ') { }*.name    == [Arsenal, United]   (silently asc)
createCriteria().list(sort: 'name, e.id') { }                      NPE from Hibernate

Same result with max: set (the HibernatePagedResultList branch). This is not an injection sink, the value ends up in the JPA Criteria API, but it is exactly the inconsistency Copilot described as "criteria paths silently coerce", and the reply that closed that thread only covers DynamicFinder. SortArgumentValidationSpec exercises criteria only for the alias happy path (line 95), so nothing pins either outcome.

Suggested fix: make normalizeDirection and validateSortProperty reachable (a small public static helper on DynamicFinder, or a new class next to NameUtils) and call them from CriteriaMethodInvoker before constructing the Query.Order, then add criteria rows to the rejection cases in SortArgumentValidationSpec.

Low: listOrderBy* still coerces the direction

References:

  • grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java:89-90
listOrderByName(order: 'sideways')*.name  == [Arsenal, United]   (silently asc)
listOrderByName(order: ' DESC ')*.name    == [Arsenal, United]   (silently asc)

The property name comes from the method name, so no injection concern, only the direction inconsistency. listOrderBy.adoc documents order, so the same expectation applies. One line with the shared helper.

Nit: order on Hibernate 7 list() is only validated when sort is present

References:

  • grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java:80 (buildSortClause)
  • grails-doc/src/en/ref/Domain Classes/list.adoc

normalizeDirection is only reached from buildSortPart, so SavClub.list(order: 'sideways') returns rows in insertion order without an error, and SavClub.list(order: 'desc') is ignored too. A dynamic finder with order alone sorts by the identity and rejects sideways. Pre-existing, but the new sentence in list.adoc ("order must be asc or desc ... any other value is rejected") over-promises for that case. Either normalize order at the top of buildSortClause regardless of sort, or qualify the sentence.

Nit: two identical normalizeDirection copies

References:

  • grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java:843
  • grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java:150

Byte-for-byte the same private method in both modules. If the criteria and listOrderBy items above are taken, one shared helper resolves all three call sites and removes the copy.

Nit: coverage gaps in DynamicFinder

Local Jacoco for grails-datamapping-core shows the following new or touched lines not executed by any spec in the module, which lines up with the 13 missing lines Codecov reports:

  • validateSortProperty entity == null early return (lines 789-790)
  • resolvePersistentEntity detached-criteria and null branches (lines 763-766)
  • composite-identity default sort in the BuildableCriteria overload (lines 457-463)
  • composite-identity lookup in resolveProperty (lines 820-827, covered only from the Hibernate 7 module)

Not blocking, the Hibernate 7 spec covers the composite case end to end. A DynamicFinderCoverageSpec row with a composite-identity entity would close most of it.

Verdict

Approve. The sort-name validation and the direction fix are correct, tested against real mappings, and green across all four modules. Before merge: update the PR description as noted above, and decide whether the criteria-builder and listOrderBy direction handling go into this PR (small, and it would make the "consistent across entry points" claim true) or into a referenced follow-up.

@jdaugherty

Copy link
Copy Markdown
Contributor

@matrei other than updating the PR description, I believe everything is done. I did narrow the path after review since hibernate 7 would error anyhow.

@jdaugherty
jdaugherty requested a review from matrei September 10, 2026 20:45
@testlens-app

testlens-app Bot commented Sep 10, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: a7647b2
▶️ Tests: 73418 executed
⚪️ Checks: 89/89 completed


Learn more about TestLens at testlens.app/docs.

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

Review Findings (round 3)

Head a7647b2731 on 8.0.x base e9cd00567b. New since round 2 is 9cd088f435 ("Validate sort arguments on every query entry point") plus a merge of 8.0.x. The branch is at the tip of 8.0.x.

Re-ran locally on this head with --continue, including codeStyle on all four modules: no violations. Also ran the changed TCK spec against Neo4j (Docker), since grails-data-neo4j-core consumes grails-datamapping-tck and the ListOrderByFinder change is shared by every datastore.

Module Tests Failures Skipped
grails-datastore-core 202 0 0
grails-datamapping-core 968 0 1
grails-data-hibernate5-core 854 0 39
grails-data-hibernate7-core 3149 0 29
grails-data-neo4j-core (ListOrderBySpec) 2 0 0

TestLens on CI is green for this head (73418 tests, 89/89 checks).

Round-2 status

Round-2 finding Status
Criteria builder was the one entry point left out Fixed. Hibernate 7 CriteriaMethodInvoker and Hibernate 5 AbstractHibernateCriteriaBuilder now call validateSortProperty and normalizeDirection before building the order; pinned for paged and unpaged createCriteria().list() in both SortArgumentValidationSpecs and in CriteriaMethodInvokerSpec.
listOrderBy* still coerced the direction Fixed in ListOrderByFinder; pinned in AbstractFinderSpec, both Hibernate specs and the TCK ListOrderBySpec (which also passes on Neo4j).
order on Hibernate 7 list() only validated when sort present Fixed, and applied consistently: HqlListQueryBuilder, the three Hibernate 5 populateArgumentsForCriteria overloads and CriteriaMethodInvoker all normalize order before looking at sort. Pinned with "an order argument is validated even when there is no sort key" on both Hibernate versions.
Two identical normalizeDirection copies Fixed. The HqlListQueryBuilder copy is gone; DynamicFinder.normalizeDirection and validateSortProperty are the single public helpers behind every path.
Alias pass-through wider than needed Addressed: a bare name that is not a persistent property is now rejected, only a dotted key gets the alias pass-through. Pinned for list(), finders, where queries and criteria on both Hibernate versions and in DynamicFinderCoverageSpec.
fetch wording in list.adoc was Hibernate 7 specific Half addressed. The new upgrade-guide section correctly scopes fetch to Hibernate 7, but list.adoc:71 still states unconditionally that "a fetch key must name a persistent property". Nit, see below.
PR description Still the original text. jdaugherty confirmed on the PR that this is the one remaining item. See below for what it needs to say.
Coverage gaps in DynamicFinder Partly closed. The entity == null early return, the resolvePersistentEntity fallback and the bare-name rejection are now covered in-module. The composite-identity branches (DynamicFinder.java:457-463 and 831-837) are still only executed from the Hibernate 7 module. Not blocking.

Hibernate 5 coverage is new in this round and was not on the round-2 list: both populateArgumentsForCriteria overloads in GrailsHibernateQueryUtils and the one in GrailsHibernateUtil now validate sort names and directions, with a dedicated SortArgumentValidationSpec against real Hibernate 5 mappings. Good.

Findings on this head

None block the merge.

Must do before merge: PR description

The description still describes the first iteration only. It should state, since all of these are user-visible changes:

  • order other than asc/desc (trimmed, case-insensitive) now throws IllegalArgumentException("Invalid sort direction") on every entry point, including listOrderBy* and criteria list(), where it used to sort ascending silently. It is checked even when no sort is given.
  • A bare sort name that is not a persistent property is rejected with Invalid sort property; a dotted key whose first segment is not a property is still passed through for aliases.
  • Hibernate 5 is covered as well as Hibernate 7.
  • Link to the new upgrade-guide section (58) so the behaviour change is discoverable.

Low: the criteria DSL order(property, direction) still coerces

References:

  • grails-data-hibernate7/core/src/main/groovy/grails/orm/HibernateCriteriaBuilder.java:1040-1045
  • grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/query/AbstractHibernateCriteriaBuilder.java:1469
  • grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractCriteriaBuilder.java:1046-1052
  • grails-datamapping-core/src/main/groovy/grails/gorm/DetachedCriteria.groovy:242
  • grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteria.groovy:342

These are the last equalsIgnoreCase("desc")-style coercions on a caller-facing method in the touched modules. They are DSL calls rather than argument-map keys, so they are outside what this PR set out to cover, but order(params.sort, params.order) inside a criteria closure is a common Grails idiom, and the new Javadoc on normalizeDirection says it is "shared by every entry point that accepts an order argument". Neither value is interpolated into a string on these paths, so there is no injection concern, only the same silent-ascending inconsistency the PR fixes elsewhere. Suggest a referenced follow-up rather than growing this PR further: one call to DynamicFinder.normalizeDirection in each of the five implementations, plus a row in the two SortArgumentValidationSpecs.

Nit: list.adoc fetch sentence is still unconditional

Reference: grails-doc/src/en/ref/Domain Classes/list.adoc:71

The sort/order half of the paragraph is now true on both Hibernate versions. The fetch half is Hibernate 7 only, as the upgrade guide correctly says ("With Hibernate 7, list() additionally requires ..."). Either qualify it the same way here or drop fetch from this sentence.

Nit: validateSortProperty accepts a bare unknown name when the entity is unknown

Reference: grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java:794-806

With entity == null the check is shape-only, so notAProperty passes. Every public entry point resolves the entity, so this is unreachable from user code and the docs' "a bare name must be a property of the domain class" holds in practice. Worth one sentence in the Javadoc's @param entity line so the next reader does not take the null branch for a hole.

Verdict

Approve. Every round-2 item that touched code is resolved, the Hibernate 5 paths are now covered too, and everything is green locally on all four modules plus the Neo4j TCK. Merge once the PR description is updated; the DSL order() item can go into a follow-up issue referenced from the description.

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.

4 participants