Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions core/src/main/java/org/apache/struts2/StrutsConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,14 @@ public final class StrutsConstants {
*/
public static final String STRUTS_PARAMETER_AUTHORIZER = "struts.parameterAuthorizer";

/**
* The {@link org.apache.struts2.interceptor.parameter.ParameterAllowlister} implementation class.
* Override to provide a custom allowlister for non-OGNL parameter targets.
*
* @since 7.2.0
*/
public static final String STRUTS_PARAMETER_ALLOWLISTER = "struts.parameterAllowlister";

/**
* Enables evaluation of OGNL expressions
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
import org.apache.struts2.url.UrlEncoder;
import org.apache.struts2.util.ContentTypeMatcher;
import org.apache.struts2.util.PatternMatcher;
import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.util.ProxyService;
import org.apache.struts2.util.TextParser;
Expand Down Expand Up @@ -448,6 +449,7 @@ public void register(ContainerBuilder builder, LocatableProperties props) {
alias(ProxyCacheFactory.class, StrutsConstants.STRUTS_PROXY_CACHE_FACTORY, builder, props, Scope.SINGLETON);
alias(ProxyService.class, StrutsConstants.STRUTS_PROXYSERVICE, builder, props, Scope.SINGLETON);
alias(ParameterAuthorizer.class, StrutsConstants.STRUTS_PARAMETER_AUTHORIZER, builder, props, Scope.SINGLETON);
alias(ParameterAllowlister.class, StrutsConstants.STRUTS_PARAMETER_ALLOWLISTER, builder, props, Scope.SINGLETON);

alias(SecurityMemberAccess.class, StrutsConstants.STRUTS_MEMBER_ACCESS, builder, props, Scope.PROTOTYPE);
alias(OgnlGuard.class, StrutsConstants.STRUTS_OGNL_GUARD, builder, props, Scope.SINGLETON);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@
import org.apache.struts2.ognl.accessor.CompoundRootAccessor;
import org.apache.struts2.ognl.accessor.RootAccessor;
import org.apache.struts2.ognl.accessor.XWorkMethodAccessor;
import org.apache.struts2.interceptor.parameter.OgnlParameterAllowlister;
import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.util.StrutsProxyService;
Expand Down Expand Up @@ -409,6 +411,7 @@ public static ContainerBuilder bootstrapFactories(ContainerBuilder builder) {
.factory(ProxyCacheFactory.class, StrutsProxyCacheFactory.class, Scope.SINGLETON)
.factory(ProxyService.class, StrutsProxyService.class, Scope.SINGLETON)
.factory(ParameterAuthorizer.class, StrutsParameterAuthorizer.class, Scope.SINGLETON)
.factory(ParameterAllowlister.class, OgnlParameterAllowlister.class, Scope.SINGLETON)
.factory(OgnlUtil.class, Scope.SINGLETON)
.factory(SecurityMemberAccess.class, Scope.PROTOTYPE)
.factory(OgnlGuard.class, StrutsOgnlGuard.class, Scope.SINGLETON)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import org.apache.struts2.ServletActionContext;
import org.apache.struts2.action.CookiesAware;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.security.AcceptedPatternsChecker;
import org.apache.struts2.security.ExcludedPatternsChecker;
import org.apache.struts2.util.TextParseUtil;
Expand Down Expand Up @@ -99,8 +101,16 @@
*
* <ul>
* <li>
* populateCookieValueIntoStack - this method will decide if this cookie value is qualified
* to be populated into the value stack (hence into the action itself)
* populateCookieValueIntoStack(name, value, map, stack, action) - the preferred extension point
* since 7.2.0. The default implementation gates the cookie write through
* {@link org.apache.struts2.interceptor.parameter.ParameterAuthorizer} and primes the OGNL allowlist via
* {@link org.apache.struts2.interceptor.parameter.ParameterAllowlister} before delegating to the legacy
* 4-arg {@code populateCookieValueIntoStack}. Override here to customize the authorization behavior itself.
* </li>
* <li>
* populateCookieValueIntoStack(name, value, map, stack) - <em>deprecated since 7.2.0</em>. The legacy
* hook that performs the actual {@code stack.setValue}. Existing overrides continue to work and
* automatically receive only authorized cookies via the 5-arg default.
* </li>
* <li>
* injectIntoCookiesAwareAction - this method will inject selected cookies (as a java.util.Map)
Expand Down Expand Up @@ -187,6 +197,8 @@ public class CookieInterceptor extends AbstractInterceptor {

private ExcludedPatternsChecker excludedPatternsChecker;
private AcceptedPatternsChecker acceptedPatternsChecker;
private transient ParameterAuthorizer parameterAuthorizer;
private transient ParameterAllowlister parameterAllowlister;

@Inject
public void setExcludedPatternsChecker(ExcludedPatternsChecker excludedPatternsChecker) {
Expand All @@ -199,6 +211,16 @@ public void setAcceptedPatternsChecker(AcceptedPatternsChecker acceptedPatternsC
this.acceptedPatternsChecker.setAcceptedPatterns(ACCEPTED_PATTERN);
}

@Inject
public void setParameterAuthorizer(ParameterAuthorizer parameterAuthorizer) {
this.parameterAuthorizer = parameterAuthorizer;
}

@Inject
public void setParameterAllowlister(ParameterAllowlister parameterAllowlister) {
this.parameterAllowlister = parameterAllowlister;
}

/**
* @param cookiesName the <code>cookiesName</code> which if matched will allow the cookie
* to be injected into action, could be comma-separated string.
Expand Down Expand Up @@ -234,6 +256,8 @@ public void setAcceptCookieNames(String commaDelimitedPattern) {
public String intercept(ActionInvocation invocation) throws Exception {
LOG.debug("start interception");

final Object action = invocation.getAction();

// contains selected cookies
final Map<String, String> cookiesMap = new LinkedHashMap<>();

Expand All @@ -248,9 +272,9 @@ public String intercept(ActionInvocation invocation) throws Exception {
if (isAcceptableName(name)) {
if (cookiesNameSet.contains("*")) {
LOG.debug("Contains cookie name [*] in configured cookies name set, cookie with name [{}] with value [{}] will be injected", name, value);
populateCookieValueIntoStack(name, value, cookiesMap, stack);
populateCookieValueIntoStack(name, value, cookiesMap, stack, action);
} else if (cookiesNameSet.contains(cookie.getName())) {
populateCookieValueIntoStack(name, value, cookiesMap, stack);
populateCookieValueIntoStack(name, value, cookiesMap, stack, action);
}
} else {
LOG.warn("Cookie name [{}] with value [{}] was rejected!", name, value);
Expand All @@ -259,7 +283,7 @@ public String intercept(ActionInvocation invocation) throws Exception {
}

// inject the cookiesMap, even if we don't have any cookies
injectIntoCookiesAwareAction(invocation.getAction(), cookiesMap);
injectIntoCookiesAwareAction(action, cookiesMap);

return invocation.invoke();
}
Expand Down Expand Up @@ -314,6 +338,30 @@ protected boolean isExcluded(String name) {
return false;
}

/**
* Authorizes the cookie against {@link ParameterAuthorizer}, primes OGNL allowlist for any nested path via
* {@link ParameterAllowlister}, then delegates to the legacy {@link #populateCookieValueIntoStack(String, String,
* Map, ValueStack)} hook so existing subclass overrides continue to participate. Override this method to customize
* the authorization behavior itself.
*
* @param cookieName cookie name (potentially an OGNL path; {@code ACCEPTED_PATTERN} restricts the character set)
* @param cookieValue cookie value
* @param cookiesMap map of cookies populated for {@link org.apache.struts2.action.CookiesAware}
* @param stack current request value stack
* @param action the action instance from {@link ActionInvocation#getAction()}; used for {@code @StrutsParameter} target resolution
* @since 7.2.0
*/
@SuppressWarnings("deprecation") // intentional: delegating to the deprecated 4-arg form is the contract that lets existing subclass overrides participate
protected void populateCookieValueIntoStack(String cookieName, String cookieValue, Map<String, String> cookiesMap, ValueStack stack, Object action) {
Object target = parameterAuthorizer.resolveTarget(action);
if (!parameterAuthorizer.isAuthorized(cookieName, target, action)) {
LOG.debug("Cookie [{}] rejected by @StrutsParameter authorization on target [{}]", cookieName, target.getClass().getSimpleName());
return;
}
parameterAllowlister.allowlistAuthorizedPath(cookieName, target);
populateCookieValueIntoStack(cookieName, cookieValue, cookiesMap, stack);
}

/**
* Hook that populate cookie value into value stack (hence the action)
* if the criteria is satisfied (if the cookie value matches with those configured).
Expand All @@ -322,7 +370,12 @@ protected boolean isExcluded(String name) {
* @param cookieValue cookie value
* @param cookiesMap map of cookies
* @param stack value stack
* @deprecated since 7.2.0. Override
* {@link #populateCookieValueIntoStack(String, String, Map, ValueStack, Object)} instead so cookie writes are
* authorized by {@link ParameterAuthorizer}. The default 5-arg implementation calls this method after the
* authorization gate, so existing overrides continue to receive only authorized cookies.
*/
@Deprecated(since = "7.2.0")
protected void populateCookieValueIntoStack(String cookieName, String cookieValue, Map<String, String> cookiesMap, ValueStack stack) {
if (cookiesValueSet.isEmpty() || cookiesValueSet.contains("*")) {
// If the interceptor is configured to accept any cookie value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
* 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.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.ognl.ThreadAllowlist;
import org.apache.struts2.util.ProxyService;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Optional;

import static org.apache.commons.lang3.StringUtils.indexOfAny;
import static org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS;
import static org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS_STR;

/**
* Default {@link ParameterAllowlister} that primes OGNL {@link ThreadAllowlist} for nested-path writes. Logic is
* extracted verbatim from {@code ParametersInterceptor.performOgnlAllowlisting} so the OGNL parameter and cookie
* channels share a single implementation.
*
* <p>No-ops for depth-0 paths — root-level setters do not need allowlisting.</p>
*
* @since 7.2.0
*/
public class OgnlParameterAllowlister implements ParameterAllowlister {

private static final Logger LOG = LogManager.getLogger(OgnlParameterAllowlister.class);

private OgnlUtil ognlUtil;
private ProxyService proxyService;
private ThreadAllowlist threadAllowlist;

@Inject
public void setOgnlUtil(OgnlUtil ognlUtil) {
this.ognlUtil = ognlUtil;
}

@Inject
public void setProxyService(ProxyService proxyService) {
this.proxyService = proxyService;
}

@Inject
public void setThreadAllowlist(ThreadAllowlist threadAllowlist) {
this.threadAllowlist = threadAllowlist;
}

@Override
public void allowlistAuthorizedPath(String parameterName, Object target) {
if (parameterName == null || parameterName.isEmpty() || target == null) {
return;
}
long paramDepth = parameterName.codePoints().mapToObj(c -> (char) c).filter(NESTING_CHARS::contains).count();
if (paramDepth == 0) {
return;
}

int nestingIndex = indexOfAny(parameterName, NESTING_CHARS_STR);
String rootProperty = nestingIndex == -1 ? parameterName : parameterName.substring(0, nestingIndex);
String normalisedRootProperty = Character.toLowerCase(rootProperty.charAt(0)) + rootProperty.substring(1);

if (allowlistViaPropertyDescriptor(target, normalisedRootProperty, paramDepth)) {
return;
}
allowlistViaPublicField(target, normalisedRootProperty, paramDepth);
}

private boolean allowlistViaPropertyDescriptor(Object target, String rootProperty, long paramDepth) {
BeanInfo beanInfo = getBeanInfo(target);
if (beanInfo == null) {
return false;
}
Optional<PropertyDescriptor> propDescOpt = Arrays.stream(beanInfo.getPropertyDescriptors())
.filter(desc -> desc.getName().equals(rootProperty)).findFirst();
if (propDescOpt.isEmpty()) {
return false;
}
PropertyDescriptor propDesc = propDescOpt.get();
Method relevantMethod = propDesc.getReadMethod();
if (relevantMethod == null || getPermittedInjectionDepth(relevantMethod) < paramDepth) {
return false;
}
allowlistClass(propDesc.getPropertyType());
if (paramDepth >= 2) {
allowlistParameterizedTypeArg(relevantMethod.getGenericReturnType());
}
return true;
}

private void allowlistViaPublicField(Object target, String rootProperty, long paramDepth) {
Class<?> targetClass = ultimateClass(target);
Field field;
try {
field = targetClass.getDeclaredField(rootProperty);
} catch (NoSuchFieldException e) {
return;
}
if (!Modifier.isPublic(field.getModifiers()) || getPermittedInjectionDepth(field) < paramDepth) {
return;
}
allowlistClass(field.getType());
if (paramDepth >= 2) {
allowlistParameterizedTypeArg(field.getGenericType());
}
}

private void allowlistClass(Class<?> clazz) {
threadAllowlist.allowClassHierarchy(clazz);
}

private void allowlistParameterizedTypeArg(Type genericType) {
if (!(genericType instanceof ParameterizedType pType)) {
return;
}
Type[] paramTypes = pType.getActualTypeArguments();
allowlistParamType(paramTypes[0]);
if (paramTypes.length > 1) {
allowlistParamType(paramTypes[1]);
}
}

private void allowlistParamType(Type paramType) {
if (paramType instanceof Class<?> clazz) {
allowlistClass(clazz);
}
}

private int getPermittedInjectionDepth(AnnotatedElement element) {
StrutsParameter annotation = element.getAnnotation(StrutsParameter.class);
return annotation == null ? -1 : annotation.depth();
}

private Class<?> ultimateClass(Object target) {
if (proxyService.isProxy(target)) {
return proxyService.ultimateTargetClass(target);
}
return target.getClass();
}

private BeanInfo getBeanInfo(Object target) {
Class<?> targetClass = ultimateClass(target);
try {
return ognlUtil.getBeanInfo(targetClass);
} catch (IntrospectionException e) {
LOG.warn("Error introspecting target {} for OGNL allowlisting", targetClass, e);
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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;

/**
* Service for priming downstream allowlists (e.g. the OGNL {@link org.apache.struts2.ognl.ThreadAllowlist}) for a
* parameter path that has already been authorized by {@link ParameterAuthorizer}. Separated from the authorizer so
* that the authorizer can remain side-effect-free and reusable from non-OGNL channels (Jackson, Juneau).
*
* <p>Implementations are expected to no-op when {@code parameterName} is depth-0 if their downstream engine does not
* require root-level priming. Callers must have already verified authorization via
* {@link ParameterAuthorizer#isAuthorized}; this service does NOT enforce annotations.</p>
*
* @since 7.2.0
*/
public interface ParameterAllowlister {

/**
* Primes the underlying allowlist for an authorized parameter path.
*
* @param parameterName the parameter name (e.g. {@code "user.role"}, {@code "items[0].name"})
* @param target the object receiving the parameter value (the action, or the model for ModelDriven actions)
*/
void allowlistAuthorizedPath(String parameterName, Object target);
}
Loading
Loading