From 512b98ff18ebaf35a20da189a59be6bec8119aa9 Mon Sep 17 00:00:00 2001 From: g0w6y Date: Thu, 9 Jul 2026 19:13:00 +0530 Subject: [PATCH] fix(rest): authorize @StrutsParameter on record/creator-bound REST body properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParameterAuthorizingModule enforces @StrutsParameter on REST/JSON body deserialization by wrapping each property's deserializeAndSet/ deserializeSetAndReturn. Jackson never calls either method for creator-bound properties (Java records, @JsonCreator constructors, @ConstructorProperties) — it calls SettableBeanProperty#deserialize directly, which is declared final and bypasses the wrapper entirely. With struts.parameters.requireAnnotations enabled, any record-typed field anywhere in a REST action's request body was populated with no authorization check at all. Add AuthorizingValueDeserializer, which wraps the property's value deserializer instead of the property itself, and install it from AuthorizingSettableBeanProperty#withValueDeserializer — scoped to CreatorProperty so ordinary setter/field/builder properties, already authorized via the existing wrapper, aren't checked twice. --- .../AuthorizingSettableBeanProperty.java | 18 ++++ .../jackson/AuthorizingValueDeserializer.java | 87 +++++++++++++++++++ .../ParameterAuthorizingModuleTest.java | 17 ++++ 3 files changed, 122 insertions(+) create mode 100644 plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java diff --git a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java index d6c3a812aa..800c67419a 100644 --- a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java +++ b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java @@ -21,6 +21,8 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.CreatorProperty; import com.fasterxml.jackson.databind.deser.SettableBeanProperty; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -58,6 +60,22 @@ protected SettableBeanProperty withDelegate(SettableBeanProperty d) { return new AuthorizingSettableBeanProperty(d); } + /** + * Creator-bound properties (records, {@code @JsonCreator} constructors) never reach + * {@link #deserializeAndSet}/{@link #deserializeSetAndReturn}: Jackson calls the {@code final} + * {@code SettableBeanProperty#deserialize} directly, through this property's own value deserializer. + * Wrap that deserializer with {@link AuthorizingValueDeserializer}, scoped to {@link CreatorProperty} + * so ordinary setter/field/builder properties -- already authorized below -- aren't double-checked. + */ + @Override + public SettableBeanProperty withValueDeserializer(JsonDeserializer deser) { + JsonDeserializer effective = deser; + if (delegate instanceof CreatorProperty && !(deser instanceof AuthorizingValueDeserializer)) { + effective = new AuthorizingValueDeserializer(deser, getName()); + } + return _with(delegate.withValueDeserializer(effective)); + } + @Override public void deserializeAndSet(JsonParser p, DeserializationContext ctxt, Object instance) throws IOException { if (!ParameterAuthorizationContext.isActive()) { diff --git a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java new file mode 100644 index 0000000000..fc31237e15 --- /dev/null +++ b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java @@ -0,0 +1,87 @@ +/* + * 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.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext; + +import java.io.IOException; +import java.util.Collection; +import java.util.Map; + +/** + * Enforces {@code @StrutsParameter} authorization for creator-bound properties (Java records, + * {@code @JsonCreator} constructors, {@code @ConstructorProperties}), which Jackson deserializes + * through the value deserializer directly rather than through a {@code SettableBeanProperty}. + * See {@link AuthorizingSettableBeanProperty#withValueDeserializer} for where this is installed. + */ +final class AuthorizingValueDeserializer extends DelegatingDeserializer { + + private static final Logger LOG = LogManager.getLogger(AuthorizingValueDeserializer.class); + + private final String propertyName; + + AuthorizingValueDeserializer(JsonDeserializer delegate, String propertyName) { + super(delegate); + this.propertyName = propertyName; + } + + @Override + protected JsonDeserializer newDelegatingInstance(JsonDeserializer newDelegatee) { + return new AuthorizingValueDeserializer(newDelegatee, propertyName); + } + + @Override + public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + if (!ParameterAuthorizationContext.isActive()) { + return super.deserialize(p, ctxt); + } + String path = ParameterAuthorizationContext.pathFor(propertyName); + if (!ParameterAuthorizationContext.isAuthorized(path)) { + LOG.warn("REST body parameter [{}] rejected by @StrutsParameter authorization (creator-bound property)", path); + p.skipChildren(); + return null; + } + ParameterAuthorizationContext.pushPath(prefixForNested(path)); + try { + return super.deserialize(p, ctxt); + } finally { + ParameterAuthorizationContext.popPath(); + } + } + + /** + * For Collection / Map / Array-valued creator parameters, the path to push for nested element + * members is {@code path + "[0]"} -- matching {@code ParametersInterceptor} bracket-depth + * semantics, and {@link AuthorizingSettableBeanProperty#prefixForNested}. Scalar / bean-valued + * parameters push the path unchanged. + */ + private String prefixForNested(String pathOfThisProperty) { + Class type = handledType(); + if (type != null && (Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type) || type.isArray())) { + return pathOfThisProperty + "[0]"; + } + return pathOfThisProperty; + } +} diff --git a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java index 0fe8dd875a..c8ba5c100a 100644 --- a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java +++ b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java @@ -130,6 +130,15 @@ public void testBuilderDeserializationRejectsAllProperties() throws Exception { assertNull(p.role); } + public void testRecordComponentAuthorizedByPath() throws Exception { + bind((path, t, a) -> "recordAddress".equals(path) || "recordAddress.city".equals(path), new Person()); + Person result = mapper.readValue( + "{\"recordAddress\":{\"city\":\"Warsaw\",\"secret\":\"admin-only\"}}", Person.class); + assertNotNull(result.recordAddress); + assertEquals("Warsaw", result.recordAddress.city()); + assertNull(result.recordAddress.secret()); + } + // --- Fixtures --- public static class Person { @@ -139,6 +148,7 @@ public static class Person { public java.util.List
addresses; public Address[] addressArray; public java.util.Map addressMap; + public RecordAddress recordAddress; } public static class Address { @@ -146,6 +156,13 @@ public static class Address { public String zip; } + /** + * Record fixture: forces Jackson to bind {@code city}/{@code secret} via its creator/constructor + * path, the code path {@link AuthorizingSettableBeanProperty#withValueDeserializer} covers. + */ + public record RecordAddress(String city, String secret) { + } + /** * Builder-pattern fixture: forces Jackson to use {@code BuilderBasedDeserializer}, * which dispatches property deserialization through {@code SettableBeanProperty.deserializeSetAndReturn}