From 5f9a41bf88300c28c3cf5e4955b38fbb439fab9c Mon Sep 17 00:00:00 2001 From: Lovro Date: Tue, 28 Jul 2026 14:07:54 +0200 Subject: [PATCH 1/3] Add tests for nested property paths in Querydsl filtering. Nested paths such as address.street are silently dropped since GH-2572, turning a filtered request into an unfiltered one. A second test pins the visibility guarantee to every segment of a nested path. See GH-2579. Signed-off-by: Lovro --- ...andlerMethodArgumentResolverUnitTests.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolverUnitTests.java b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolverUnitTests.java index 7e6a3843eb..d57d9c5817 100755 --- a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolverUnitTests.java +++ b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolverUnitTests.java @@ -64,6 +64,7 @@ * Unit tests for {@link QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver}. * * @author Oliver Gierke + * @author Lovro Vrlec */ @ExtendWith(MockitoExtension.class) class QuerydslAwareRootResourceInformationHandlerMethodArgumentResolverUnitTests { @@ -180,6 +181,42 @@ void translatesJacksonRenamedPropertyToPersistentPropertyName() { assertThat(forwarded.get("aliased")).containsExactly("value"); } + @Test // GH-2579 + void forwardsNestedPropertyPathsToQuerydsl() { + + Object repository = mock(QuerydslUserRepository.class); + when(repositories.getRepositoryFor(User.class)).thenReturn(Optional.of(repository)); + + Map parameters = Map.of("address.street", new String[] { "value" }); + + ArgumentCaptor> captor = ArgumentCaptor.captor(); + when(builder.getPredicate(any(), captor.capture(), any())).thenReturn(mock(Predicate.class)); + + resolver.postProcess(parameter, invoker, User.class, parameters); + + // Neither User.address nor Address.street is hidden from serialization, so the nested path has to reach Querydsl, + // which resolves dotted paths itself. Dropping it would silently turn a filtered lookup into an unfiltered one. + assertThat(captor.getValue().get("address.street")).containsExactly("value"); + } + + @Test // GH-2579 + void doesNotExposeJsonIgnoredPropertiesNestedInAssociations() { + + Object repository = mock(QuerydslUserRepository.class); + when(repositories.getRepositoryFor(User.class)).thenReturn(Optional.of(repository)); + + Map parameters = Map.of("manager.ignored", new String[] { "candidate-value" }); + + ArgumentCaptor> captor = ArgumentCaptor.captor(); + when(builder.getPredicate(any(), captor.capture(), any())).thenReturn(mock(Predicate.class)); + + resolver.postProcess(parameter, invoker, User.class, parameters); + + // User.manager is visible, but User.ignored is @JsonIgnore-annotated: the guarantee established for top-level + // properties has to hold for every segment of a nested path as well. + assertThat(captor.getValue()).doesNotContainKey("manager.ignored"); + } + interface QuerydslUserRepository extends QuerydslPredicateExecutor {} interface QuerydslProfileRepository extends QuerydslPredicateExecutor {} From 94e579f162cf5b855fd9df4640e89338a1322acd Mon Sep 17 00:00:00 2001 From: Lovro Date: Tue, 28 Jul 2026 14:11:50 +0200 Subject: [PATCH 2/3] Consider nested property paths when filtering Querydsl parameters. Request parameters were matched against a flat map of top-level Jackson field names, so nested paths such as address.street never matched and were dropped, silently turning a filtered request into an unfiltered one. Parameter names are now resolved segment by segment, rejecting the parameter as soon as a segment does not refer to an exposed property, so hidden properties remain unreachable through associations. Closes GH-2579. Signed-off-by: Lovro --- ...ormationHandlerMethodArgumentResolver.java | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.java index c342408cf8..c0b2fa4b3a 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.java @@ -21,6 +21,7 @@ import java.util.Map.Entry; import java.util.Optional; import java.util.function.Function; +import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; @@ -51,11 +52,14 @@ * * @author Oliver Gierke * @author Mark Paluch + * @author Lovro Vrlec * @since 2.4 */ class QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver extends RootResourceInformationHandlerMethodArgumentResolver { + private static final Pattern NESTED_PATH_SEPARATOR = Pattern.compile("\\."); + private final Repositories repositories; private final QuerydslPredicateBuilder predicateBuilder; private final QuerydslBindingsFactory factory; @@ -123,9 +127,7 @@ private Optional, Predicate>> getRepositoryAnd */ private Map filterByJacksonVisibility(Class domainType, Map parameters) { - MappedJacksonProperties properties = jacksonPropertiesLookup.apply(domainType); - - if (properties == null) { + if (jacksonPropertiesLookup.apply(domainType) == null) { return parameters; } @@ -133,16 +135,53 @@ private Map filterByJacksonVisibility(Class domainType, Map for (Entry entry : parameters.entrySet()) { - PersistentProperty property = properties.getPersistentProperty(entry.getKey()); + String path = resolvePath(domainType, entry.getKey()); - if (property != null) { - filtered.put(property.getName(), entry.getValue()); + if (path != null) { + filtered.put(path, entry.getValue()); } } return filtered; } + /** + * Resolves the given request parameter name into the property path Querydsl operates on, applying the visibility + * rules of {@link #filterByJacksonVisibility(Class, Map)} to every segment of a nested path, as Querydsl supports + * filtering on nested properties (e.g. {@code address.street}). Returns {@literal null} if a segment does not refer + * to a property Jackson exposes, so that a hidden property cannot be reached through an association either, or if + * the type owning a segment is not backed by a {@link MappedJacksonProperties} instance, in which case visibility + * cannot be established. + * + * @param domainType the type the leading segment is resolved against, must not be {@literal null}. + * @param parameterName must not be {@literal null}. + * @return the translated property path or {@literal null} if the parameter must not be forwarded to Querydsl. + */ + private @Nullable String resolvePath(Class domainType, String parameterName) { + + StringBuilder path = new StringBuilder(parameterName.length()); + Class owningType = domainType; + + for (String segment : NESTED_PATH_SEPARATOR.split(parameterName)) { + + MappedJacksonProperties properties = jacksonPropertiesLookup.apply(owningType); + PersistentProperty property = properties == null ? null : properties.getPersistentProperty(segment); + + if (property == null) { + return null; + } + + if (!path.isEmpty()) { + path.append('.'); + } + + path.append(property.getName()); + owningType = property.getActualType(); + } + + return path.toString(); + } + @SuppressWarnings("unchecked") private static RepositoryInvoker getQuerydslAdapter(RepositoryInvoker invoker, QuerydslPredicateExecutor repository, Predicate predicate) { From 50e44b8cc19fbf8516999c03a3f73157a64ecbe3 Mon Sep 17 00:00:00 2001 From: Lovro Date: Thu, 17 Sep 2026 10:27:05 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Reject=20blank=20segments=20in=20Querydsl?= =?UTF-8?q?=20parameter=20paths.=20Parameter=20names=20such=20as=20".stree?= =?UTF-8?q?t",=20"address..street"=20or=20"address."=20tripped=20the=20Ass?= =?UTF-8?q?ert.hasText=20guard=20in=20MappedJacksonProperties.getPersisten?= =?UTF-8?q?tProperty(=E2=80=A6)=20and=20surfaced=20as=20an=20IllegalArgume?= =?UTF-8?q?ntException=20for=20client-controlled=20input.=20A=20blank=20se?= =?UTF-8?q?gment=20rejects=20the=20parameter,=20so=20it=20is=20dropped=20l?= =?UTF-8?q?ike=20any=20other=20unknown=20key.=20The=20root=20type's=20Mapp?= =?UTF-8?q?edJacksonProperties=20is=20resolved=20once=20per=20request=20in?= =?UTF-8?q?stead=20of=20once=20per=20parameter,=20as=20the=20lookup=20runs?= =?UTF-8?q?=20Jackson=20introspection=20on=20every=20call.=20resolvePath(?= =?UTF-8?q?=E2=80=A6)=20guards=20its=20arguments=20consistently=20with=20t?= =?UTF-8?q?he=20rest=20of=20the=20class.=20Tests=20cover=20@JsonProperty?= =?UTF-8?q?=20renames=20at=20nested=20level=20and=20rejection=20beneath=20?= =?UTF-8?q?types=20without=20a=20property=20mapping;=20Address=20gains=20a?= =?UTF-8?q?=20renamed=20property=20for=20the=20former.=20See=20GH-2579.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lovro --- .../data/rest/tests/mongodb/Address.java | 1 + ...andlerMethodArgumentResolverUnitTests.java | 67 ++++++++++++++++++- ...ormationHandlerMethodArgumentResolver.java | 42 ++++++++---- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/main/java/org/springframework/data/rest/tests/mongodb/Address.java b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/main/java/org/springframework/data/rest/tests/mongodb/Address.java index 4ed3a300ad..b1428ac319 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/main/java/org/springframework/data/rest/tests/mongodb/Address.java +++ b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/main/java/org/springframework/data/rest/tests/mongodb/Address.java @@ -24,4 +24,5 @@ public class Address { public String street; public @JsonProperty(required = true) String zipCode; + public @JsonProperty("renamed") String aliased; } diff --git a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolverUnitTests.java b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolverUnitTests.java index d57d9c5817..c2bb2fb541 100755 --- a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolverUnitTests.java +++ b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolverUnitTests.java @@ -212,11 +212,74 @@ void doesNotExposeJsonIgnoredPropertiesNestedInAssociations() { resolver.postProcess(parameter, invoker, User.class, parameters); - // User.manager is visible, but User.ignored is @JsonIgnore-annotated: the guarantee established for top-level - // properties has to hold for every segment of a nested path as well. + // User.manager is visible, but User.manager.ignored is @JsonIgnore-annotated: the guarantee established for + // top-level properties has to hold for every segment of a nested path as well. assertThat(captor.getValue()).doesNotContainKey("manager.ignored"); } + @Test // GH-2579 + void translatesJacksonRenamedPropertyNestedInAssociation() { + + Object repository = mock(QuerydslUserRepository.class); + when(repositories.getRepositoryFor(User.class)).thenReturn(Optional.of(repository)); + + // Address.aliased is exposed as "renamed" via @JsonProperty("renamed"). The translation applied to top-level + // properties has to be applied to every segment: the public alias reaches Querydsl under the domain property + // name, the bare Java field name is not accepted. + Map parameters = Map.of( + "address.renamed", new String[] { "value" }, + "address.aliased", new String[] { "ignored" }); + + ArgumentCaptor> captor = ArgumentCaptor.captor(); + when(builder.getPredicate(any(), captor.capture(), any())).thenReturn(mock(Predicate.class)); + + resolver.postProcess(parameter, invoker, User.class, parameters); + + MultiValueMap forwarded = captor.getValue(); + assertThat(forwarded).doesNotContainKey("address.renamed"); + assertThat(forwarded.get("address.aliased")).containsExactly("value"); + } + + @Test // GH-2579 + void rejectsNestedPathsBeneathTypesWithoutJacksonPropertyMapping() { + + Object repository = mock(QuerydslUserRepository.class); + when(repositories.getRepositoryFor(User.class)).thenReturn(Optional.of(repository)); + + Map parameters = Map.of("firstname.length", new String[] { "value" }); + + ArgumentCaptor> captor = ArgumentCaptor.captor(); + when(builder.getPredicate(any(), captor.capture(), any())).thenReturn(mock(Predicate.class)); + + resolver.postProcess(parameter, invoker, User.class, parameters); + + // User.firstname is a String, i.e. not a persistent entity, so there are no MappedJacksonProperties to establish + // the visibility of a segment beneath it. The whole parameter is rejected rather than forwarded unchecked. + assertThat(captor.getValue()).isEmpty(); + } + + @Test // GH-2579 + void dropsParameterNamesWithBlankSegments() { + + Object repository = mock(QuerydslUserRepository.class); + when(repositories.getRepositoryFor(User.class)).thenReturn(Optional.of(repository)); + + Map parameters = Map.of( + "", new String[] { "value" }, + ".street", new String[] { "value" }, + "address..street", new String[] { "value" }, + "address.", new String[] { "value" }); + + ArgumentCaptor> captor = ArgumentCaptor.captor(); + when(builder.getPredicate(any(), captor.capture(), any())).thenReturn(mock(Predicate.class)); + + resolver.postProcess(parameter, invoker, User.class, parameters); + + // A blank segment cannot refer to a property. Being client-controlled input, such parameters are dropped like + // any other unknown key instead of being rejected with an exception. + assertThat(captor.getValue()).isEmpty(); + } + interface QuerydslUserRepository extends QuerydslPredicateExecutor {} interface QuerydslProfileRepository extends QuerydslPredicateExecutor {} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.java index c0b2fa4b3a..b7f184b7c8 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.java @@ -40,6 +40,7 @@ import org.springframework.data.rest.webmvc.RootResourceInformation; import org.springframework.data.rest.webmvc.json.MappedJacksonProperties; import org.springframework.data.util.Pair; +import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -127,7 +128,9 @@ private Optional, Predicate>> getRepositoryAnd */ private Map filterByJacksonVisibility(Class domainType, Map parameters) { - if (jacksonPropertiesLookup.apply(domainType) == null) { + MappedJacksonProperties properties = jacksonPropertiesLookup.apply(domainType); + + if (properties == null) { return parameters; } @@ -135,7 +138,7 @@ private Map filterByJacksonVisibility(Class domainType, Map for (Entry entry : parameters.entrySet()) { - String path = resolvePath(domainType, entry.getKey()); + String path = resolvePath(properties, entry.getKey()); if (path != null) { filtered.put(path, entry.getValue()); @@ -148,24 +151,38 @@ private Map filterByJacksonVisibility(Class domainType, Map /** * Resolves the given request parameter name into the property path Querydsl operates on, applying the visibility * rules of {@link #filterByJacksonVisibility(Class, Map)} to every segment of a nested path, as Querydsl supports - * filtering on nested properties (e.g. {@code address.street}). Returns {@literal null} if a segment does not refer - * to a property Jackson exposes, so that a hidden property cannot be reached through an association either, or if - * the type owning a segment is not backed by a {@link MappedJacksonProperties} instance, in which case visibility - * cannot be established. + * filtering on nested properties (e.g. {@code address.street}). Returns {@literal null} if a segment is blank or + * does not refer to a property Jackson exposes, so that a hidden property cannot be reached through an association + * either, or if the type owning a segment is not backed by a {@link MappedJacksonProperties} instance, in which + * case visibility cannot be established. * - * @param domainType the type the leading segment is resolved against, must not be {@literal null}. + * @param properties the Jackson property mapping of the type the leading segment is resolved against, must not be + * {@literal null}. * @param parameterName must not be {@literal null}. * @return the translated property path or {@literal null} if the parameter must not be forwarded to Querydsl. */ - private @Nullable String resolvePath(Class domainType, String parameterName) { + private @Nullable String resolvePath(MappedJacksonProperties properties, String parameterName) { + + Assert.notNull(properties, "MappedJacksonProperties must not be null"); + Assert.notNull(parameterName, "Parameter name must not be null"); StringBuilder path = new StringBuilder(parameterName.length()); - Class owningType = domainType; + PersistentProperty property = null; + + for (String segment : NESTED_PATH_SEPARATOR.split(parameterName, -1)) { - for (String segment : NESTED_PATH_SEPARATOR.split(parameterName)) { + // The leading segment is resolved against the given properties, every further one against the type of the + // previously resolved segment, which is only backed by a MappedJacksonProperties instance if it is a + // persistent entity itself. + MappedJacksonProperties owner = property == null // + ? properties // + : jacksonPropertiesLookup.apply(property.getActualType()); + + if (owner == null || segment.isBlank()) { + return null; + } - MappedJacksonProperties properties = jacksonPropertiesLookup.apply(owningType); - PersistentProperty property = properties == null ? null : properties.getPersistentProperty(segment); + property = owner.getPersistentProperty(segment); if (property == null) { return null; @@ -176,7 +193,6 @@ private Map filterByJacksonVisibility(Class domainType, Map } path.append(property.getName()); - owningType = property.getActualType(); } return path.toString();