This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new b96a99e08 WW-5712 Cover Jackson any-setters in REST parameter
authorization (#1889)
b96a99e08 is described below
commit b96a99e0814a195cda9b866ccd5585d97dd1209d
Author: carrerasdarren-cell <[email protected]>
AuthorDate: Thu Sep 10 23:53:15 2026 -0400
WW-5712 Cover Jackson any-setters in REST parameter authorization (#1889)
Fixes [WW-5712](https://issues.apache.org/jira/browse/WW-5712).
This adds opt-in authorization coverage for Jackson any-setters in the REST
plugin:
- adds `@StrutsParameter(allowDynamicKeys = true)` for explicit dynamic-key
consent;
- adds `struts.rest.anySetter.requireAnnotations`, defaulting to `false` for
compatibility;
- applies the consent and `depth()` limit to method and field any-setters;
- rejects creator-parameter any-setters when enforcement is enabled; and
- keeps JSON and XML behavior aligned while preserving existing behavior
when
enforcement is disabled.
Verification:
- 142 REST plugin tests passed;
- 83 focused core parameter-authorization and annotation tests passed;
- REST plugin `verify` and Apache RAT passed; and
- `git diff --check` passed.
---
.../interceptor/parameter/StrutsParameter.java | 11 +
.../org/apache/struts2/rest/RestConstants.java | 1 +
.../rest/config/entities/RestConstantConfig.java | 11 +
.../struts2/rest/handler/JacksonJsonHandler.java | 17 +-
.../struts2/rest/handler/JacksonXmlHandler.java | 18 +-
.../jackson/AuthorizingSettableAnyProperty.java | 266 +++++++++++++++
.../jackson/AuthorizingSettableBeanProperty.java | 4 +-
.../jackson/AuthorizingValueDeserializer.java | 2 +-
.../jackson/DynamicKeyAuthorizationContext.java | 115 +++++++
.../jackson/ParameterAuthorizingModule.java | 31 ++
plugins/rest/src/main/resources/struts-plugin.xml | 1 +
.../ContentTypeInterceptorIntegrationTest.java | 60 +++-
.../jackson/ParameterAuthorizingModuleTest.java | 375 +++++++++++++++++++++
13 files changed, 904 insertions(+), 8 deletions(-)
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/parameter/StrutsParameter.java
b/core/src/main/java/org/apache/struts2/interceptor/parameter/StrutsParameter.java
index 1fbafa3c7..9e936239b 100644
---
a/core/src/main/java/org/apache/struts2/interceptor/parameter/StrutsParameter.java
+++
b/core/src/main/java/org/apache/struts2/interceptor/parameter/StrutsParameter.java
@@ -43,4 +43,15 @@ public @interface StrutsParameter {
* In a practical sense, the depth dictates the number of periods or
brackets that can appear in the parameter name.
*/
int depth() default 0;
+
+ /**
+ * Allows a dynamic property sink, such as a REST JSON or XML any-setter,
to accept parameter
+ * names that do not correspond to declared members on the action.
+ * <p>
+ * This is an explicit opt-in for input channels that support dynamic
keys. Ordinary parameter
+ * injection ignores this flag, although placing {@code @StrutsParameter}
on a field still makes
+ * that field eligible for ordinary query and form parameter injection.
The {@link #depth()} value
+ * limits nesting below each dynamic key.
+ */
+ boolean allowDynamicKeys() default false;
}
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/RestConstants.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/RestConstants.java
index cb47b6a93..d2675ecee 100644
--- a/plugins/rest/src/main/java/org/apache/struts2/rest/RestConstants.java
+++ b/plugins/rest/src/main/java/org/apache/struts2/rest/RestConstants.java
@@ -23,6 +23,7 @@ public class RestConstants {
public static final String REST_LOGGER = "struts.rest.logger";
public static final String REST_DEFAULT_ERROR_RESULT_NAME =
"struts.rest.defaultErrorResultName";
public static final String REST_CONTENT_RESTRICT_TO_GET =
"struts.rest.content.restrictToGET";
+ public static final String REST_ANY_SETTER_REQUIRE_ANNOTATIONS =
"struts.rest.anySetter.requireAnnotations";
public static final String REST_MAPPER_INDEX_METHOD_NAME =
"struts.mapper.indexMethodName";
public static final String REST_MAPPER_GET_METHOD_NAME =
"struts.mapper.getMethodName";
public static final String REST_MAPPER_POST_METHOD_NAME =
"struts.mapper.postMethodName";
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/config/entities/RestConstantConfig.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/config/entities/RestConstantConfig.java
index d83f01b42..2b814cfe0 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/config/entities/RestConstantConfig.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/config/entities/RestConstantConfig.java
@@ -29,6 +29,7 @@ public class RestConstantConfig extends ConstantConfig {
private Boolean restLogger;
private String restDefaultErrorResultName;
private Boolean restContentRestrictToGet;
+ private Boolean restAnySetterRequireAnnotations;
private String mapperIndexMethodName;
private String mapperGetMethodName;
private String mapperPostMethodName;
@@ -50,6 +51,8 @@ public class RestConstantConfig extends ConstantConfig {
map.put(RestConstants.REST_LOGGER, Objects.toString(restLogger, null));
map.put(RestConstants.REST_DEFAULT_ERROR_RESULT_NAME,
restDefaultErrorResultName);
map.put(RestConstants.REST_CONTENT_RESTRICT_TO_GET,
Objects.toString(restContentRestrictToGet, null));
+ map.put(RestConstants.REST_ANY_SETTER_REQUIRE_ANNOTATIONS,
+ Objects.toString(restAnySetterRequireAnnotations, null));
map.put(RestConstants.REST_MAPPER_INDEX_METHOD_NAME,
mapperIndexMethodName);
map.put(RestConstants.REST_MAPPER_GET_METHOD_NAME,
mapperGetMethodName);
map.put(RestConstants.REST_MAPPER_POST_METHOD_NAME,
mapperPostMethodName);
@@ -98,6 +101,14 @@ public class RestConstantConfig extends ConstantConfig {
this.restContentRestrictToGet = restContentRestrictToGet;
}
+ public Boolean getRestAnySetterRequireAnnotations() {
+ return restAnySetterRequireAnnotations;
+ }
+
+ public void setRestAnySetterRequireAnnotations(Boolean
restAnySetterRequireAnnotations) {
+ this.restAnySetterRequireAnnotations = restAnySetterRequireAnnotations;
+ }
+
public String getMapperIndexMethodName() {
return mapperIndexMethodName;
}
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonJsonHandler.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonJsonHandler.java
index 834661cd5..20f520d42 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonJsonHandler.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonJsonHandler.java
@@ -21,9 +21,12 @@ package org.apache.struts2.rest.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.SerializationFeature;
+import org.apache.commons.lang3.BooleanUtils;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.rest.RestConstants;
+import org.apache.struts2.rest.handler.jackson.ParameterAuthorizingModule;
import java.io.IOException;
import java.io.Reader;
@@ -36,14 +39,19 @@ public class JacksonJsonHandler implements
AuthorizationAwareContentTypeHandler
private static final String DEFAULT_CONTENT_TYPE = "application/json";
private String defaultEncoding = "ISO-8859-1";
+ private final ParameterAuthorizingModule parameterAuthorizingModule = new
ParameterAuthorizingModule();
private ObjectMapper mapper = new ObjectMapper()
- .registerModule(new
org.apache.struts2.rest.handler.jackson.ParameterAuthorizingModule());
+ .registerModule(parameterAuthorizingModule);
@Override
public void toObject(ActionInvocation invocation, Reader in, Object
target) throws IOException {
mapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false);
ObjectReader or = mapper.readerForUpdating(target);
- or.readValue(in);
+ try {
+ or.readValue(in);
+ } finally {
+ parameterAuthorizingModule.clearAuthorizationContext();
+ }
}
@Override
@@ -67,4 +75,9 @@ public class JacksonJsonHandler implements
AuthorizationAwareContentTypeHandler
public void setDefaultEncoding(String val) {
this.defaultEncoding = val;
}
+
+ @Inject(value = RestConstants.REST_ANY_SETTER_REQUIRE_ANNOTATIONS,
required = false)
+ public void setAnySetterRequireAnnotations(String value) {
+
parameterAuthorizingModule.setRequireAnySetterAnnotations(BooleanUtils.toBoolean(value));
+ }
}
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonXmlHandler.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonXmlHandler.java
index ccc102023..a25b151aa 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonXmlHandler.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonXmlHandler.java
@@ -20,9 +20,13 @@ package org.apache.struts2.rest.handler;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+import org.apache.commons.lang3.BooleanUtils;
import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.inject.Inject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.apache.struts2.rest.RestConstants;
+import org.apache.struts2.rest.handler.jackson.ParameterAuthorizingModule;
import java.io.IOException;
import java.io.Reader;
@@ -37,17 +41,22 @@ public class JacksonXmlHandler implements
AuthorizationAwareContentTypeHandler {
private static final String DEFAULT_CONTENT_TYPE = "application/xml";
private final XmlMapper mapper;
+ private final ParameterAuthorizingModule parameterAuthorizingModule = new
ParameterAuthorizingModule();
public JacksonXmlHandler() {
mapper = new XmlMapper();
- mapper.registerModule(new
org.apache.struts2.rest.handler.jackson.ParameterAuthorizingModule());
+ mapper.registerModule(parameterAuthorizingModule);
}
@Override
public void toObject(ActionInvocation invocation, Reader in, Object
target) throws IOException {
LOG.debug("Converting input into an object of: {}",
target.getClass().getName());
ObjectReader or = mapper.readerForUpdating(target);
- or.readValue(in);
+ try {
+ or.readValue(in);
+ } finally {
+ parameterAuthorizingModule.clearAuthorizationContext();
+ }
}
@Override
@@ -67,4 +76,9 @@ public class JacksonXmlHandler implements
AuthorizationAwareContentTypeHandler {
return "xml";
}
+ @Inject(value = RestConstants.REST_ANY_SETTER_REQUIRE_ANNOTATIONS,
required = false)
+ public void setAnySetterRequireAnnotations(String value) {
+
parameterAuthorizingModule.setRequireAnySetterAnnotations(BooleanUtils.toBoolean(value));
+ }
+
}
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableAnyProperty.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableAnyProperty.java
new file mode 100644
index 000000000..e738e77a4
--- /dev/null
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableAnyProperty.java
@@ -0,0 +1,266 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.struts2.rest.handler.jackson;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.BeanProperty;
+import com.fasterxml.jackson.databind.DeserializationConfig;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.deser.SettableAnyProperty;
+import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
+import com.fasterxml.jackson.databind.util.TokenBuffer;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
+
+import java.io.IOException;
+
+/**
+ * Requires an explicit {@link StrutsParameter#allowDynamicKeys()} opt-in
before a Jackson
+ * any-setter can consume dynamic REST body properties.
+ */
+final class AuthorizingSettableAnyProperty extends SettableAnyProperty {
+
+ private static final long serialVersionUID = 1L;
+ private static final Logger LOG =
LogManager.getLogger(AuthorizingSettableAnyProperty.class);
+ private static final Object REJECTED_VALUE = new Object();
+
+ private final SettableAnyProperty delegate;
+ private final StrutsParameter permission;
+ private final boolean creatorParameter;
+
+ AuthorizingSettableAnyProperty(SettableAnyProperty delegate) {
+ super(delegate.getProperty(), memberOf(delegate.getProperty()),
delegate.getType(),
+ null, null, null);
+ this.delegate = delegate;
+ this.permission = permissionOf(delegate.getProperty());
+ this.creatorParameter = delegate.getParameterIndex() >= 0;
+ }
+
+ private static AnnotatedMember memberOf(BeanProperty property) {
+ return property == null ? null : property.getMember();
+ }
+
+ private static StrutsParameter permissionOf(BeanProperty property) {
+ AnnotatedMember member = memberOf(property);
+ return member == null ? null :
member.getAnnotation(StrutsParameter.class);
+ }
+
+ @Override
+ public SettableAnyProperty withValueDeserializer(JsonDeserializer<Object>
deserializer) {
+ return new
AuthorizingSettableAnyProperty(delegate.withValueDeserializer(deserializer));
+ }
+
+ @Override
+ public void fixAccess(DeserializationConfig config) {
+ delegate.fixAccess(config);
+ }
+
+ @Override
+ public boolean hasValueDeserializer() {
+ return delegate.hasValueDeserializer();
+ }
+
+ @Override
+ public String getPropertyName() {
+ return delegate.getPropertyName();
+ }
+
+ @Override
+ public int getParameterIndex() {
+ return delegate.getParameterIndex();
+ }
+
+ @Override
+ public boolean isFieldType() {
+ return delegate.isFieldType();
+ }
+
+ @Override
+ public boolean isSetterType() {
+ return delegate.isSetterType();
+ }
+
+ @Override
+ public Object createParameterObject() {
+ return delegate.createParameterObject();
+ }
+
+ @Override
+ public void deserializeAndSet(JsonParser parser, DeserializationContext
context,
+ Object instance, String propertyName) throws
IOException {
+ if (!ParameterAuthorizationContext.isActive()) {
+ delegate.deserializeAndSet(parser, context, instance,
propertyName);
+ return;
+ }
+
+ String path = ParameterAuthorizationContext.pathFor(propertyName);
+ int allowedDepth = allowedDepth(path);
+ if (allowedDepth < 0) {
+ rejectPermission(parser, path);
+ return;
+ }
+
+ try (TokenBuffer value = context.bufferAsCopyOfValue(parser)) {
+ int valueDepth = valueDepth(value);
+ if (valueDepth > allowedDepth) {
+ rejectDepth(parser, path, valueDepth, allowedDepth);
+ return;
+ }
+ try (JsonParser replay = value.asParserOnFirstToken()) {
+ boolean scopePushed = false;
+ boolean pathPushed = false;
+ try {
+ DynamicKeyAuthorizationContext.push(path, allowedDepth);
+ scopePushed = true;
+
ParameterAuthorizationContext.pushPath(prefixForNested(path));
+ pathPushed = true;
+ delegate.deserializeAndSet(replay, context, instance,
propertyName);
+ } finally {
+ if (pathPushed) {
+ ParameterAuthorizationContext.popPath();
+ }
+ if (scopePushed) {
+ DynamicKeyAuthorizationContext.pop();
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public Object deserialize(JsonParser parser, DeserializationContext
context) throws IOException {
+ if (!ParameterAuthorizationContext.isActive()) {
+ return delegate.deserialize(parser, context);
+ }
+
+ String propertyName = parser.currentName();
+ if (propertyName == null) {
+ rejectMissingPropertyName(parser);
+ return REJECTED_VALUE;
+ }
+
+ String path = ParameterAuthorizationContext.pathFor(propertyName);
+ int allowedDepth = allowedDepth(path);
+ if (allowedDepth < 0) {
+ rejectPermission(parser, path);
+ return REJECTED_VALUE;
+ }
+
+ try (TokenBuffer value = context.bufferAsCopyOfValue(parser)) {
+ int valueDepth = valueDepth(value);
+ if (valueDepth > allowedDepth) {
+ rejectDepth(parser, path, valueDepth, allowedDepth);
+ return REJECTED_VALUE;
+ }
+ try (JsonParser replay = value.asParserOnFirstToken()) {
+ boolean scopePushed = false;
+ boolean pathPushed = false;
+ try {
+ DynamicKeyAuthorizationContext.push(path, allowedDepth);
+ scopePushed = true;
+
ParameterAuthorizationContext.pushPath(prefixForNested(path));
+ pathPushed = true;
+ return delegate.deserialize(replay, context);
+ } finally {
+ if (pathPushed) {
+ ParameterAuthorizationContext.popPath();
+ }
+ if (scopePushed) {
+ DynamicKeyAuthorizationContext.pop();
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void set(Object instance, Object propertyName, Object value) throws
IOException {
+ if (value != REJECTED_VALUE) {
+ delegate.set(instance, propertyName, value);
+ }
+ }
+
+ @Override
+ protected void _set(Object instance, Object propertyName, Object value)
throws Exception {
+ if (value != REJECTED_VALUE) {
+ delegate.set(instance, propertyName, value);
+ }
+ }
+
+ private int allowedDepth(String path) {
+ if (creatorParameter || permission == null ||
!permission.allowDynamicKeys()) {
+ return -1;
+ }
+ return DynamicKeyAuthorizationContext.limitForNestedScope(path,
permission.depth());
+ }
+
+ private void rejectPermission(JsonParser parser, String path) throws
IOException {
+ if (creatorParameter) {
+ LOG.warn("REST body creator-parameter any-setter [{}] rejected;
dynamic-key consent "
+ + "can only be declared on an any-setter method or field",
path);
+ } else {
+ LOG.warn("REST body any-setter parameter [{}] rejected; dynamic
keys require "
+ + "@StrutsParameter(allowDynamicKeys = true) on a method
or field", path);
+ }
+ redactAndSkip(parser);
+ }
+
+ private void rejectDepth(JsonParser parser, String path, int valueDepth,
int allowedDepth) throws IOException {
+ LOG.warn("REST body any-setter parameter [{}] rejected; value depth
[{}] exceeds "
+ + "@StrutsParameter depth [{}]", path, valueDepth,
allowedDepth);
+ redactAndSkip(parser);
+ }
+
+ private void rejectMissingPropertyName(JsonParser parser) throws
IOException {
+ LOG.warn("REST body any-setter parameter rejected; dynamic property
name is unavailable");
+ redactAndSkip(parser);
+ }
+
+ private void redactAndSkip(JsonParser parser) throws IOException {
+ ParameterAuthorizationContext.markRedacted();
+ parser.skipChildren();
+ }
+
+ private int valueDepth(TokenBuffer value) throws IOException {
+ int currentDepth = 0;
+ int maximumDepth = 0;
+ try (JsonParser parser = value.asParserOnFirstToken()) {
+ for (JsonToken token = parser.currentToken(); token != null; token
= parser.nextToken()) {
+ if (token == JsonToken.START_OBJECT || token ==
JsonToken.START_ARRAY) {
+ currentDepth++;
+ maximumDepth = Math.max(maximumDepth, currentDepth);
+ } else if (token == JsonToken.END_OBJECT || token ==
JsonToken.END_ARRAY) {
+ currentDepth--;
+ }
+ }
+ }
+ return maximumDepth;
+ }
+
+ private String prefixForNested(String path) {
+ JavaType type = delegate.getType();
+ if (type != null && (type.isCollectionLikeType() ||
type.isMapLikeType() || type.isArrayType())) {
+ return path + "[0]";
+ }
+ return path;
+ }
+}
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java
index 3da5c5f09..14c3bcec7 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java
@@ -83,7 +83,7 @@ public class AuthorizingSettableBeanProperty extends
SettableBeanProperty.Delega
return;
}
String path = ParameterAuthorizationContext.pathFor(getName());
- if (!ParameterAuthorizationContext.isAuthorized(path)) {
+ if (!DynamicKeyAuthorizationContext.isAuthorized(path)) {
LOG.warn("REST body parameter [{}] rejected by @StrutsParameter
authorization on [{}]",
path, instance.getClass().getName());
ParameterAuthorizationContext.markRedacted();
@@ -104,7 +104,7 @@ public class AuthorizingSettableBeanProperty extends
SettableBeanProperty.Delega
return delegate.deserializeSetAndReturn(p, ctxt, instance);
}
String path = ParameterAuthorizationContext.pathFor(getName());
- if (!ParameterAuthorizationContext.isAuthorized(path)) {
+ if (!DynamicKeyAuthorizationContext.isAuthorized(path)) {
LOG.warn("REST body parameter [{}] rejected by @StrutsParameter
authorization on [{}]",
path, instance.getClass().getName());
ParameterAuthorizationContext.markRedacted();
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
index 0a1369e34..fc9532960 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
@@ -58,7 +58,7 @@ final class AuthorizingValueDeserializer extends
DelegatingDeserializer {
return super.deserialize(p, ctxt);
}
String path = ParameterAuthorizationContext.pathFor(propertyName);
- if (!ParameterAuthorizationContext.isAuthorized(path)) {
+ if (!DynamicKeyAuthorizationContext.isAuthorized(path)) {
LOG.warn("REST body parameter [{}] rejected by @StrutsParameter
authorization (creator-bound property)", path);
ParameterAuthorizationContext.markRedacted();
p.skipChildren();
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/DynamicKeyAuthorizationContext.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/DynamicKeyAuthorizationContext.java
new file mode 100644
index 000000000..fdafde4eb
--- /dev/null
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/DynamicKeyAuthorizationContext.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.struts2.rest.handler.jackson;
+
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * Tracks the bounded subtree authorized by an explicitly opted-in Jackson
any-setter.
+ */
+final class DynamicKeyAuthorizationContext {
+
+ private static final ThreadLocal<Deque<Scope>> SCOPES = new
ThreadLocal<>();
+
+ private DynamicKeyAuthorizationContext() {
+ // utility
+ }
+
+ static boolean isAuthorized(String path) {
+ Deque<Scope> scopes = SCOPES.get();
+ if (scopes == null || scopes.isEmpty()) {
+ return ParameterAuthorizationContext.isAuthorized(path);
+ }
+ return remainingDepth(scopes.peek(), path) >= 0;
+ }
+
+ static int limitForNestedScope(String path, int requestedDepth) {
+ if (requestedDepth < 0) {
+ return -1;
+ }
+ Deque<Scope> scopes = SCOPES.get();
+ if (scopes == null || scopes.isEmpty()) {
+ return requestedDepth;
+ }
+ int remainingDepth = remainingDepth(scopes.peek(), path);
+ return remainingDepth < 0 ? -1 : Math.min(requestedDepth,
remainingDepth);
+ }
+
+ static void push(String basePath, int maxDepth) {
+ Deque<Scope> scopes = SCOPES.get();
+ if (scopes == null) {
+ scopes = new ArrayDeque<>();
+ SCOPES.set(scopes);
+ }
+ scopes.push(new Scope(basePath, maxDepth));
+ }
+
+ static void pop() {
+ Deque<Scope> scopes = SCOPES.get();
+ if (scopes != null && !scopes.isEmpty()) {
+ scopes.pop();
+ }
+ if (scopes == null || scopes.isEmpty()) {
+ SCOPES.remove();
+ }
+ }
+
+ static boolean isActive() {
+ Deque<Scope> scopes = SCOPES.get();
+ return scopes != null && !scopes.isEmpty();
+ }
+
+ static void clear() {
+ SCOPES.remove();
+ }
+
+ private static int remainingDepth(Scope scope, String path) {
+ if (path == null || scope.basePath == null ||
!path.startsWith(scope.basePath)) {
+ return -1;
+ }
+ if (path.length() == scope.basePath.length()) {
+ return scope.maxDepth;
+ }
+
+ char boundary = path.charAt(scope.basePath.length());
+ if (boundary != '.' && boundary != '[' && boundary != '(') {
+ return -1;
+ }
+
+ int usedDepth = 0;
+ for (int i = scope.basePath.length(); i < path.length(); i++) {
+ char current = path.charAt(i);
+ if (current == '.' || current == '[' || current == '(') {
+ usedDepth++;
+ }
+ }
+ return scope.maxDepth - usedDepth;
+ }
+
+ private static final class Scope {
+ private final String basePath;
+ private final int maxDepth;
+
+ private Scope(String basePath, int maxDepth) {
+ this.basePath = basePath;
+ this.maxDepth = maxDepth;
+ }
+ }
+}
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java
index c3a191ef0..99450c185 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java
@@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
+import com.fasterxml.jackson.databind.deser.SettableAnyProperty;
import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
import com.fasterxml.jackson.databind.module.SimpleModule;
@@ -43,8 +44,14 @@ import java.util.Iterator;
public class ParameterAuthorizingModule extends SimpleModule {
private static final long serialVersionUID = 1L;
+ private volatile boolean requireAnySetterAnnotations;
public ParameterAuthorizingModule() {
+ this(false);
+ }
+
+ public ParameterAuthorizingModule(boolean requireAnySetterAnnotations) {
+ this.requireAnySetterAnnotations = requireAnySetterAnnotations;
setDeserializerModifier(new BeanDeserializerModifier() {
@Override
public BeanDeserializerBuilder updateBuilder(DeserializationConfig
config,
@@ -58,6 +65,13 @@ public class ParameterAuthorizingModule extends SimpleModule
{
}
builder.addOrReplaceProperty(new
AuthorizingSettableBeanProperty(original), true);
}
+ if
(ParameterAuthorizingModule.this.requireAnySetterAnnotations) {
+ SettableAnyProperty anySetter = builder.getAnySetter();
+ if (anySetter != null && !(anySetter instanceof
AuthorizingSettableAnyProperty)) {
+ builder.setAnySetter(null);
+ builder.setAnySetter(new
AuthorizingSettableAnyProperty(anySetter));
+ }
+ }
return builder;
}
@@ -72,4 +86,21 @@ public class ParameterAuthorizingModule extends SimpleModule
{
}
});
}
+
+ /**
+ * Configures any-setter enforcement. Set this before the mapper is first
used so Jackson has
+ * not yet cached deserializers built by this module.
+ */
+ public void setRequireAnySetterAnnotations(boolean
requireAnySetterAnnotations) {
+ this.requireAnySetterAnnotations = requireAnySetterAnnotations;
+ }
+
+ /**
+ * Clears request-scoped dynamic-key authorization state after a mapper
read.
+ *
+ * @since 7.4.0
+ */
+ public void clearAuthorizationContext() {
+ DynamicKeyAuthorizationContext.clear();
+ }
}
diff --git a/plugins/rest/src/main/resources/struts-plugin.xml
b/plugins/rest/src/main/resources/struts-plugin.xml
index cccc62d30..73ad21146 100644
--- a/plugins/rest/src/main/resources/struts-plugin.xml
+++ b/plugins/rest/src/main/resources/struts-plugin.xml
@@ -40,6 +40,7 @@
<constant name="struts.rest.defaultExtension" value="xhtml" />
<constant name="struts.rest.logger" value="true" />
<constant name="struts.rest.defaultErrorResultName" value="default-error"
/>
+ <constant name="struts.rest.anySetter.requireAnnotations" value="false" />
<constant name="struts.mapper.class" value="rest" />
<constant name="struts.mapper.idParameterName" value="id" />
<constant name="struts.action.extension" value="xhtml,,xml,json" />
diff --git
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
index 7e5407c17..cfe9b2a61 100644
---
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
+++
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
@@ -18,6 +18,7 @@
*/
package org.apache.struts2.rest;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.mockobjects.dynamic.AnyConstraintMatcher;
import com.mockobjects.dynamic.Mock;
import junit.framework.TestCase;
@@ -37,6 +38,9 @@ import org.apache.struts2.rest.handler.JacksonJsonHandler;
import org.apache.struts2.util.StrutsProxyService;
import org.springframework.mock.web.MockHttpServletRequest;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
/**
@@ -59,6 +63,10 @@ public class ContentTypeInterceptorIntegrationTest extends
TestCase {
}
private void setupInterceptorWithAction(Object actionInstance) {
+ setupInterceptorWithAction(actionInstance, false);
+ }
+
+ private void setupInterceptorWithAction(Object actionInstance, boolean
requireAnySetterAnnotations) {
var ognlUtil = new OgnlUtil(
new DefaultOgnlExpressionCacheFactory<>("1000",
LRU.toString()),
new DefaultOgnlBeanInfoCacheFactory<>("1000", LRU.toString()),
@@ -80,10 +88,12 @@ public class ContentTypeInterceptorIntegrationTest extends
TestCase {
mockActionInvocation.expectAndReturn("getAction", actionInstance);
mockActionInvocation.expectAndReturn("getAction", actionInstance);
mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
+ JacksonJsonHandler handler = new JacksonJsonHandler();
+
handler.setAnySetterRequireAnnotations(Boolean.toString(requireAnySetterAnnotations));
mockSelector.expectAndReturn("getHandlerForRequest", new
AnyConstraintMatcher() {
@Override
public boolean matches(Object[] args) { return true; }
- }, new JacksonJsonHandler());
+ }, handler);
interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager)
mockSelector.proxy());
}
@@ -167,6 +177,27 @@ public class ContentTypeInterceptorIntegrationTest extends
TestCase {
restrictedAction.getUnauthorized());
}
+ public void testAnySetterEnforcementIsBackwardCompatibleByDefault() throws
Exception {
+ UnannotatedAnySetterAction anySetterAction = new
UnannotatedAnySetterAction();
+ setupInterceptorWithAction(anySetterAction);
+ runWithBody("{\"role\":\"admin\"}");
+ assertEquals("admin", anySetterAction.getValues().get("role"));
+ }
+
+ public void testAnySetterEnforcementRejectsUnannotatedSinkWhenEnabled()
throws Exception {
+ UnannotatedAnySetterAction anySetterAction = new
UnannotatedAnySetterAction();
+ setupInterceptorWithAction(anySetterAction, true);
+ runWithBody("{\"role\":\"admin\"}");
+ assertTrue(anySetterAction.getValues().isEmpty());
+ }
+
+ public void testAnySetterEnforcementAcceptsExplicitDynamicKeySink() throws
Exception {
+ AnnotatedAnySetterAction anySetterAction = new
AnnotatedAnySetterAction();
+ setupInterceptorWithAction(anySetterAction, true);
+ runWithBody("{\"role\":\"admin\"}");
+ assertEquals("admin", anySetterAction.getValues().get("role"));
+ }
+
// --- Test fixtures for new path verification ---
/**
@@ -203,4 +234,31 @@ public class ContentTypeInterceptorIntegrationTest extends
TestCase {
this.unauthorized = unauthorized;
}
}
+
+ public static class UnannotatedAnySetterAction extends ActionSupport {
+ private final Map<String, Object> values = new LinkedHashMap<>();
+
+ @JsonAnySetter
+ public void put(String name, Object value) {
+ values.put(name, value);
+ }
+
+ public Map<String, Object> getValues() {
+ return values;
+ }
+ }
+
+ public static class AnnotatedAnySetterAction extends ActionSupport {
+ private final Map<String, Object> values = new LinkedHashMap<>();
+
+ @JsonAnySetter
+ @StrutsParameter(allowDynamicKeys = true)
+ public void put(String name, Object value) {
+ values.put(name, value);
+ }
+
+ public Map<String, Object> getValues() {
+ return values;
+ }
+ }
}
diff --git
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
index 34171e252..61ee26eaf 100644
---
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
+++
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
@@ -18,19 +18,36 @@
*/
package org.apache.struts2.rest.handler.jackson;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonUnwrapped;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.BeanDescription;
+import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder;
+import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
+import com.fasterxml.jackson.databind.deser.SettableAnyProperty;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.util.TokenBuffer;
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import junit.framework.TestCase;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
+import org.apache.struts2.rest.handler.JacksonJsonHandler;
import java.beans.ConstructorProperties;
+import java.io.StringReader;
+import java.math.BigDecimal;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
public class ParameterAuthorizingModuleTest extends TestCase {
@@ -44,6 +61,7 @@ public class ParameterAuthorizingModuleTest extends TestCase {
@Override
protected void tearDown() {
ParameterAuthorizationContext.unbind();
+ DynamicKeyAuthorizationContext.clear();
}
private void bind(ParameterAuthorizer authorizer, Object instance) {
@@ -115,6 +133,249 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
ParameterAuthorizationContext.currentPathPrefix());
}
+ public void testAnySetterEnforcementDisabledByDefault() throws Exception {
+ bind((path, t, a) -> false, new UnannotatedAnySetterBean());
+ UnannotatedAnySetterBean result = mapper.readValue(
+ "{\"role\":\"admin\"}", UnannotatedAnySetterBean.class);
+ assertEquals("admin", result.values.get("role"));
+ }
+
+ public void testUnannotatedAnySetterRejectedWhenEnforcementEnabled()
throws Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> true, new UnannotatedAnySetterBean());
+ UnannotatedAnySetterBean result = enforcingMapper.readValue(
+ "{\"role\":\"admin\"}", UnannotatedAnySetterBean.class);
+ assertTrue(result.values.isEmpty());
+ }
+
+ public void testAnySetterWithoutDynamicKeyOptInRejected() throws Exception
{
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> true, new AnnotatedAnySetterBean());
+ AnnotatedAnySetterBean result = enforcingMapper.readValue(
+ "{\"role\":\"admin\"}", AnnotatedAnySetterBean.class);
+ assertTrue(result.values.isEmpty());
+ }
+
+ public void testMethodAnySetterWithDynamicKeyOptInAcceptsScalar() throws
Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new DynamicScalarAnySetterBean());
+ DynamicScalarAnySetterBean result = enforcingMapper.readValue(
+ "{\"role\":\"admin\"}", DynamicScalarAnySetterBean.class);
+ assertEquals("admin", result.values.get("role"));
+ }
+
+ public void testFieldAnySetterWithDynamicKeyOptInAcceptsScalar() throws
Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new DynamicFieldAnySetterBean());
+ DynamicFieldAnySetterBean result = enforcingMapper.readValue(
+ "{\"role\":\"admin\"}", DynamicFieldAnySetterBean.class);
+ assertEquals("admin", result.values.get("role"));
+ }
+
+ public void testMethodAnySetterPreservesFloatingPointValues() throws
Exception {
+ String number = "1.2345678901234567890123456789";
+ for (boolean useBigDecimal : new boolean[]{false, true}) {
+ ObjectMapper enforcingMapper = enforcingMapper()
+
.configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, useBigDecimal);
+ bind((path, t, a) -> false, new DynamicScalarAnySetterBean());
+ DynamicScalarAnySetterBean result = enforcingMapper.readValue(
+ "{\"amount\":" + number + "}",
DynamicScalarAnySetterBean.class);
+ assertEquals(useBigDecimal ? new BigDecimal(number) :
Double.valueOf(number),
+ result.values.get("amount"));
+ }
+ }
+
+ public void testFieldAnySetterPreservesFloatingPointValues() throws
Exception {
+ String number = "1.2345678901234567890123456789";
+ for (boolean useBigDecimal : new boolean[]{false, true}) {
+ ObjectMapper enforcingMapper = enforcingMapper()
+
.configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, useBigDecimal);
+ bind((path, t, a) -> false, new DynamicFieldAnySetterBean());
+ DynamicFieldAnySetterBean result = enforcingMapper.readValue(
+ "{\"amount\":" + number + "}",
DynamicFieldAnySetterBean.class);
+ assertEquals(useBigDecimal ? new BigDecimal(number) :
Double.valueOf(number),
+ result.values.get("amount"));
+ }
+ }
+
+ public void testPropertyCreatorAnySetterPreservesFloatingPointValues()
throws Exception {
+ String number = "1.2345678901234567890123456789";
+ for (boolean useBigDecimal : new boolean[]{false, true}) {
+ ObjectMapper enforcingMapper = enforcingMapper()
+
.configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, useBigDecimal);
+ bind((path, t, a) -> true, new
PropertyCreatorWithAnySetterBean(""));
+ // Read the dynamic value before the constructor argument so
Jackson buffers it.
+ PropertyCreatorWithAnySetterBean result =
enforcingMapper.readValue(
+ "{\"amount\":" + number + ",\"name\":\"alice\"}",
PropertyCreatorWithAnySetterBean.class);
+ assertEquals("alice", result.name);
+ assertEquals(useBigDecimal ? new BigDecimal(number) :
Double.valueOf(number),
+ result.values.get("amount"));
+ }
+ }
+
+ public void testXmlAnySetterPreservesNumericTextRoundTrip() throws
Exception {
+ String number = "1.2345678901234567890123456789";
+ for (boolean useBigDecimal : new boolean[]{false, true}) {
+ XmlMapper xmlMapper = new XmlMapper();
+ xmlMapper.registerModule(new ParameterAuthorizingModule(true));
+
xmlMapper.configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS,
useBigDecimal);
+ bind((path, t, a) -> false, new DynamicScalarAnySetterBean());
+ DynamicScalarAnySetterBean result = xmlMapper.readValue(
+ "<value><amount>" + number + "</amount></value>",
DynamicScalarAnySetterBean.class);
+ assertEquals(number, result.values.get("amount"));
+
+ DynamicScalarAnySetterBean roundTrip = xmlMapper.readValue(
+ xmlMapper.writeValueAsString(result.values),
DynamicScalarAnySetterBean.class);
+ assertEquals(number, roundTrip.values.get("amount"));
+ }
+ }
+
+ public void testFieldAnySetterDepthOneAcceptsDirectMember() throws
Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new DynamicDepthOneFieldAnySetterBean());
+ DynamicDepthOneFieldAnySetterBean result = enforcingMapper.readValue(
+ "{\"home\":{\"city\":\"Warsaw\"}}",
+ DynamicDepthOneFieldAnySetterBean.class);
+ assertEquals("Warsaw", result.values.get("home").city);
+ }
+
+ public void testDynamicKeyDepthZeroRejectsNestedMembers() throws Exception
{
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new DynamicDepthZeroAnySetterBean());
+ DynamicDepthZeroAnySetterBean result = enforcingMapper.readValue(
+ "{\"home\":{\"city\":\"Warsaw\"}}",
DynamicDepthZeroAnySetterBean.class);
+ assertTrue(result.values.isEmpty());
+ }
+
+ public void testDynamicKeyDepthOneAcceptsDirectMember() throws Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new DynamicDepthOneAnySetterBean());
+ DynamicDepthOneAnySetterBean result = enforcingMapper.readValue(
+ "{\"home\":{\"city\":\"Warsaw\"}}",
+ DynamicDepthOneAnySetterBean.class);
+ assertEquals("Warsaw", result.values.get("home").city);
+ }
+
+ public void testDynamicKeyDepthOneRejectsGrandchildBeforeConstruction()
throws Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new DynamicDepthOneAnySetterBean());
+ DynamicDepthOneAnySetterBean result = enforcingMapper.readValue(
+
"{\"home\":{\"city\":\"Warsaw\",\"geo\":{\"country\":\"PL\"}}}",
+ DynamicDepthOneAnySetterBean.class);
+ assertTrue(result.values.isEmpty());
+ }
+
+ public void testDynamicKeyDepthZeroRejectsUntypedObject() throws Exception
{
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new DynamicScalarAnySetterBean());
+ DynamicScalarAnySetterBean result = enforcingMapper.readValue(
+ "{\"settings\":{\"role\":\"admin\"}}",
DynamicScalarAnySetterBean.class);
+ assertTrue(result.values.isEmpty());
+ }
+
+ public void testDynamicKeyDepthTwoAcceptsGrandchild() throws Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new DynamicDepthTwoAnySetterBean());
+ DynamicDepthTwoAnySetterBean result = enforcingMapper.readValue(
+ "{\"home\":{\"geo\":{\"country\":\"PL\"}}}",
DynamicDepthTwoAnySetterBean.class);
+ assertEquals("PL", result.values.get("home").geo.country);
+ }
+
+ public void testCreatorParameterAnySetterRejectedWhenEnforcementEnabled()
throws Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> true, new CreatorAnySetterBean(Map.of()));
+ CreatorAnySetterBean result = enforcingMapper.readValue(
+ "{\"role\":\"admin\"}", CreatorAnySetterBean.class);
+ assertTrue(result.values.isEmpty());
+ }
+
+ public void testDeserializeWithoutCurrentNameRejectsAndClearsScope()
throws Exception {
+ AtomicReference<SettableAnyProperty> captured = new
AtomicReference<>();
+ SimpleModule captureModule = new SimpleModule("capture-any-setter");
+ captureModule.setDeserializerModifier(new BeanDeserializerModifier() {
+ @Override
+ public BeanDeserializerBuilder updateBuilder(DeserializationConfig
config,
+ BeanDescription
beanDesc,
+
BeanDeserializerBuilder builder) {
+ if (beanDesc.getBeanClass() ==
PropertyCreatorWithAnySetterBean.class) {
+ captured.set(builder.getAnySetter());
+ }
+ return builder;
+ }
+ });
+ ObjectMapper captureMapper = new
ObjectMapper().registerModule(captureModule);
+ captureMapper.readValue("{\"name\":\"alice\"}",
PropertyCreatorWithAnySetterBean.class);
+
+ assertNotNull(captured.get());
+ AuthorizingSettableAnyProperty property = new
AuthorizingSettableAnyProperty(captured.get());
+ bind((path, t, a) -> true, new PropertyCreatorWithAnySetterBean(""));
+ try (TokenBuffer value = new TokenBuffer(captureMapper, false)) {
+ value.writeString("admin");
+ try (JsonParser parser = value.asParserOnFirstToken()) {
+ assertNull(parser.currentName());
+ assertNotNull(property.deserialize(parser, null));
+ }
+ }
+
+ assertFalse(DynamicKeyAuthorizationContext.isActive());
+ assertEquals("", ParameterAuthorizationContext.currentPathPrefix());
+ }
+
+ public void testJacksonHandlerClearsDynamicScopeAfterReadFailure() throws
Exception {
+ DynamicKeyAuthorizationContext.push("stale", 0);
+ assertTrue(DynamicKeyAuthorizationContext.isActive());
+
+ try {
+ new JacksonJsonHandler().toObject(null, new StringReader("{"), new
Person());
+ fail("expected malformed JSON to fail");
+ } catch (Exception expected) {
+ // The handler's request-boundary cleanup must run even when
Jackson aborts the read.
+ }
+
+ assertFalse(DynamicKeyAuthorizationContext.isActive());
+ }
+
+ public void testUnauthorizedParentStillBlocksNestedAnySetter() throws
Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new AnySetterParent());
+ AnySetterParent result = enforcingMapper.readValue(
+ "{\"child\":{\"role\":\"admin\"}}", AnySetterParent.class);
+ assertNull(result.child);
+ }
+
+ public void testJsonUnwrappedRemainsUnaffected() throws Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> true, new UnwrappedBean());
+ UnwrappedBean result =
enforcingMapper.readValue("{\"city\":\"Warsaw\"}", UnwrappedBean.class);
+ assertEquals("Warsaw", result.address.city);
+ }
+
+ public void testDynamicKeyScopeCleanAfterDeserialization() throws
Exception {
+ ObjectMapper enforcingMapper = enforcingMapper();
+ bind((path, t, a) -> false, new DynamicDepthTwoAnySetterBean());
+ enforcingMapper.readValue(
+ "{\"home\":{\"geo\":{\"country\":\"PL\"}}}",
DynamicDepthTwoAnySetterBean.class);
+ assertFalse(DynamicKeyAuthorizationContext.isActive());
+ assertEquals("", ParameterAuthorizationContext.currentPathPrefix());
+ }
+
+ public void testXmlAnySetterUsesSameOptIn() throws Exception {
+ XmlMapper xmlMapper = new XmlMapper();
+ xmlMapper.registerModule(new ParameterAuthorizingModule(true));
+
+ bind((path, t, a) -> false, new DynamicScalarAnySetterBean());
+ DynamicScalarAnySetterBean allowed = xmlMapper.readValue(
+
"<DynamicScalarAnySetterBean><role>admin</role></DynamicScalarAnySetterBean>",
+ DynamicScalarAnySetterBean.class);
+ assertEquals("admin", allowed.values.get("role"));
+
+ bind((path, t, a) -> true, new UnannotatedAnySetterBean());
+ UnannotatedAnySetterBean rejected = xmlMapper.readValue(
+
"<UnannotatedAnySetterBean><role>admin</role></UnannotatedAnySetterBean>",
+ UnannotatedAnySetterBean.class);
+ assertTrue(rejected.values.isEmpty());
+ }
+
public void testBuilderDeserializationNoContextPassThrough() throws
Exception {
// No bind → AuthorizingSettableBeanProperty.deserializeSetAndReturn
falls through
// to the delegate without consulting the authorization context.
@@ -280,6 +541,10 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
// --- Fixtures ---
+ private ObjectMapper enforcingMapper() {
+ return new ObjectMapper().registerModule(new
ParameterAuthorizingModule(true));
+ }
+
public static class Person {
public String name;
public String role;
@@ -293,6 +558,116 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
public static class Address {
public String city;
public String zip;
+ public Geo geo;
+ }
+
+ public static class Geo {
+ public String country;
+ }
+
+ public static class UnannotatedAnySetterBean {
+ public final Map<String, Object> values = new LinkedHashMap<>();
+
+ @JsonAnySetter
+ public void put(String name, Object value) {
+ values.put(name, value);
+ }
+ }
+
+ public static class AnnotatedAnySetterBean {
+ public final Map<String, Object> values = new LinkedHashMap<>();
+
+ @JsonAnySetter
+ @StrutsParameter
+ public void put(String name, Object value) {
+ values.put(name, value);
+ }
+ }
+
+ public static class DynamicScalarAnySetterBean {
+ public final Map<String, Object> values = new LinkedHashMap<>();
+
+ @JsonAnySetter
+ @StrutsParameter(allowDynamicKeys = true)
+ public void put(String name, Object value) {
+ values.put(name, value);
+ }
+ }
+
+ public static class DynamicFieldAnySetterBean {
+ @JsonAnySetter
+ @StrutsParameter(allowDynamicKeys = true)
+ public Map<String, Object> values = new LinkedHashMap<>();
+ }
+
+ public static class DynamicDepthOneFieldAnySetterBean {
+ @JsonAnySetter
+ @StrutsParameter(allowDynamicKeys = true, depth = 1)
+ public Map<String, Address> values = new LinkedHashMap<>();
+ }
+
+ public static class DynamicDepthZeroAnySetterBean {
+ public final Map<String, Address> values = new LinkedHashMap<>();
+
+ @JsonAnySetter
+ @StrutsParameter(allowDynamicKeys = true)
+ public void put(String name, Address value) {
+ values.put(name, value);
+ }
+ }
+
+ public static class DynamicDepthOneAnySetterBean {
+ public final Map<String, Address> values = new LinkedHashMap<>();
+
+ @JsonAnySetter
+ @StrutsParameter(allowDynamicKeys = true, depth = 1)
+ public void put(String name, Address value) {
+ values.put(name, value);
+ }
+ }
+
+ public static class DynamicDepthTwoAnySetterBean {
+ public final Map<String, Address> values = new LinkedHashMap<>();
+
+ @JsonAnySetter
+ @StrutsParameter(allowDynamicKeys = true, depth = 2)
+ public void put(String name, Address value) {
+ values.put(name, value);
+ }
+ }
+
+ public static class CreatorAnySetterBean {
+ public final Map<String, Object> values;
+
+ @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+ public CreatorAnySetterBean(@JsonAnySetter Map<String, Object> values)
{
+ this.values = values;
+ }
+ }
+
+ public static class PropertyCreatorWithAnySetterBean {
+ public final String name;
+ public final Map<String, Object> values = new LinkedHashMap<>();
+
+ @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+ public PropertyCreatorWithAnySetterBean(@JsonProperty("name") String
name) {
+ this.name = name;
+ }
+
+ @JsonAnySetter
+ @StrutsParameter(allowDynamicKeys = true)
+ public void put(String key, Object value) {
+ values.put(key, value);
+ }
+ }
+
+ public static class AnySetterParent {
+ public DynamicScalarAnySetterBean child;
+ }
+
+ public static class UnwrappedBean {
+ @JsonUnwrapped
+ public Address address = new Address();
}
/**