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 09d03286f WW-5626 per-property authorization for Jackson REST handlers
(#1674)
09d03286f is described below
commit 09d03286f81d16ee4212a03f4881bf5abf0cdd80
Author: Lukasz Lenart <[email protected]>
AuthorDate: Thu May 14 15:01:00 2026 +0200
WW-5626 per-property authorization for Jackson REST handlers (#1674)
* WW-5626 spike: validate Jackson per-property authorization mechanism
Validates that the Approach C design is feasible before committing to a
detailed
implementation plan. Wraps each SettableBeanProperty via
BeanDeserializerModifier;
intercepts deserializeAndSet to authorize against a path built from a
ThreadLocal
Deque; uses skipChildren() to discard unauthorized values; uses [0] suffix
for
collection/map/array elements to match ParametersInterceptor depth
semantics.
Findings:
- Delegating base class via 'protected delegate' field is the right pattern
- addOrReplaceProperty(prop, true) is the correct builder API
- Reject-at-parent skips all nested deserialization (better security than
two-phase
copy: setter side effects on unauthorized properties never fire)
- JavaType#isCollectionLikeType/isMapLikeType/isArrayType detects the
indexed-path case
Spike is kept under .../spike/ as a learning artifact; it will be replaced
by
production code + tests in subsequent commits.
* WW-5626 add ParameterAuthorizationContext for deserializer-level
authorization
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* WW-5626 address review feedback on ParameterAuthorizationContext
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* WW-5626 add AuthorizationAwareContentTypeHandler marker interface
* WW-5626 add AuthorizingSettableBeanProperty for Jackson per-property
authorization
* WW-5626 add ParameterAuthorizingModule installing the property wrapper on
Jackson mappers
* WW-5626 register ParameterAuthorizingModule on default Jackson REST
handlers
* WW-5626 use AuthorizationAwareContentTypeHandler path when handler
supports it
* WW-5626 add integration tests proving the new Jackson authorization path
is used
* WW-5626 deprecate XStreamHandler in favor of JacksonXmlHandler
* WW-5626 remove Jackson auth spike; replaced by production tests
* WW-5626 make JuneauXmlHandler authorization-aware via post-parse walk
Implements AuthorizationAwareContentTypeHandler. When
ParameterAuthorizationContext
is active (set by ContentTypeInterceptor when requireAnnotations=true), the
handler
walks the parsed result tree and copies only authorized properties to the
target,
descending into nested beans/collections/maps/arrays with indexed-path
semantics
([0] suffix) for parity with ParametersInterceptor.
Note: Juneau parses the entire result tree before our walk runs, so setter
side
effects on transient nested objects can fire even for unauthorized
properties —
those transient objects are then discarded. This is functionally equivalent
to the
legacy two-phase copy in ContentTypeInterceptor; only the Jackson handlers
achieve
the stronger guarantee where unauthorized subtrees are never instantiated
at all
(they use Jackson's BeanDeserializerModifier + skipChildren).
When no context is bound (default config), behavior is unchanged:
parser.parse +
BeanUtils.copyProperties.
* WW-5626 add JuneauXmlHandler integration tests for @StrutsParameter
authorization
* WW-5626 test(rest): cover JuneauXmlHandler post-parse walk for
collections, maps, arrays
Sonar reported 51 uncovered new lines in JuneauXmlHandler (48.8% coverage
on the
post-parse authorization walk — the security-critical code path the branch
exists
to introduce). Add integration coverage for the previously-uncovered
branches:
- collection-of-scalars (List<String> tags)
- collection-of-beans (List<Address> addresses)
- map-of-scalars (Map<String,String> attributes)
- array-of-scalars (String[] aliases)
- empty collection
- malformed XML wrapped as IOException
Also drop two unnecessary casts (Sonar S1905) on lines 243/252 — the
unchecked
conversion happens at the return statement, the explicit casts were
redundant
under the existing @SuppressWarnings("unchecked").
Add @Override on the inline AnyConstraintMatcher.matches override (Sonar
S1161).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
* WW-5626 test(rest): cover AuthorizingSettableBeanProperty builder-path
deserialization
Sonar reported 11 uncovered new lines on AuthorizingSettableBeanProperty
(66.7%
coverage). All 11 are in deserializeSetAndReturn — the alternate Jackson
entry
point used for builder-pattern deserialization, never triggered by
setter-based
fixtures like Person.
Add an @JsonDeserialize(builder=...) fixture (ImmutablePerson) that forces
Jackson to use BuilderBasedDeserializer, which dispatches property writes
through deserializeSetAndReturn. Three new tests exercise the path:
inactive-context pass-through, top-level authorization, and full rejection.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
* WW-5626 refactor(rest): extract helpers from
ContentTypeInterceptor.intercept
Sonar S3776 flagged intercept() at cognitive complexity 16 (limit 15).
Extract
the body-handling branches into named helpers:
- openBodyReader: encoding-aware reader from the request InputStream
- applyRequestBody: dispatcher between requireAnnotations on/off paths
- applyWithAuthorizationContext: bind + delegate + unbind for
AuthorizationAware handlers
- applyTwoPhaseDeserialize: legacy fresh-instance +
copyAuthorizedProperties path
intercept() drops to ~12 lines and reads as a flat sequence: resolve target,
delegate body application, invoke. Each helper carries the comment that
explains the security model for its branch.
Add @Override on the inline AnyConstraintMatcher.matches override (Sonar
S1161).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
.../parameter/ParameterAuthorizationContext.java | 144 +++++++++++++++
.../ParameterAuthorizationContextTest.java | 129 +++++++++++++
.../struts2/rest/ContentTypeInterceptor.java | 82 ++++++---
.../AuthorizationAwareContentTypeHandler.java | 46 +++++
.../struts2/rest/handler/JacksonJsonHandler.java | 5 +-
.../struts2/rest/handler/JacksonXmlHandler.java | 9 +-
.../struts2/rest/handler/JuneauXmlHandler.java | 204 ++++++++++++++++++++-
.../struts2/rest/handler/XStreamHandler.java | 11 +-
.../jackson/AuthorizingSettableBeanProperty.java | 114 ++++++++++++
.../jackson/ParameterAuthorizingModule.java | 64 +++++++
.../ContentTypeInterceptorIntegrationTest.java | 92 +++++++++-
...t.java => JuneauXmlHandlerIntegrationTest.java} | 90 ++++++---
.../org/apache/struts2/rest/SecureRestAction.java | 31 ++++
.../struts2/rest/handler/JuneauXmlHandlerTest.java | 9 +
.../jackson/ParameterAuthorizingModuleTest.java | 175 ++++++++++++++++++
15 files changed, 1141 insertions(+), 64 deletions(-)
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
new file mode 100644
index 000000000..bcd35ce32
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
@@ -0,0 +1,144 @@
+/*
+ * 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.interceptor.parameter;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Objects;
+
+/**
+ * ThreadLocal holder for per-request parameter authorization state, used by
deserializer-level
+ * authorization (e.g. the REST plugin's {@code ContentTypeInterceptor}). All
state — the
+ * {@link ParameterAuthorizer}, the target, the action, and the current
property-path stack — is
+ * bound by input-channel interceptors before invoking the deserializer, and
unbound in a
+ * {@code finally} block afterwards.
+ *
+ * <p>Implementations that consult this context (e.g. {@code
AuthorizingSettableBeanProperty}) call
+ * {@link #isActive()} to decide whether to enforce authorization at all —
when no context is bound
+ * (default config, {@code requireAnnotations=false}), they short-circuit to
the delegate behavior.</p>
+ *
+ * @since 7.2.0
+ */
+public final class ParameterAuthorizationContext {
+
+ private static final ThreadLocal<State> STATE = new ThreadLocal<>();
+ private static final ThreadLocal<Deque<String>> PATH_STACK =
ThreadLocal.withInitial(ArrayDeque::new);
+
+ private ParameterAuthorizationContext() {
+ // utility
+ }
+
+ /**
+ * Binds an authorizer, target, and action to the current thread. {@code
target} is the object
+ * being populated — typically the action itself, or the model object for
{@code ModelDriven}
+ * actions (the same contract as {@link
ParameterAuthorizer#isAuthorized}). {@code action} is
+ * always the action instance. A subsequent call without an intervening
{@link #unbind()} replaces
+ * the prior state without resetting the path stack.
+ *
+ * @param authorizer the authorizer to use for this request; must not be
{@code null}
+ * @param target the object being populated (action or model)
+ * @param action the action instance
+ */
+ public static void bind(ParameterAuthorizer authorizer, Object target,
Object action) {
+ Objects.requireNonNull(authorizer, "authorizer");
+ STATE.set(new State(authorizer, target, action));
+ }
+
+ /**
+ * Removes the bound authorizer state and clears the path stack for the
current thread.
+ * Safe to call even when no context has been bound.
+ */
+ public static void unbind() {
+ STATE.remove();
+ PATH_STACK.remove();
+ }
+
+ /**
+ * Returns {@code true} if an authorizer has been bound on the current
thread via {@link #bind}.
+ */
+ public static boolean isActive() {
+ return STATE.get() != null;
+ }
+
+ /**
+ * Authorizes a parameter at the given path against the bound authorizer.
Returns {@code true}
+ * when no context is bound — callers that don't want enforcement at all
should not bind context
+ * in the first place; this default keeps wrapping deserializers safe for
non-authorized requests.
+ */
+ public static boolean isAuthorized(String parameterPath) {
+ State state = STATE.get();
+ if (state == null) {
+ return true;
+ }
+ return state.authorizer.isAuthorized(parameterPath, state.target,
state.action);
+ }
+
+ /**
+ * Pushes the full cumulative path prefix onto the stack. Subsequent
{@link #pathFor(String)}
+ * calls will append {@code name} to this prefix. Callers building a
collection-element prefix
+ * (e.g. {@code items[0]}) must pass the full string including the suffix.
+ *
+ * @param cumulativePath the full path prefix to push (e.g. {@code
"address"} or {@code "items[0]"})
+ */
+ public static void pushPath(String cumulativePath) {
+ PATH_STACK.get().push(cumulativePath);
+ }
+
+ /**
+ * Pops the top path prefix from the stack. Has no effect if the stack is
empty.
+ */
+ public static void popPath() {
+ Deque<String> stack = PATH_STACK.get();
+ if (!stack.isEmpty()) {
+ stack.pop();
+ }
+ }
+
+ /**
+ * @return the current top-of-stack path prefix, or empty string if none
+ */
+ public static String currentPathPrefix() {
+ Deque<String> stack = PATH_STACK.get();
+ if (stack.isEmpty()) {
+ return "";
+ }
+ return stack.peek();
+ }
+
+ /**
+ * Builds the full path for a property at the current nesting level:
{@code prefix.propertyName}
+ * (or just {@code propertyName} when at the root).
+ */
+ public static String pathFor(String propertyName) {
+ String prefix = currentPathPrefix();
+ return prefix.isEmpty() ? propertyName : prefix + "." + propertyName;
+ }
+
+ private static final class State {
+ final ParameterAuthorizer authorizer;
+ final Object target;
+ final Object action;
+
+ State(ParameterAuthorizer authorizer, Object target, Object action) {
+ this.authorizer = authorizer;
+ this.target = target;
+ this.action = action;
+ }
+ }
+}
diff --git
a/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
new file mode 100644
index 000000000..76e4a3466
--- /dev/null
+++
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.interceptor.parameter;
+
+import org.junit.After;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ParameterAuthorizationContextTest {
+
+ @After
+ public void tearDown() {
+ ParameterAuthorizationContext.unbind();
+ }
+
+ @Test
+ public void notActive_byDefault() {
+ assertThat(ParameterAuthorizationContext.isActive()).isFalse();
+ }
+
+ @Test
+ public void bind_thenActive() {
+ ParameterAuthorizer authorizer = (n, t, a) -> true;
+ Object action = new Object();
+ ParameterAuthorizationContext.bind(authorizer, action, action);
+ assertThat(ParameterAuthorizationContext.isActive()).isTrue();
+ }
+
+ @Test
+ public void unbind_clearsState() {
+ ParameterAuthorizer authorizer = (n, t, a) -> true;
+ Object action = new Object();
+ ParameterAuthorizationContext.bind(authorizer, action, action);
+ ParameterAuthorizationContext.unbind();
+ assertThat(ParameterAuthorizationContext.isActive()).isFalse();
+ }
+
+ @Test
+ public void isAuthorized_delegatesToBoundAuthorizer() {
+ Object action = new Object();
+ ParameterAuthorizationContext.bind((n, t, a) -> "name".equals(n),
action, action);
+
assertThat(ParameterAuthorizationContext.isAuthorized("name")).isTrue();
+
assertThat(ParameterAuthorizationContext.isAuthorized("role")).isFalse();
+ }
+
+ @Test
+ public void isAuthorized_returnsTrue_whenNotActive() {
+ // Defensive default: no context bound = no enforcement
+
assertThat(ParameterAuthorizationContext.isAuthorized("anything")).isTrue();
+ }
+
+ @Test
+ public void pathStack_emptyByDefault() {
+
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
+ }
+
+ @Test
+ public void pushPath_buildsPrefix() {
+ ParameterAuthorizationContext.pushPath("address");
+
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address");
+ ParameterAuthorizationContext.pushPath("address.city");
+
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address.city");
+ }
+
+ @Test
+ public void popPath_unwinds() {
+ ParameterAuthorizationContext.pushPath("address");
+ ParameterAuthorizationContext.pushPath("address.city");
+ ParameterAuthorizationContext.popPath();
+
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address");
+ ParameterAuthorizationContext.popPath();
+
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
+ }
+
+ @Test
+ public void pathFor_concatenatesPropertyName() {
+
assertThat(ParameterAuthorizationContext.pathFor("name")).isEqualTo("name");
+ ParameterAuthorizationContext.pushPath("address");
+
assertThat(ParameterAuthorizationContext.pathFor("city")).isEqualTo("address.city");
+ }
+
+ @Test
+ public void unbind_clearsPathStack() {
+ ParameterAuthorizationContext.bind((n, t, a) -> true, new Object(),
new Object());
+ ParameterAuthorizationContext.pushPath("address");
+ ParameterAuthorizationContext.unbind();
+
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
+ }
+
+ @Test
+ public void bind_replacesPriorState_doesNotResetPathStack() {
+ Object firstAction = new Object();
+ Object secondAction = new Object();
+ ParameterAuthorizationContext.bind((n, t, a) -> "first".equals(n),
firstAction, firstAction);
+ ParameterAuthorizationContext.pushPath("address");
+ // Rebind with a different authorizer
+ ParameterAuthorizationContext.bind((n, t, a) -> "second".equals(n),
secondAction, secondAction);
+ // New authorizer in effect
+
assertThat(ParameterAuthorizationContext.isAuthorized("first")).isFalse();
+
assertThat(ParameterAuthorizationContext.isAuthorized("second")).isTrue();
+ // Path stack is preserved across rebind (it's a separate ThreadLocal)
+
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEqualTo("address");
+ }
+
+ @Test
+ public void unbind_whenNeverBound_isSafeNoOp() {
+ // Should not throw; isActive should remain false
+ ParameterAuthorizationContext.unbind();
+ assertThat(ParameterAuthorizationContext.isActive()).isFalse();
+
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
+ }
+}
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
index 73f3cd7ef..17ccdf2de 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
@@ -87,36 +87,72 @@ public class ContentTypeInterceptor extends
AbstractInterceptor {
Object target = invocation.getAction();
if (target instanceof ModelDriven) {
- target = ((ModelDriven<?>)target).getModel();
+ target = ((ModelDriven<?>) target).getModel();
}
if (request.getContentLength() > 0) {
- final String encoding = request.getCharacterEncoding();
- InputStream is = request.getInputStream();
- InputStreamReader reader = encoding == null ? new
InputStreamReader(is) : new InputStreamReader(is, encoding);
-
- if (requireAnnotations) {
- // Two-phase deserialization: deserialize into a fresh
instance, then copy only authorized properties.
- // Requires a public no-arg constructor on the target class.
- // If absent, body processing is rejected entirely — a
best-effort scrub cannot guarantee
- // that every nested unauthorized property is nulled out, so
the safer choice is to skip.
- Object freshInstance = createFreshInstance(target.getClass());
- if (freshInstance != null) {
- handler.toObject(invocation, reader, freshInstance);
- copyAuthorizedProperties(freshInstance, target,
invocation.getAction(), target, "");
- } else {
- LOG.warn("REST body rejected: requireAnnotations=true but
[{}] has no no-arg constructor; "
- + "body deserialization skipped to preserve
@StrutsParameter authorization integrity",
- target.getClass().getName());
- }
- } else {
- // Direct deserialization (backward compat when
requireAnnotations is not enabled)
- handler.toObject(invocation, reader, target);
- }
+ applyRequestBody(invocation, handler, target,
openBodyReader(request));
}
return invocation.invoke();
}
+ private static InputStreamReader openBodyReader(HttpServletRequest
request) throws java.io.IOException {
+ String encoding = request.getCharacterEncoding();
+ InputStream is = request.getInputStream();
+ return encoding == null ? new InputStreamReader(is) : new
InputStreamReader(is, encoding);
+ }
+
+ private void applyRequestBody(ActionInvocation invocation,
ContentTypeHandler handler, Object target,
+ InputStreamReader reader) throws Exception {
+ if (!requireAnnotations) {
+ // Direct deserialization (backward compat when requireAnnotations
is not enabled).
+ handler.toObject(invocation, reader, target);
+ return;
+ }
+ if (handler instanceof
org.apache.struts2.rest.handler.AuthorizationAwareContentTypeHandler) {
+ applyWithAuthorizationContext(invocation, handler, target, reader);
+ } else {
+ applyTwoPhaseDeserialize(invocation, handler, target, reader);
+ }
+ }
+
+ /**
+ * Path used for {@link
org.apache.struts2.rest.handler.AuthorizationAwareContentTypeHandler}s — the
handler
+ * authorizes per-property during deserialization, so we only need to bind
{@code ParameterAuthorizationContext}
+ * for the call duration.
+ */
+ private void applyWithAuthorizationContext(ActionInvocation invocation,
ContentTypeHandler handler, Object target,
+ InputStreamReader reader)
throws java.io.IOException {
+ Object action = invocation.getAction();
+ Object resolvedTarget = parameterAuthorizer.resolveTarget(action);
+
org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext.bind(
+ parameterAuthorizer, resolvedTarget, action);
+ try {
+ handler.toObject(invocation, reader, target);
+ } finally {
+
org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext.unbind();
+ }
+ }
+
+ /**
+ * Legacy two-phase deserialization for handlers that don't authorize
themselves: deserialize into a fresh
+ * instance, then copy only authorized properties. Requires a public
no-arg constructor on the target class —
+ * if absent, body processing is rejected entirely (a best-effort scrub
cannot guarantee every nested
+ * unauthorized property is nulled out, so skipping is the safer choice).
+ */
+ private void applyTwoPhaseDeserialize(ActionInvocation invocation,
ContentTypeHandler handler, Object target,
+ InputStreamReader reader) throws
Exception {
+ Object freshInstance = createFreshInstance(target.getClass());
+ if (freshInstance == null) {
+ LOG.warn("REST body rejected: requireAnnotations=true but [{}] has
no no-arg constructor; "
+ + "body deserialization skipped to preserve
@StrutsParameter authorization integrity",
+ target.getClass().getName());
+ return;
+ }
+ handler.toObject(invocation, reader, freshInstance);
+ copyAuthorizedProperties(freshInstance, target,
invocation.getAction(), target, "");
+ }
+
private Object createFreshInstance(Class<?> clazz) {
try {
return clazz.getDeclaredConstructor().newInstance();
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/AuthorizationAwareContentTypeHandler.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/AuthorizationAwareContentTypeHandler.java
new file mode 100644
index 000000000..0e01c507e
--- /dev/null
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/AuthorizationAwareContentTypeHandler.java
@@ -0,0 +1,46 @@
+/*
+ * 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;
+
+/**
+ * Marker interface for {@link ContentTypeHandler} implementations that
respect the
+ * {@code ParameterAuthorizationContext} ThreadLocal during deserialization,
enforcing
+ * {@code @StrutsParameter} authorization per-property.
+ *
+ * <p>When {@code struts.parameters.requireAnnotations=true}, the REST plugin's
+ * {@code ContentTypeInterceptor} binds the authorization context before
invoking handlers that
+ * implement this interface, allowing them to filter unauthorized properties
during deserialization
+ * (rather than after, via reflection-based copying).</p>
+ *
+ * <p>Handlers that do NOT implement this interface fall back to the legacy
two-phase copy in
+ * {@code ContentTypeInterceptor} — correct but more expensive (and requires a
no-arg constructor
+ * on the target).</p>
+ *
+ * <p><strong>Implementer responsibility:</strong> A handler that declares
this interface MUST register
+ * the authorization-aware mechanism on its underlying parser (e.g. for
Jackson, register
+ * {@code ParameterAuthorizingModule} on the {@code ObjectMapper}). If the
handler implements the
+ * interface but its parser does not honor the context, authorization will
silently do nothing —
+ * a serious security bug. The marker interface is the contract;
implementations must uphold it.</p>
+ *
+ * @since 7.2.0
+ */
+public interface AuthorizationAwareContentTypeHandler extends
ContentTypeHandler {
+ // Marker interface — no methods. Implementations signal that their
toObject() method
+ // honors ParameterAuthorizationContext for per-property @StrutsParameter
enforcement.
+}
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 d97af08e3..834661cd5 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
@@ -32,11 +32,12 @@ import java.io.Writer;
/**
* Handles JSON content using jackson-lib
*/
-public class JacksonJsonHandler implements ContentTypeHandler {
+public class JacksonJsonHandler implements
AuthorizationAwareContentTypeHandler {
private static final String DEFAULT_CONTENT_TYPE = "application/json";
private String defaultEncoding = "ISO-8859-1";
- private ObjectMapper mapper = new ObjectMapper();
+ private ObjectMapper mapper = new ObjectMapper()
+ .registerModule(new
org.apache.struts2.rest.handler.jackson.ParameterAuthorizingModule());
@Override
public void toObject(ActionInvocation invocation, Reader in, Object
target) throws IOException {
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 a73ad4d21..ccc102023 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
@@ -31,12 +31,17 @@ import java.io.Writer;
/**
* Handles XML content using Jackson
*/
-public class JacksonXmlHandler implements ContentTypeHandler {
+public class JacksonXmlHandler implements AuthorizationAwareContentTypeHandler
{
private static final Logger LOG =
LogManager.getLogger(JacksonXmlHandler.class);
private static final String DEFAULT_CONTENT_TYPE = "application/xml";
- private final XmlMapper mapper = new XmlMapper();
+ private final XmlMapper mapper;
+
+ public JacksonXmlHandler() {
+ mapper = new XmlMapper();
+ mapper.registerModule(new
org.apache.struts2.rest.handler.jackson.ParameterAuthorizingModule());
+ }
@Override
public void toObject(ActionInvocation invocation, Reader in, Object
target) throws IOException {
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JuneauXmlHandler.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JuneauXmlHandler.java
index 30057c4ee..277549f6e 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JuneauXmlHandler.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JuneauXmlHandler.java
@@ -27,17 +27,39 @@ import org.apache.juneau.xml.XmlParser;
import org.apache.juneau.xml.XmlSerializer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
+import java.beans.BeanInfo;
+import java.beans.IntrospectionException;
+import java.beans.Introspector;
+import java.beans.PropertyDescriptor;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
+import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Collection;
+import java.util.Map;
/**
* Handles XML content using Apache Juneau
- * http://juneau.apache.org/#marshall.html
+ * <a
href="http://juneau.apache.org/#marshall.html">http://juneau.apache.org/#marshall.html</a>
+ *
+ * <p>Implements {@link AuthorizationAwareContentTypeHandler}: when
+ * {@link ParameterAuthorizationContext#isActive()} is {@code true}, performs
a post-parse walk
+ * over the parsed result and copies only authorized properties to the target.
Without an active
+ * context, behavior is unchanged (Juneau parses, then {@code
BeanUtils.copyProperties} populates
+ * the target).</p>
+ *
+ * <p>Note: Juneau's parser builds the entire result tree before our
authorization walk runs, so
+ * setter side effects on transient nested objects may fire even for
unauthorized properties —
+ * those transient objects are then discarded. This is functionally equivalent
to the legacy
+ * two-phase copy in {@code ContentTypeInterceptor}, with the same security
model. Only the
+ * Jackson-based handlers ({@link JacksonJsonHandler}, {@link
JacksonXmlHandler}) achieve the
+ * stronger guarantee where unauthorized subtrees are never instantiated at
all.</p>
*/
-public class JuneauXmlHandler implements ContentTypeHandler {
+public class JuneauXmlHandler implements AuthorizationAwareContentTypeHandler {
private static final Logger LOG =
LogManager.getLogger(JuneauXmlHandler.class);
@@ -51,12 +73,188 @@ public class JuneauXmlHandler implements
ContentTypeHandler {
LOG.debug("Converting input into an object of: {}",
target.getClass().getName());
try {
Object result = parser.parse(in, target.getClass());
- BeanUtils.copyProperties(target, result);
+ if (ParameterAuthorizationContext.isActive()) {
+ copyAuthorizedProperties(target, result, "");
+ } else {
+ BeanUtils.copyProperties(target, result);
+ }
} catch (ParseException | IllegalAccessException |
InvocationTargetException e) {
throw new IOException(e);
}
}
+ /**
+ * Recursively copies properties from {@code source} into {@code target},
consulting
+ * {@link ParameterAuthorizationContext} at each property. Unauthorized
properties are skipped
+ * (target retains its existing value). Authorized scalar properties are
copied directly;
+ * authorized nested beans are recursed into so their nested fields are
individually authorized;
+ * authorized collections / maps / arrays use indexed-path semantics
({@code path[0].field}).
+ */
+ private void copyAuthorizedProperties(Object target, Object source, String
prefix) throws IOException {
+ if (source == null) {
+ return;
+ }
+ BeanInfo beanInfo;
+ try {
+ beanInfo = Introspector.getBeanInfo(source.getClass(),
Object.class);
+ } catch (IntrospectionException e) {
+ throw new IOException("Unable to introspect " + source.getClass(),
e);
+ }
+ for (PropertyDescriptor pd : beanInfo.getPropertyDescriptors()) {
+ copyAuthorizedProperty(target, source, prefix, pd);
+ }
+ }
+
+ private void copyAuthorizedProperty(Object target, Object source, String
prefix, PropertyDescriptor pd)
+ throws IOException {
+ Method readMethod = pd.getReadMethod();
+ Method writeMethod = pd.getWriteMethod();
+ if (readMethod == null || writeMethod == null) {
+ return;
+ }
+ String path = prefix.isEmpty() ? pd.getName() : prefix + "." +
pd.getName();
+ if (!ParameterAuthorizationContext.isAuthorized(path)) {
+ LOG.warn("REST body parameter [{}] rejected by @StrutsParameter
authorization on [{}]",
+ path, target.getClass().getName());
+ return;
+ }
+ Object value;
+ try {
+ value = readMethod.invoke(source);
+ } catch (ReflectiveOperationException e) {
+ throw new IOException("Failed reading " + path, e);
+ }
+ if (value == null) {
+ return;
+ }
+ try {
+ writeAuthorizedValue(target, readMethod, writeMethod, value, path);
+ } catch (ReflectiveOperationException e) {
+ throw new IOException("Failed writing " + path, e);
+ }
+ }
+
+ private void writeAuthorizedValue(Object target, Method readMethod, Method
writeMethod, Object value, String path)
+ throws ReflectiveOperationException, IOException {
+ if (value instanceof Collection<?> collection) {
+ writeMethod.invoke(target, copyAuthorizedCollection(collection,
path));
+ } else if (value instanceof Map<?, ?> map) {
+ writeMethod.invoke(target, copyAuthorizedMap(map, path));
+ } else if (value.getClass().isArray()) {
+ writeMethod.invoke(target, copyAuthorizedArray(value, path));
+ } else if (isLeaf(value.getClass())) {
+ writeMethod.invoke(target, value);
+ } else {
+ writeAuthorizedNestedBean(target, readMethod, writeMethod, value,
path);
+ }
+ }
+
+ private void writeAuthorizedNestedBean(Object target, Method readMethod,
Method writeMethod,
+ Object value, String path)
+ throws ReflectiveOperationException, IOException {
+ Object nestedTarget = readMethod.invoke(target);
+ if (nestedTarget == null) {
+ nestedTarget = newInstance(value.getClass());
+ if (nestedTarget == null) {
+ // Cannot authorize without a fresh target instance; skip
rather than
+ // bulk-copy the unfiltered value.
+ LOG.warn("REST nested bean [{}] skipped — no no-arg
constructor for [{}]",
+ path, value.getClass().getName());
+ return;
+ }
+ writeMethod.invoke(target, nestedTarget);
+ }
+ copyAuthorizedProperties(nestedTarget, value, path);
+ }
+
+ private Collection<Object> copyAuthorizedCollection(Collection<?> source,
String prefix) throws IOException {
+ Collection<Object> result = newCollection(source);
+ String elementPath = prefix + "[0]";
+ for (Object element : source) {
+ result.add(copyAuthorizedElement(element, elementPath));
+ }
+ return result;
+ }
+
+ private Map<Object, Object> copyAuthorizedMap(Map<?, ?> source, String
prefix) throws IOException {
+ Map<Object, Object> result = newMap(source);
+ String elementPath = prefix + "[0]";
+ for (Map.Entry<?, ?> entry : source.entrySet()) {
+ result.put(entry.getKey(), copyAuthorizedElement(entry.getValue(),
elementPath));
+ }
+ return result;
+ }
+
+ private Object copyAuthorizedArray(Object sourceArray, String prefix)
throws IOException {
+ int length = Array.getLength(sourceArray);
+ Object result =
Array.newInstance(sourceArray.getClass().getComponentType(), length);
+ String elementPath = prefix + "[0]";
+ for (int i = 0; i < length; i++) {
+ Object element = Array.get(sourceArray, i);
+ Object copied = copyAuthorizedElement(element, elementPath);
+ if (copied != null ||
!sourceArray.getClass().getComponentType().isPrimitive()) {
+ Array.set(result, i, copied);
+ }
+ }
+ return result;
+ }
+
+ private Object copyAuthorizedElement(Object element, String elementPath)
throws IOException {
+ if (element == null || isLeaf(element.getClass())) {
+ return element;
+ }
+ Object freshElement = newInstance(element.getClass());
+ if (freshElement == null) {
+ LOG.warn("REST element [{}] skipped — no no-arg constructor for
[{}]",
+ elementPath, element.getClass().getName());
+ return null;
+ }
+ copyAuthorizedProperties(freshElement, element, elementPath);
+ return freshElement;
+ }
+
+ /**
+ * Treats common JDK value types as leaves (no introspection needed).
Mirrors the
+ * conservative classification used elsewhere in the REST plugin's
two-phase copy.
+ */
+ private static boolean isLeaf(Class<?> c) {
+ if (c.isPrimitive() || c.isEnum()) return true;
+ String n = c.getName();
+ return n.startsWith("java.lang.")
+ || n.startsWith("java.math.")
+ || n.startsWith("java.time.")
+ || n.startsWith("java.net.")
+ || n.startsWith("java.io.")
+ || n.startsWith("java.nio.")
+ || (n.startsWith("java.util.") &&
!Collection.class.isAssignableFrom(c) && !Map.class.isAssignableFrom(c));
+ }
+
+ private static Object newInstance(Class<?> c) {
+ try {
+ return c.getDeclaredConstructor().newInstance();
+ } catch (ReflectiveOperationException e) {
+ return null;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Collection<Object> newCollection(Collection<?> source) {
+ try {
+ return source.getClass().getDeclaredConstructor().newInstance();
+ } catch (ReflectiveOperationException e) {
+ return new java.util.ArrayList<>();
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<Object, Object> newMap(Map<?, ?> source) {
+ try {
+ return source.getClass().getDeclaredConstructor().newInstance();
+ } catch (ReflectiveOperationException e) {
+ return new java.util.LinkedHashMap<>();
+ }
+ }
+
@Override
public String fromObject(ActionInvocation invocation, Object obj, String
resultCode, Writer stream) throws IOException {
LOG.debug("Converting an object of {} into string",
obj.getClass().getName());
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/XStreamHandler.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/XStreamHandler.java
index 40a042a9c..f0cf191ca 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/XStreamHandler.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/XStreamHandler.java
@@ -42,8 +42,17 @@ import java.util.Map;
import java.util.Set;
/**
- * Handles XML content
+ * Handles XML content via the XStream library.
+ *
+ * @deprecated since 7.2.0, scheduled for removal in a future major version.
XStream has a long
+ * history of deserialization vulnerabilities and requires
per-class allowlist
+ * maintenance. The default {@code xml} binding in {@code
struts-plugin.xml} uses
+ * {@link JacksonXmlHandler}, which respects {@code
@StrutsParameter} authorization
+ * via the {@link AuthorizationAwareContentTypeHandler} mechanism.
Users who have
+ * explicitly overridden the {@code xml} handler to {@code
XStreamHandler} should
+ * migrate to {@link JacksonXmlHandler}.
*/
+@Deprecated(since = "7.2.0", forRemoval = true)
public class XStreamHandler implements ContentTypeHandler {
private static final Logger LOG =
LogManager.getLogger(XStreamHandler.class);
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
new file mode 100644
index 000000000..d6c3a812a
--- /dev/null
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java
@@ -0,0 +1,114 @@
+/*
+ * 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.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
+
+import java.io.IOException;
+
+/**
+ * A {@link SettableBeanProperty.Delegating} that authorizes each property
against the
+ * {@link ParameterAuthorizationContext} before delegating to the underlying
property's
+ * {@code deserializeAndSet}. Unauthorized properties are silently dropped —
the JSON value is
+ * skipped via {@link JsonParser#skipChildren()}, so any nested object graph
is never instantiated
+ * and setter side effects on unauthorized properties never fire.
+ *
+ * <p>Path tracking: the wrapper pushes the full path of the current property
onto the context's
+ * path stack before delegating, then pops in a {@code finally} block. For
collection / map / array-typed
+ * properties, the path pushed is suffixed with {@code [0]} so nested element
members produce paths like
+ * {@code items[0].field} — matching {@code ParametersInterceptor} depth
semantics.</p>
+ *
+ * <p>When {@link ParameterAuthorizationContext#isActive()} is {@code false},
this wrapper is a
+ * straight pass-through to the delegate — no overhead for default-config
requests.</p>
+ *
+ * @since 7.2.0
+ */
+public class AuthorizingSettableBeanProperty extends
SettableBeanProperty.Delegating {
+
+ private static final Logger LOG =
LogManager.getLogger(AuthorizingSettableBeanProperty.class);
+
+ public AuthorizingSettableBeanProperty(SettableBeanProperty delegate) {
+ super(delegate);
+ }
+
+ @Override
+ protected SettableBeanProperty withDelegate(SettableBeanProperty d) {
+ return new AuthorizingSettableBeanProperty(d);
+ }
+
+ @Override
+ public void deserializeAndSet(JsonParser p, DeserializationContext ctxt,
Object instance) throws IOException {
+ if (!ParameterAuthorizationContext.isActive()) {
+ delegate.deserializeAndSet(p, ctxt, instance);
+ return;
+ }
+ String path = ParameterAuthorizationContext.pathFor(getName());
+ if (!ParameterAuthorizationContext.isAuthorized(path)) {
+ LOG.warn("REST body parameter [{}] rejected by @StrutsParameter
authorization on [{}]",
+ path, instance.getClass().getName());
+ p.skipChildren();
+ return;
+ }
+ ParameterAuthorizationContext.pushPath(prefixForNested(path));
+ try {
+ delegate.deserializeAndSet(p, ctxt, instance);
+ } finally {
+ ParameterAuthorizationContext.popPath();
+ }
+ }
+
+ @Override
+ public Object deserializeSetAndReturn(JsonParser p, DeserializationContext
ctxt, Object instance) throws IOException {
+ if (!ParameterAuthorizationContext.isActive()) {
+ return delegate.deserializeSetAndReturn(p, ctxt, instance);
+ }
+ String path = ParameterAuthorizationContext.pathFor(getName());
+ if (!ParameterAuthorizationContext.isAuthorized(path)) {
+ LOG.warn("REST body parameter [{}] rejected by @StrutsParameter
authorization on [{}]",
+ path, instance.getClass().getName());
+ p.skipChildren();
+ return instance;
+ }
+ ParameterAuthorizationContext.pushPath(prefixForNested(path));
+ try {
+ return delegate.deserializeSetAndReturn(p, ctxt, instance);
+ } finally {
+ ParameterAuthorizationContext.popPath();
+ }
+ }
+
+ /**
+ * For Collection / Map / Array properties, the path to push for nested
element members is
+ * {@code path + "[0]"} — matching {@code ParametersInterceptor}
bracket-depth semantics. Scalar /
+ * bean properties push the path unchanged.
+ */
+ private String prefixForNested(String pathOfThisProperty) {
+ JavaType type = getType();
+ if (type != null && (type.isCollectionLikeType() ||
type.isMapLikeType() || type.isArrayType())) {
+ return pathOfThisProperty + "[0]";
+ }
+ return pathOfThisProperty;
+ }
+}
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
new file mode 100644
index 000000000..c90016415
--- /dev/null
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java
@@ -0,0 +1,64 @@
+/*
+ * 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.databind.BeanDescription;
+import com.fasterxml.jackson.databind.DeserializationConfig;
+import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder;
+import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
+import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+
+import java.util.Iterator;
+
+/**
+ * Jackson {@link SimpleModule} that wraps every {@link SettableBeanProperty}
on every bean type
+ * with an {@link AuthorizingSettableBeanProperty}, enforcing {@code
@StrutsParameter} authorization
+ * during deserialization via the {@link
org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext}
+ * ThreadLocal.
+ *
+ * <p>Register this module once on each handler's mapper (e.g. in the
constructor). All per-request
+ * authorization state is read from the ThreadLocal context, so the module +
mapper combination is
+ * thread-safe and reusable across requests.</p>
+ *
+ * @since 7.2.0
+ */
+public class ParameterAuthorizingModule extends SimpleModule {
+
+ private static final long serialVersionUID = 1L;
+
+ public ParameterAuthorizingModule() {
+ setDeserializerModifier(new BeanDeserializerModifier() {
+ @Override
+ public BeanDeserializerBuilder updateBuilder(DeserializationConfig
config,
+ BeanDescription
beanDesc,
+
BeanDeserializerBuilder builder) {
+ Iterator<SettableBeanProperty> it = builder.getProperties();
+ while (it.hasNext()) {
+ SettableBeanProperty original = it.next();
+ if (original instanceof AuthorizingSettableBeanProperty) {
+ continue; // idempotent; protect against
double-registration
+ }
+ builder.addOrReplaceProperty(new
AuthorizingSettableBeanProperty(original), true);
+ }
+ return builder;
+ }
+ });
+ }
+}
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 dc11c6fdf..7e5407c17 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
@@ -23,8 +23,10 @@ import com.mockobjects.dynamic.Mock;
import junit.framework.TestCase;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.ActionSupport;
import org.apache.struts2.action.Action;
import org.apache.struts2.dispatcher.mapper.ActionMapping;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
import org.apache.struts2.ognl.DefaultOgnlBeanInfoCacheFactory;
import org.apache.struts2.ognl.DefaultOgnlExpressionCacheFactory;
@@ -53,7 +55,10 @@ public class ContentTypeInterceptorIntegrationTest extends
TestCase {
protected void setUp() throws Exception {
super.setUp();
action = new SecureRestAction();
+ setupInterceptorWithAction(action);
+ }
+ private void setupInterceptorWithAction(Object actionInstance) {
var ognlUtil = new OgnlUtil(
new DefaultOgnlExpressionCacheFactory<>("1000",
LRU.toString()),
new DefaultOgnlBeanInfoCacheFactory<>("1000", LRU.toString()),
@@ -72,10 +77,11 @@ public class ContentTypeInterceptorIntegrationTest extends
TestCase {
mockActionInvocation = new Mock(ActionInvocation.class);
mockSelector = new Mock(ContentTypeHandlerManager.class);
// ContentTypeInterceptor calls getAction() twice when
requireAnnotations=true
- mockActionInvocation.expectAndReturn("getAction", action);
- mockActionInvocation.expectAndReturn("getAction", action);
+ mockActionInvocation.expectAndReturn("getAction", actionInstance);
+ mockActionInvocation.expectAndReturn("getAction", actionInstance);
mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
mockSelector.expectAndReturn("getHandlerForRequest", new
AnyConstraintMatcher() {
+ @Override
public boolean matches(Object[] args) { return true; }
}, new JacksonJsonHandler());
interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager)
mockSelector.proxy());
@@ -120,15 +126,81 @@ public class ContentTypeInterceptorIntegrationTest
extends TestCase {
}
public void testNestedPropertyRejectedWhenDepthInsufficient() throws
Exception {
- // shallowAddress has @StrutsParameter (depth=0) — its nested fields
should NOT be set
+ // shallowAddress has @StrutsParameter on the setter (depth-0
authorized) but the getter
+ // has no depth>=1 annotation. The Jackson path enters shallowAddress
(constructed by
+ // Jackson) but skipChildren on each inner property — so the Address
is non-null but its
+ // city/zip fields stay null.
runWithBody("{\"shallowAddress\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}");
- // The top-level shallowAddress reference itself may be created
(Jackson behavior)
- // but its nested city/zip must remain null because depth-1 isn't
allowed.
- if (action.getShallowAddress() != null) {
- assertNull("nested city should be rejected (depth insufficient)",
- action.getShallowAddress().getCity());
- assertNull("nested zip should be rejected (depth insufficient)",
- action.getShallowAddress().getZip());
+ assertNotNull("shallowAddress is depth-0 authorized; Jackson
constructs it",
+ action.getShallowAddress());
+ assertNull("nested city must be rejected (depth-1 not authorized)",
+ action.getShallowAddress().getCity());
+ assertNull("nested zip must be rejected (depth-1 not authorized)",
+ action.getShallowAddress().getZip());
+ }
+
+ // --- Tests proving the new Jackson authorization path is in use ---
+
+ public void testJacksonHandlerDoesNotRequireNoArgConstructor() throws
Exception {
+ // The legacy two-phase copy required a no-arg constructor on the
target. Jackson's
+ // readerForUpdating populates the existing instance directly, so this
constraint
+ // is gone — proof that the new AuthorizationAware path is being taken.
+ NoNoArgAction noNoArg = new
NoNoArgAction("preserved-pre-deserialization-value");
+ setupInterceptorWithAction(noNoArg);
+ runWithBody("{\"name\":\"alice\"}");
+ assertEquals("alice", noNoArg.getName());
+ assertEquals("pre-existing field must be preserved (no fresh-instance
copy)",
+ "preserved-pre-deserialization-value",
noNoArg.getRequiredField());
+ }
+
+ public void testRejectedAtParentNeverInstantiatesNestedObject() throws
Exception {
+ // Stronger guarantee than the two-phase copy: when the parent
property is rejected,
+ // Jackson's skipChildren() discards the entire JSON subtree and the
nested object
+ // is never constructed. role-typed fixture: address requires a setter
@StrutsParameter
+ // for depth-0 authorization. By giving address a fresh action where
address is depth-0
+ // unauthorized, we prove the setter is never called and address stays
null.
+ // (We use a custom action where address has no setter annotation.)
+ UnauthorizedNestedAction restrictedAction = new
UnauthorizedNestedAction();
+ setupInterceptorWithAction(restrictedAction);
+ runWithBody("{\"unauthorized\":{\"city\":\"Warsaw\"}}");
+ assertNull("unauthorized property must be rejected at parent — Jackson
never enters",
+ restrictedAction.getUnauthorized());
+ }
+
+ // --- Test fixtures for new path verification ---
+
+ /**
+ * Action with no public no-arg constructor — would fail the legacy
two-phase copy's
+ * createFreshInstance check, but works fine with the Jackson
authorization path.
+ */
+ public static class NoNoArgAction extends ActionSupport {
+ private final String requiredField;
+ private String name;
+
+ public NoNoArgAction(String requiredField) {
+ this.requiredField = requiredField;
+ }
+
+ public String getName() { return name; }
+
+ @StrutsParameter
+ public void setName(String name) { this.name = name; }
+
+ public String getRequiredField() { return requiredField; }
+ }
+
+ /**
+ * Action with a property that has NO @StrutsParameter on its setter —
depth-0 authorization
+ * fails, so Jackson must never enter this property nor instantiate the
nested object.
+ */
+ public static class UnauthorizedNestedAction extends ActionSupport {
+ private SecureRestAction.Address unauthorized;
+
+ public SecureRestAction.Address getUnauthorized() { return
unauthorized; }
+
+ // No @StrutsParameter annotation — depth-0 path "unauthorized" is
rejected.
+ public void setUnauthorized(SecureRestAction.Address unauthorized) {
+ this.unauthorized = unauthorized;
}
}
}
diff --git
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
b/plugins/rest/src/test/java/org/apache/struts2/rest/JuneauXmlHandlerIntegrationTest.java
similarity index 53%
copy from
plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
copy to
plugins/rest/src/test/java/org/apache/struts2/rest/JuneauXmlHandlerIntegrationTest.java
index dc11c6fdf..29300b388 100644
---
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
+++
b/plugins/rest/src/test/java/org/apache/struts2/rest/JuneauXmlHandlerIntegrationTest.java
@@ -31,18 +31,21 @@ import
org.apache.struts2.ognl.DefaultOgnlExpressionCacheFactory;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.ognl.StrutsOgnlGuard;
import org.apache.struts2.ognl.StrutsProxyCacheFactory;
-import org.apache.struts2.rest.handler.JacksonJsonHandler;
+import org.apache.struts2.rest.handler.JuneauXmlHandler;
import org.apache.struts2.util.StrutsProxyService;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
/**
- * Integration tests for ContentTypeInterceptor that use a real {@link
JacksonJsonHandler}
- * and a real {@link StrutsParameterAuthorizer}, end-to-end. Verifies that
property
- * filtering actually occurs on the deserialized object — not merely that the
wiring runs.
+ * End-to-end integration tests for {@link JuneauXmlHandler} as an
+ * {@link
org.apache.struts2.rest.handler.AuthorizationAwareContentTypeHandler}: a real
+ * Juneau parser + a real {@link StrutsParameterAuthorizer} + the {@link
ContentTypeInterceptor}
+ * binding {@code ParameterAuthorizationContext} before invoking the handler.
Verifies that
+ * authorization filtering happens during the handler's post-parse walk rather
than via the
+ * legacy two-phase copy in the interceptor.
*/
-public class ContentTypeInterceptorIntegrationTest extends TestCase {
+public class JuneauXmlHandlerIntegrationTest extends TestCase {
private ContentTypeInterceptor interceptor;
private SecureRestAction action;
@@ -71,20 +74,21 @@ public class ContentTypeInterceptorIntegrationTest extends
TestCase {
mockActionInvocation = new Mock(ActionInvocation.class);
mockSelector = new Mock(ContentTypeHandlerManager.class);
- // ContentTypeInterceptor calls getAction() twice when
requireAnnotations=true
mockActionInvocation.expectAndReturn("getAction", action);
mockActionInvocation.expectAndReturn("getAction", action);
mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
+ mockActionInvocation.matchAndReturn("getInvocationContext",
ActionContext.getContext());
mockSelector.expectAndReturn("getHandlerForRequest", new
AnyConstraintMatcher() {
+ @Override
public boolean matches(Object[] args) { return true; }
- }, new JacksonJsonHandler());
+ }, new JuneauXmlHandler());
interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager)
mockSelector.proxy());
}
private void runWithBody(String body) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContent(body.getBytes());
- request.setContentType("application/json");
+ request.setContentType("application/xml");
ActionContext.of()
.withActionMapping(new ActionMapping())
@@ -97,38 +101,78 @@ public class ContentTypeInterceptorIntegrationTest extends
TestCase {
}
public void testAnnotatedTopLevelPropertyIsApplied() throws Exception {
- runWithBody("{\"name\":\"alice\"}");
+ runWithBody("<object><name>alice</name></object>");
assertEquals("alice", action.getName());
}
public void testUnannotatedTopLevelPropertyIsRejected() throws Exception {
- runWithBody("{\"role\":\"admin\"}");
- assertNull("unannotated 'role' must not be set", action.getRole());
+ runWithBody("<object><role>admin</role></object>");
+ assertNull("unannotated 'role' must not be set on target",
action.getRole());
}
public void testMixedPropertiesFilteredCorrectly() throws Exception {
- runWithBody("{\"name\":\"alice\",\"role\":\"admin\"}");
+ runWithBody("<object><name>alice</name><role>admin</role></object>");
assertEquals("alice", action.getName());
assertNull(action.getRole());
}
public void testNestedPropertyAuthorizedWhenDepthAllows() throws Exception
{
- runWithBody("{\"address\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}");
+
runWithBody("<object><address><city>Warsaw</city><zip>00-001</zip></address></object>");
assertNotNull("address should be set", action.getAddress());
assertEquals("Warsaw", action.getAddress().getCity());
assertEquals("00-001", action.getAddress().getZip());
}
public void testNestedPropertyRejectedWhenDepthInsufficient() throws
Exception {
- // shallowAddress has @StrutsParameter (depth=0) — its nested fields
should NOT be set
-
runWithBody("{\"shallowAddress\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}");
- // The top-level shallowAddress reference itself may be created
(Jackson behavior)
- // but its nested city/zip must remain null because depth-1 isn't
allowed.
- if (action.getShallowAddress() != null) {
- assertNull("nested city should be rejected (depth insufficient)",
- action.getShallowAddress().getCity());
- assertNull("nested zip should be rejected (depth insufficient)",
- action.getShallowAddress().getZip());
- }
+ // shallowAddress: setter @StrutsParameter (depth-0 authorized) but
getter has no depth>=1
+ // annotation. The handler walks into the parsed Address and rejects
city/zip individually.
+
runWithBody("<object><shallowAddress><city>Warsaw</city><zip>00-001</zip></shallowAddress></object>");
+ assertNotNull("shallowAddress is depth-0 authorized; handler enters
it",
+ action.getShallowAddress());
+ assertNull("nested city must be rejected (depth-1 not authorized)",
+ action.getShallowAddress().getCity());
+ assertNull("nested zip must be rejected (depth-1 not authorized)",
+ action.getShallowAddress().getZip());
+ }
+
+ public void testCollectionOfScalarsCopiedThroughAuthorizedWalk() throws
Exception {
+
runWithBody("<object><tags><string>red</string><string>green</string></tags></object>");
+ assertNotNull(action.getTags());
+ assertEquals(2, action.getTags().size());
+ assertEquals("red", action.getTags().get(0));
+ assertEquals("green", action.getTags().get(1));
+ }
+
+ public void testCollectionOfBeansCopiedThroughAuthorizedWalk() throws
Exception {
+ runWithBody("<object><addresses>"
+ + "<object><city>Warsaw</city><zip>00-001</zip></object>"
+ + "<object><city>Krakow</city><zip>30-001</zip></object>"
+ + "</addresses></object>");
+ assertNotNull(action.getAddresses());
+ assertEquals(2, action.getAddresses().size());
+ assertEquals("Warsaw", action.getAddresses().get(0).getCity());
+ assertEquals("00-001", action.getAddresses().get(0).getZip());
+ assertEquals("Krakow", action.getAddresses().get(1).getCity());
+ }
+
+ public void testMapOfScalarsCopiedThroughAuthorizedWalk() throws Exception
{
+
runWithBody("<object><attributes><color>red</color><size>large</size></attributes></object>");
+ assertNotNull(action.getAttributes());
+ assertEquals("red", action.getAttributes().get("color"));
+ assertEquals("large", action.getAttributes().get("size"));
+ }
+
+ public void testArrayOfScalarsCopiedThroughAuthorizedWalk() throws
Exception {
+
runWithBody("<object><aliases><string>al1</string><string>al2</string></aliases></object>");
+ assertNotNull(action.getAliases());
+ assertEquals(2, action.getAliases().length);
+ assertEquals("al1", action.getAliases()[0]);
+ assertEquals("al2", action.getAliases()[1]);
+ }
+
+ public void testEmptyCollectionPreserved() throws Exception {
+ runWithBody("<object><tags></tags></object>");
+ assertNotNull(action.getTags());
+ assertEquals(0, action.getTags().size());
}
}
diff --git
a/plugins/rest/src/test/java/org/apache/struts2/rest/SecureRestAction.java
b/plugins/rest/src/test/java/org/apache/struts2/rest/SecureRestAction.java
index 16966dec1..8ca032f50 100644
--- a/plugins/rest/src/test/java/org/apache/struts2/rest/SecureRestAction.java
+++ b/plugins/rest/src/test/java/org/apache/struts2/rest/SecureRestAction.java
@@ -21,6 +21,9 @@ package org.apache.struts2.rest;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
+import java.util.List;
+import java.util.Map;
+
/**
* Test fixture for ContentTypeInterceptor integration tests.
* Has annotated and unannotated properties to exercise authorization
filtering.
@@ -31,6 +34,10 @@ public class SecureRestAction extends ActionSupport {
private String role;
private Address address;
private Address shallowAddress;
+ private List<String> tags;
+ private List<Address> addresses;
+ private Map<String, String> attributes;
+ private String[] aliases;
public String getName() { return name; }
@@ -57,6 +64,30 @@ public class SecureRestAction extends ActionSupport {
@StrutsParameter
public void setShallowAddress(Address shallowAddress) {
this.shallowAddress = shallowAddress; }
+ @StrutsParameter(depth = 1)
+ public List<String> getTags() { return tags; }
+
+ @StrutsParameter
+ public void setTags(List<String> tags) { this.tags = tags; }
+
+ @StrutsParameter(depth = 2)
+ public List<Address> getAddresses() { return addresses; }
+
+ @StrutsParameter
+ public void setAddresses(List<Address> addresses) { this.addresses =
addresses; }
+
+ @StrutsParameter(depth = 1)
+ public Map<String, String> getAttributes() { return attributes; }
+
+ @StrutsParameter
+ public void setAttributes(Map<String, String> attributes) {
this.attributes = attributes; }
+
+ @StrutsParameter(depth = 1)
+ public String[] getAliases() { return aliases; }
+
+ @StrutsParameter
+ public void setAliases(String[] aliases) { this.aliases = aliases; }
+
public static class Address {
private String city;
private String zip;
diff --git
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/JuneauXmlHandlerTest.java
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/JuneauXmlHandlerTest.java
index 488214e8a..78966bf5d 100644
---
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/JuneauXmlHandlerTest.java
+++
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/JuneauXmlHandlerTest.java
@@ -31,6 +31,7 @@ import java.util.Arrays;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class JuneauXmlHandlerTest extends XWorkTestCase {
@@ -94,4 +95,12 @@ public class JuneauXmlHandlerTest extends XWorkTestCase {
.containsExactly("Adam", "Ewa");
}
+ public void testMalformedXmlIsWrappedInIOException() {
+ SimpleBean obj = new SimpleBean();
+ Reader in = new StringReader("<object><name>unterminated");
+
+ assertThatThrownBy(() -> handler.toObject(ai, in, obj))
+ .isInstanceOf(java.io.IOException.class);
+ }
+
}
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
new file mode 100644
index 000000000..0fe8dd875
--- /dev/null
+++
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
@@ -0,0 +1,175 @@
+/*
+ * 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.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import junit.framework.TestCase;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
+
+public class ParameterAuthorizingModuleTest extends TestCase {
+
+ private ObjectMapper mapper;
+
+ @Override
+ protected void setUp() {
+ mapper = new ObjectMapper().registerModule(new
ParameterAuthorizingModule());
+ }
+
+ @Override
+ protected void tearDown() {
+ ParameterAuthorizationContext.unbind();
+ }
+
+ private void bind(ParameterAuthorizer authorizer, Object instance) {
+ ParameterAuthorizationContext.bind(authorizer, instance, instance);
+ }
+
+ public void testNoContext_passThrough() throws Exception {
+ // No bind → wrapper is a no-op
+ Person p = mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}",
Person.class);
+ assertEquals("alice", p.name);
+ assertEquals("admin", p.role);
+ }
+
+ public void testTopLevelAuthorized() throws Exception {
+ bind((path, t, a) -> "name".equals(path), new Person());
+ Person result =
mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}", Person.class);
+ assertEquals("alice", result.name);
+ assertNull(result.role);
+ }
+
+ public void testNestedPropertyAuthorizedByPath() throws Exception {
+ bind((path, t, a) -> "address".equals(path) ||
"address.city".equals(path), new Person());
+ Person result = mapper.readValue(
+ "{\"address\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}",
Person.class);
+ assertNotNull(result.address);
+ assertEquals("Warsaw", result.address.city);
+ assertNull(result.address.zip);
+ }
+
+ public void testNestedRejectedAtParent() throws Exception {
+ bind((path, t, a) -> "name".equals(path), new Person());
+ Person result = mapper.readValue(
+ "{\"name\":\"alice\",\"address\":{\"city\":\"Warsaw\"}}",
Person.class);
+ assertEquals("alice", result.name);
+ assertNull(result.address);
+ }
+
+ public void testListUsesIndexedPath() throws Exception {
+ bind((path, t, a) -> "addresses".equals(path) ||
"addresses[0].city".equals(path), new Person());
+ Person result = mapper.readValue(
+ "{\"addresses\":[{\"city\":\"Warsaw\",\"zip\":\"00-001\"}]}",
Person.class);
+ assertEquals(1, result.addresses.size());
+ assertEquals("Warsaw", result.addresses.get(0).city);
+ assertNull(result.addresses.get(0).zip);
+ }
+
+ public void testArrayUsesIndexedPath() throws Exception {
+ bind((path, t, a) -> "addressArray".equals(path) ||
"addressArray[0].city".equals(path), new Person());
+ Person result = mapper.readValue(
+
"{\"addressArray\":[{\"city\":\"Warsaw\",\"zip\":\"00-001\"}]}", Person.class);
+ assertEquals(1, result.addressArray.length);
+ assertEquals("Warsaw", result.addressArray[0].city);
+ assertNull(result.addressArray[0].zip);
+ }
+
+ public void testMapUsesIndexedPath() throws Exception {
+ bind((path, t, a) -> "addressMap".equals(path) ||
"addressMap[0].city".equals(path), new Person());
+ Person result = mapper.readValue(
+
"{\"addressMap\":{\"home\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}}",
Person.class);
+ assertNotNull(result.addressMap.get("home"));
+ assertEquals("Warsaw", result.addressMap.get("home").city);
+ assertNull(result.addressMap.get("home").zip);
+ }
+
+ public void testPathStackCleanAfterDeserialization() throws Exception {
+ bind((path, t, a) -> true, new Person());
+
mapper.readValue("{\"name\":\"alice\",\"address\":{\"city\":\"Warsaw\"}}",
Person.class);
+ assertEquals("path stack must be empty after deserialization", "",
+ ParameterAuthorizationContext.currentPathPrefix());
+ }
+
+ public void testBuilderDeserializationNoContextPassThrough() throws
Exception {
+ // No bind → AuthorizingSettableBeanProperty.deserializeSetAndReturn
falls through
+ // to the delegate without consulting the authorization context.
+ ImmutablePerson p =
mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}",
ImmutablePerson.class);
+ assertEquals("alice", p.name);
+ assertEquals("admin", p.role);
+ }
+
+ public void testBuilderDeserializationAuthorizedTopLevel() throws
Exception {
+ bind((path, t, a) -> "name".equals(path), new
ImmutablePerson.Builder());
+ ImmutablePerson p =
mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}",
ImmutablePerson.class);
+ assertEquals("alice", p.name);
+ assertNull("unauthorized property must be skipped on builder path",
p.role);
+ }
+
+ public void testBuilderDeserializationRejectsAllProperties() throws
Exception {
+ bind((path, t, a) -> false, new ImmutablePerson.Builder());
+ ImmutablePerson p =
mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}",
ImmutablePerson.class);
+ assertNull(p.name);
+ assertNull(p.role);
+ }
+
+ // --- Fixtures ---
+
+ public static class Person {
+ public String name;
+ public String role;
+ public Address address;
+ public java.util.List<Address> addresses;
+ public Address[] addressArray;
+ public java.util.Map<String, Address> addressMap;
+ }
+
+ public static class Address {
+ public String city;
+ public String zip;
+ }
+
+ /**
+ * Builder-pattern fixture: forces Jackson to use {@code
BuilderBasedDeserializer},
+ * which dispatches property deserialization through {@code
SettableBeanProperty.deserializeSetAndReturn}
+ * — the alternate code path on {@code AuthorizingSettableBeanProperty}
not exercised by
+ * setter-based fixtures like {@link Person}.
+ */
+ @JsonDeserialize(builder = ImmutablePerson.Builder.class)
+ public static final class ImmutablePerson {
+ public final String name;
+ public final String role;
+
+ private ImmutablePerson(Builder b) {
+ this.name = b.name;
+ this.role = b.role;
+ }
+
+ @JsonPOJOBuilder(withPrefix = "set")
+ public static class Builder {
+ private String name;
+ private String role;
+
+ public Builder setName(String n) { this.name = n; return this; }
+ public Builder setRole(String r) { this.role = r; return this; }
+ public ImmutablePerson build() { return new ImmutablePerson(this);
}
+ }
+ }
+}