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 <noreply@anthropic.com>

* WW-5626 address review feedback on ParameterAuthorizationContext

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukasz Lenart
2026-05-14 15:01:00 +02:00
committed by GitHub
parent a48f57d161
commit 09d03286f8
15 changed files with 1252 additions and 41 deletions
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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();
@@ -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.
}
@@ -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 {
@@ -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 {
@@ -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());
@@ -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);
@@ -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;
}
}
@@ -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;
}
});
}
}
@@ -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;
}
}
}
@@ -0,0 +1,178 @@
/*
* 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;
import com.mockobjects.dynamic.AnyConstraintMatcher;
import com.mockobjects.dynamic.Mock;
import junit.framework.TestCase;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.action.Action;
import org.apache.struts2.dispatcher.mapper.ActionMapping;
import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
import org.apache.struts2.ognl.DefaultOgnlBeanInfoCacheFactory;
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.JuneauXmlHandler;
import org.apache.struts2.util.StrutsProxyService;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
/**
* 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 JuneauXmlHandlerIntegrationTest extends TestCase {
private ContentTypeInterceptor interceptor;
private SecureRestAction action;
private Mock mockActionInvocation;
private Mock mockSelector;
@Override
protected void setUp() throws Exception {
super.setUp();
action = new SecureRestAction();
var ognlUtil = new OgnlUtil(
new DefaultOgnlExpressionCacheFactory<>("1000", LRU.toString()),
new DefaultOgnlBeanInfoCacheFactory<>("1000", LRU.toString()),
new StrutsOgnlGuard());
var proxyService = new StrutsProxyService(new StrutsProxyCacheFactory<>("1000", "basic"));
StrutsParameterAuthorizer authorizer = new StrutsParameterAuthorizer();
authorizer.setOgnlUtil(ognlUtil);
authorizer.setProxyService(proxyService);
authorizer.setRequireAnnotations(Boolean.TRUE.toString());
interceptor = new ContentTypeInterceptor();
interceptor.setParameterAuthorizer(authorizer);
interceptor.setRequireAnnotations(Boolean.TRUE.toString());
mockActionInvocation = new Mock(ActionInvocation.class);
mockSelector = new Mock(ContentTypeHandlerManager.class);
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 JuneauXmlHandler());
interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager) mockSelector.proxy());
}
private void runWithBody(String body) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContent(body.getBytes());
request.setContentType("application/xml");
ActionContext.of()
.withActionMapping(new ActionMapping())
.withServletRequest(request)
.bind();
interceptor.intercept((ActionInvocation) mockActionInvocation.proxy());
mockSelector.verify();
mockActionInvocation.verify();
}
public void testAnnotatedTopLevelPropertyIsApplied() throws Exception {
runWithBody("<object><name>alice</name></object>");
assertEquals("alice", action.getName());
}
public void testUnannotatedTopLevelPropertyIsRejected() throws Exception {
runWithBody("<object><role>admin</role></object>");
assertNull("unannotated 'role' must not be set on target", action.getRole());
}
public void testMixedPropertiesFilteredCorrectly() throws Exception {
runWithBody("<object><name>alice</name><role>admin</role></object>");
assertEquals("alice", action.getName());
assertNull(action.getRole());
}
public void testNestedPropertyAuthorizedWhenDepthAllows() throws Exception {
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: 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());
}
}
@@ -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;
@@ -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);
}
}
@@ -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); }
}
}
}