mirror of
https://github.com/apache/struts.git
synced 2026-08-05 14:47:09 +00:00
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:
+144
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -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();
|
||||
|
||||
+46
@@ -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);
|
||||
|
||||
+114
@@ -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;
|
||||
}
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+82
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+178
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+175
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user