jdaugherty commented on code in PR #16312:
URL: https://github.com/apache/grails-core/pull/16312#discussion_r3931597831
##########
grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java:
##########
@@ -111,13 +114,35 @@ private String buildSortClause() {
}
private String buildSortPart(String propertyName, String direction,
boolean ignoreCase) {
- if (propertyName == null) return "";
- String path = "e." + propertyName;
+ if (propertyName == null || propertyName.isBlank()) {
+ return "";
+ }
+ if (!PROPERTY_PATH.matcher(propertyName).matches()) {
Review Comment:
**The `fetch` keys in this same builder are still concatenated into HQL
unvalidated.** `buildListHql`, line 61:
```java
hql.append(" join fetch e.").append(prop);
```
`prop` is a raw map key, reachable exactly the way `sort` is:
`Book.list(params)` with `?fetch.<injected>=join`.
`GrailsParameterMap.processNestedKeys` turns dotted request parameters into a
nested `Map`, which satisfies the `fetchObj instanceof Map` check, and the
value is attacker-controlled too.
Confirmed against the real builder:
```
input : [fetch: ['books left join fetch e.secretNotes': 'join']]
output: "from Person e join fetch e.books left join fetch e.secretNotes"
```
Pre-existing rather than introduced here, but hardening `sort` while leaving
the sibling sink in the same method leaves the fix incomplete.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java:
##########
@@ -486,7 +490,9 @@ else if (sortObject instanceof Map) {
for (Object key : sortMap.keySet()) {
Object value = sortMap.get(key);
String sort = key.toString();
- final Query.Order order =
ORDER_DESC.equalsIgnoreCase(orderParam) ? Query.Order.desc(sort) :
Query.Order.asc(sort);
+ validateSortProperty(sortEntity, sort);
+ String direction = value != null ? value.toString() :
orderParam;
Review Comment:
This is a behaviour change beyond the stated scope of the PR. `value` was
previously read and discarded, so every entry in a sort map took its direction
from `order`. Now each entry uses its own value, so `list(sort: [name: 'asc'],
order: 'desc')` flips from descending to ascending.
It is probably the correct behaviour -- it matches `applySortForMap` -- but
it is undocumented, untested, and unrelated to sort-name validation. Note also
that the null fallback differs between the two: `orderParam` here, `ORDER_ASC`
in `applySortForMap`.
##########
grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilderSpec.groovy:
##########
@@ -208,4 +208,39 @@ class HqlListQueryBuilderSpec extends Specification {
[offset: 5] | true
[max: 10, offset: 5] | true
}
+
+ void "test buildListHql rejects injected sort property"() {
Review Comment:
These specs run entirely against `Mock(GrailsHibernatePersistentEntity)`, so
the new "throw when `getHibernatePropertyByPath` returns null" rule is never
exercised against a real Hibernate mapping -- which is the part most likely to
regress.
I ran the following against real entities on this branch and they all still
pass, but they are what needs pinning here so a future mapping change cannot
silently break `list()`:
- `list(sort: 'id')` and `list(sort: 'version')`
- a subclass sorted by a property inherited from its superclass
- an embedded path, e.g. `sort: 'address.city'`
- the default sort from `mapping { sort 'label' }`
- an association path, e.g. `sort: 'club.name'`
Also missing: a spec for injected `fetch` keys (see the comment on
`buildSortPart`).
##########
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy:
##########
@@ -251,6 +251,39 @@ class DynamicFinderCoverageSpec extends Specification {
then:
results*.name.sort() == ['Alice', 'Charlie']
}
+
+ void "list(sort) still sorts by a mapped property"() {
Review Comment:
The added coverage stops at the happy path and the two rejection cases. The
new branches in `validateSortProperty` are untested:
- nested association traversal (`sort: 'assoc.prop'` accepted)
- a non-association property mid-path (rejected)
- an association whose `getAssociatedEntity()` is null (rejected)
- the identity fallback (`sort: 'id'`, and `sort: 'assoc.id'`)
- the direction-from-map-value change in `populateArgumentsForCriteria`
For the checklist: the PR ran `:grails-data-hibernate7-core:test --tests
HqlListQueryBuilderSpec` only. A full run of the affected modules is what
surfaces the `WhereQueryWithAssociationSortSpec` failure.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java:
##########
@@ -767,7 +773,55 @@ private static void resetMethodExpressionPattern() {
methodExpressinPattern = Pattern.compile("\\p{Upper}[\\p{Lower}\\d]+("
+ expressionPattern + ")");
}
+ private static PersistentEntity resolvePersistentEntity(BuildableCriteria
query) {
+ if (query instanceof AbstractCriteriaBuilder) {
+ return ((AbstractCriteriaBuilder) query).getPersistentEntity();
+ }
+ if (query instanceof AbstractDetachedCriteria) {
+ return ((AbstractDetachedCriteria) query).getPersistentEntity();
+ }
+ return null;
+ }
+
+ /**
+ * 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.
+ *
+ * @param entity the entity being queried, or {@code null} when it cannot
be resolved
+ * @param sort the requested sort property
+ */
+ public static void validateSortProperty(PersistentEntity entity, String
sort) {
+ if (sort == null || !SORT_PROPERTY_PATTERN.matcher(sort).matches()) {
+ throw new IllegalArgumentException("Invalid sort property: " +
sort);
+ }
+ if (entity == null) {
+ return;
+ }
+ PersistentEntity current = entity;
+ String[] parts = sort.split("\\.");
+ for (int i = 0; i < parts.length; i++) {
+ PersistentProperty prop = current.getPropertyByName(parts[i]);
+ if (prop == null) {
+ PersistentProperty identity = current.getIdentity();
+ if (identity != null && parts[i].equals(identity.getName()) &&
i == parts.length - 1) {
+ return;
+ }
+ throw new IllegalArgumentException("Unknown sort property: " +
sort);
Review Comment:
**Regression: this breaks alias-based sorting, which is a documented
feature.**
The first path segment is resolved against the root entity's persistent
properties, but criteria / where-query aliases are not persistent properties.
Two existing specs fail on this branch:
```
:grails-data-hibernate7-core:test -> 3070 tests, 1 failed
:grails-data-hibernate5-core:test -> 812 tests, 1 failed
WhereQueryWithAssociationSortSpec > Test sort with where query that queries
association FAILED
java.lang.IllegalArgumentException: Unknown sort property: c1.name
at DynamicFinder.validateSortProperty(DynamicFinder.java:809)
at DynamicFinder.addSimpleSort(DynamicFinder.java:824)
at DynamicFinder.populateArgumentsForCriteria(DynamicFinder.java:591)
at
grails.gorm.DetachedCriteria.withPopulatedQuery(DetachedCriteria.groovy:741)
```
This is the pattern
`grails-data-hibernate7/docs/src/docs/asciidoc/querying/whereQueries.adoc` (and
the hibernate5 copy) recommends for sorting on an association:
```groovy
def query = Pet.where {
def o1 = owner
o1.firstName == "Fred"
}.list(sort: 'o1.lastName')
```
`createCriteria().list(sort: 'a.name') { createAlias('author', 'a') }`
breaks the same way.
Suggestion: when the first segment does not resolve to a property, fall back
to the shape check instead of throwing -- `SORT_PROPERTY_PATTERN` already
rejects every injection payload (commas, whitespace, parens, quotes).
Alternatively, collect the aliases declared on the query / detached criteria
and accept those as roots.
Worth weighing the cost/benefit as well: on the paths in this repo
`Query.Order` is never string-concatenated. Hibernate 7 goes through the JPA
Criteria API (`JpaCriteriaQueryCreator.assignOrderBy`) and Hibernate 5 through
`org.hibernate.criterion.Order`, and both resolve the name against the mapping.
So this half is defence in depth for downstream GORM implementations that do
build query strings, rather than a fix for a live sink here.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java:
##########
@@ -767,7 +773,55 @@ private static void resetMethodExpressionPattern() {
methodExpressinPattern = Pattern.compile("\\p{Upper}[\\p{Lower}\\d]+("
+ expressionPattern + ")");
}
+ private static PersistentEntity resolvePersistentEntity(BuildableCriteria
query) {
Review Comment:
This duplicates the inline block a little further up in
`populateArgumentsForCriteria` -- the `sortObject == null && orderParam !=
null` branch does the same two `instanceof` checks to resolve the entity. Worth
calling the new helper there too.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java:
##########
@@ -91,6 +92,7 @@ public abstract class DynamicFinder extends AbstractFinder
implements QueryBuild
public static final String ARGUMENT_IGNORE_CASE = "ignoreCase";
public static final String ARGUMENT_CACHE = "cache";
public static final String ARGUMENT_LOCK = "lock";
+ private static final Pattern SORT_PROPERTY_PATTERN =
Pattern.compile("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*");
Review Comment:
Three notes on the pattern:
- It is byte-for-byte identical to `PROPERTY_PATH` in `HqlListQueryBuilder`.
`grails-data-hibernate7-core` declares `api
project(':grails-datamapping-core')`, so a single shared constant would keep
the two from drifting.
- `[A-Za-z_][A-Za-z0-9_]*` rejects `$` and non-ASCII characters, both legal
in Groovy/Java identifiers and therefore in domain property names.
`Character.isJavaIdentifierStart` / `isJavaIdentifierPart` is the faithful
check.
- It sits in the middle of the `public static final String ARGUMENT_*`
block; moving it above or below keeps that group intact.
##########
grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/HqlListQueryBuilder.java:
##########
@@ -111,13 +114,35 @@ private String buildSortClause() {
}
private String buildSortPart(String propertyName, String direction,
boolean ignoreCase) {
- if (propertyName == null) return "";
- String path = "e." + propertyName;
+ if (propertyName == null || propertyName.isBlank()) {
Review Comment:
Two small edges in this method:
- A blank property name returns `""`. For a `String` sort that is harmless
-- `buildSortClause` returns empty and no `order by` is appended -- but in the
`Map` branch the empty string is still joined, producing `order by , e.name
asc`. Either reject blank alongside the other invalid shapes, or filter empty
parts before `String.join`.
- `normalizeDirection` does not trim, so `order: ' desc'` now throws where
it previously produced working HQL.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java:
##########
@@ -767,7 +773,55 @@ private static void resetMethodExpressionPattern() {
methodExpressinPattern = Pattern.compile("\\p{Upper}[\\p{Lower}\\d]+("
+ expressionPattern + ")");
}
+ private static PersistentEntity resolvePersistentEntity(BuildableCriteria
query) {
+ if (query instanceof AbstractCriteriaBuilder) {
+ return ((AbstractCriteriaBuilder) query).getPersistentEntity();
+ }
+ if (query instanceof AbstractDetachedCriteria) {
+ return ((AbstractDetachedCriteria) query).getPersistentEntity();
+ }
+ return null;
+ }
+
+ /**
+ * 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.
+ *
+ * @param entity the entity being queried, or {@code null} when it cannot
be resolved
+ * @param sort the requested sort property
+ */
+ public static void validateSortProperty(PersistentEntity entity, String
sort) {
Review Comment:
Three smaller points on this method:
- It is only called from within `DynamicFinder`, so `private static` would
keep it off the public surface.
- The messages echo the caller-supplied value and distinguish `Invalid sort
property` from `Unknown sort property`. The value here is untrusted request
input, so a single generic message avoids both reflecting the input back and
letting a client probe which names are real properties.
- Composite-identity entities are not covered: `getIdentity()` returns
`null` for them and `getCompositeIdentity()` is never consulted. It works today
only because the composite id properties are left in `propertiesByName`
(`AbstractPersistentEntity` removes them from `persistentProperties` but not
from the by-name map), so it is leaning on an implementation detail.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]