OgnlUtil.java

/*
 * 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.ognl;

import ognl.ClassResolver;
import ognl.Ognl;
import ognl.OgnlContext;
import ognl.OgnlException;
import ognl.OgnlRuntime;
import ognl.SimpleNode;
import ognl.TypeConverter;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.conversion.impl.XWorkConverter;
import org.apache.struts2.inject.Container;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.ognl.accessor.RootAccessor;
import org.apache.struts2.util.CompoundRoot;
import org.apache.struts2.util.reflection.ReflectionException;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

import static java.util.Objects.requireNonNull;
import static org.apache.struts2.ognl.OgnlGuard.EXPR_BLOCKED;


/**
 * Utility class that provides common access to the Ognl APIs for
 * setting and getting properties from objects (usually Actions).
 *
 * @author Jason Carreira
 */
public class OgnlUtil {

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

    private final OgnlCache<String, Object> expressionCache;
    private final OgnlCache<Class<?>, BeanInfo> beanInfoCache;
    private TypeConverter defaultConverter;
    private final OgnlGuard ognlGuard;

    private boolean devMode;
    private boolean enableExpressionCache = true;
    private boolean enableEvalExpression;

    private Container container;

    /**
     * Construct a new OgnlUtil instance for use with the framework, with optional cache factories for OGNL Expression
     * and BeanInfo caches.
     *
     * @param ognlExpressionCacheFactory factory for Expression cache instance
     * @param ognlBeanInfoCacheFactory   factory for BeanInfo cache instance
     * @param ognlGuard                  OGNL Guard instance
     */
    @Inject
    public OgnlUtil(@Inject ExpressionCacheFactory<String, Object> ognlExpressionCacheFactory,
                    @Inject BeanInfoCacheFactory<Class<?>, BeanInfo> ognlBeanInfoCacheFactory,
                    @Inject OgnlGuard ognlGuard) {
        this.expressionCache =  requireNonNull(ognlExpressionCacheFactory).buildOgnlCache();
        this.beanInfoCache =  requireNonNull(ognlBeanInfoCacheFactory).buildOgnlCache();
        this.ognlGuard = requireNonNull(ognlGuard);
    }

    @Inject
    protected void setXWorkConverter(XWorkConverter conv) {
        this.defaultConverter = new OgnlTypeConverterWrapper(conv);
    }

    @Inject(StrutsConstants.STRUTS_DEVMODE)
    protected void setDevMode(String mode) {
        this.devMode = BooleanUtils.toBoolean(mode);
    }

    @Inject(value = StrutsConstants.STRUTS_OGNL_ENABLE_EXPRESSION_CACHE, required = false)
    protected void setEnableExpressionCache(String cache) {
        enableExpressionCache = BooleanUtils.toBoolean(cache);
    }

    @Inject(value = StrutsConstants.STRUTS_OGNL_ENABLE_EVAL_EXPRESSION, required = false)
    protected void setEnableEvalExpression(String evalExpression) {
        this.enableEvalExpression = BooleanUtils.toBoolean(evalExpression);
        if (this.enableEvalExpression) {
            LOG.warn("Enabling OGNL expression evaluation may introduce security risks " +
                    "(see http://struts.apache.org/release/2.3.x/docs/s2-013.html for further details)");
        }
    }

    @Inject
    protected void setContainer(Container container) {
        this.container = container;
    }

    /**
     * @param maxLength Injects the Struts OGNL expression maximum length.
     */
    @Inject(value = StrutsConstants.STRUTS_OGNL_EXPRESSION_MAX_LENGTH, required = false)
    protected void applyExpressionMaxLength(String maxLength) {
        try {
            if (maxLength == null || maxLength.isEmpty()) {
                Ognl.applyExpressionMaxLength(null);
                LOG.warn("OGNL Expression Max Length disabled.");
            } else {
                Ognl.applyExpressionMaxLength(Integer.parseInt(maxLength));
                LOG.debug("OGNL Expression Max Length enabled with {}.", maxLength);
            }
        } catch (Exception ex) {
            LOG.error("Unable to set OGNL Expression Max Length {}.", maxLength);  // Help configuration debugging.
            throw ex;
        }
    }

    /**
     * Convenience mechanism to clear the OGNL Runtime Cache via OgnlUtil.  May be utilized
     * by applications that generate many unique OGNL expressions over time.
     *
     * Note: This call affects the global OGNL cache, see ({@link ognl.OgnlRuntime#clearCache()} for details.
     *
     * Warning: Frequent calling if this method may negatively impact performance, but may be required
     *          to avoid memory exhaustion (resource leak) with too many OGNL expressions being cached.
     *
     * @since 2.5.21
     */
    public static void clearRuntimeCache() {
        OgnlRuntime.clearCache();
    }

    /**
     * Provide a mechanism to clear the OGNL expression cache.  May be utilized by applications
     * that generate many unique OGNL expressions over time.
     *
     * Note: This call affects the current OgnlUtil instance.  For Struts this is often a Singleton
     *       instance so it can be "effectively global".
     *
     * Warning: Frequent calling if this method may negatively impact performance, but may be required
     *          to avoid memory exhaustion (resource leak) with too many OGNL expressions being cached.
     *
     * @since 2.5.21
     */
    public void clearExpressionCache() {
        expressionCache.clear();
    }

    /**
     * Check the size of the expression cache (current number of elements).
     *
     * @return current number of elements in the expression cache.
     *
     * @since 2.5.21
     */
    public int expressionCacheSize() {
        return expressionCache.size();
    }

    /**
     * Provide a mechanism to clear the BeanInfo cache.  May be utilized by applications
     * that request BeanInfo and/or PropertyDescriptors for many unique classes or objects over time
     * (especially dynamic objects).
     *
     * Note: This call affects the current OgnlUtil instance.  For Struts this is often a Singleton
     *       instance so it can be "effectively global".
     *
     * Warning: Frequent calling if this method may negatively impact performance, but may be required
     *          to avoid memory exhaustion (resource leak) with too many BeanInfo elements being cached.
     *
     * @since 2.5.21
     */
    public void clearBeanInfoCache() {
        beanInfoCache.clear();
    }

    /**
     * Check the size of the BeanInfo cache (current number of elements).
     *
     * @return current number of elements in the BeanInfo cache.
     *
     * @since 2.5.21
     */
    public int beanInfoCacheSize() {
        return beanInfoCache.size();
    }

    /**
     * Sets the object's properties using the default type converter, defaulting to not throw
     * exceptions for problems setting the properties.
     *
     * @param props   the properties being set
     * @param o       the object
     * @param context the action context
     */
    public void setProperties(Map<String, ?> props, Object o, Map<String, Object> context) {
        setProperties(props, o, context, false);
    }

    /**
     * Sets the object's properties using the default type converter.
     *
     * @param props                   the properties being set
     * @param o                       the object
     * @param context                 the action context
     * @param throwPropertyExceptions boolean which tells whether it should throw exceptions for
     *                                problems setting the properties
     */
    public void setProperties(Map<String, ?> props, Object o, Map<String, Object> context, boolean throwPropertyExceptions) throws ReflectionException{
        if (props == null) {
            return;
        }

        Object oldRoot = Ognl.getRoot(context);
        Ognl.setRoot(context, o);

        for (Map.Entry<String, ?> entry : props.entrySet()) {
            String expression = entry.getKey();
            internalSetProperty(expression, entry.getValue(), o, context, throwPropertyExceptions);
        }

        Ognl.setRoot(context, oldRoot);
    }

    /**
     * Sets the properties on the object using the default context, defaulting to not throwing
     * exceptions for problems setting the properties.
     *
     * @param properties map of properties
     * @param o object
     */
    public void setProperties(Map<String, ?> properties, Object o) {
        setProperties(properties, o, false);
    }

    /**
     * Sets the properties on the object using the default context.
     *
     * @param properties              the property map to set on the object
     * @param o                       the object to set the properties into
     * @param throwPropertyExceptions boolean which tells whether it should throw exceptions for
     *                                problems setting the properties
     */
    public void setProperties(Map<String, ?> properties, Object o, boolean throwPropertyExceptions) {
        Map<String, Object> context = createDefaultContext(o);
        setProperties(properties, o, context, throwPropertyExceptions);
    }

    /**
     * Sets the named property to the supplied value on the Object, defaults to not throwing
     * property exceptions.
     *
     * @param name    the name of the property to be set
     * @param value   the value to set into the named property
     * @param o       the object upon which to set the property
     * @param context the context which may include the TypeConverter
     */
    public void setProperty(String name, Object value, Object o, Map<String, Object> context) {
        setProperty(name, value, o, context, false);
    }

    /**
     * Sets the named property to the supplied value on the Object.
     *
     * @param name                    the name of the property to be set
     * @param value                   the value to set into the named property
     * @param o                       the object upon which to set the property
     * @param context                 the context which may include the TypeConverter
     * @param throwPropertyExceptions boolean which tells whether it should throw exceptions for
     *                                problems setting the property
     */
    public void setProperty(String name, Object value, Object o, Map<String, Object> context, boolean throwPropertyExceptions) {

        Object oldRoot = Ognl.getRoot(context);
        Ognl.setRoot(context, o);

        internalSetProperty(name, value, o, context, throwPropertyExceptions);

        Ognl.setRoot(context, oldRoot);
    }

    /**
     * Looks for the real target with the specified property given a root Object which may be a
     * CompoundRoot.
     *
     * @param property  the property
     * @param context context map
     * @param root compound root
     *
     * @return the real target or null if no object can be found with the specified property
     * @throws OgnlException in case of ognl errors
     */
    public Object getRealTarget(String property, Map<String, Object> context, Object root) throws OgnlException {
        //special keyword, they must be cutting the stack
        if ("top".equals(property)) {
            return root;
        }

        if (root instanceof CompoundRoot) {
            // find real target
            CompoundRoot cr = (CompoundRoot) root;

            try {
                for (Object target : cr) {
                    if (OgnlRuntime.hasSetProperty((OgnlContext) context, target, property)
                            || OgnlRuntime.hasGetProperty((OgnlContext) context, target, property)
                            || OgnlRuntime.getIndexedPropertyType((OgnlContext) context, target.getClass(), property) != OgnlRuntime.INDEXED_PROPERTY_NONE
                            ) {
                        return target;
                    }
                }
            } catch (IntrospectionException ex) {
                throw new ReflectionException("Cannot figure out real target class", ex);
            }

            return null;
        }

        return root;
    }

    /**
     * Wrapper around Ognl#setValue
     *
     * @param name  the name
     * @param context context map
     * @param root root
     * @param value value
     *
     * @throws OgnlException in case of ognl errors
     */
    public void setValue(final String name, final Map<String, Object> context, final Object root, final Object value) throws OgnlException {
        ognlSet(name, context, root, value, context, this::checkEvalExpression, this::checkArithmeticExpression);
    }

    private boolean isEvalExpression(Object tree, Map<String, Object> context) throws OgnlException {
        if (tree instanceof SimpleNode) {
            SimpleNode node = (SimpleNode) tree;
            OgnlContext ognlContext = null;

            if (context instanceof OgnlContext) {
                ognlContext = (OgnlContext) context;
            }
            return node.isEvalChain(ognlContext) || node.isSequence(ognlContext);
        }
        return false;
    }

    private boolean isArithmeticExpression(Object tree, Map<String, Object> context) throws OgnlException {
        if (tree instanceof SimpleNode) {
            SimpleNode node = (SimpleNode) tree;
            OgnlContext ognlContext = null;

            if (context instanceof OgnlContext) {
                ognlContext = (OgnlContext) context;
            }
            return node.isOperation(ognlContext);
        }
        return false;
    }

    private boolean isSimpleMethod(Object tree, Map<String, Object> context) throws OgnlException {
        if (tree instanceof SimpleNode) {
            SimpleNode node = (SimpleNode) tree;
            OgnlContext ognlContext = null;

            if (context instanceof OgnlContext) {
                ognlContext = (OgnlContext) context;
            }
            return node.isSimpleMethod(ognlContext) && !node.isChain(ognlContext);
        }
        return false;
    }

    public Object getValue(final String name, final Map<String, Object> context, final Object root) throws OgnlException {
        return getValue(name, context, root, null);
    }

    public Object callMethod(final String name, final Map<String, Object> context, final Object root) throws OgnlException {
        return ognlGet(name, context, root, null, context, this::checkSimpleMethod);
    }

    public Object getValue(final String name, final Map<String, Object> context, final Object root, final Class<?> resultType) throws OgnlException {
        return ognlGet(name, context, root, resultType, context, this::checkEnableEvalExpression);
    }

    public Object compile(String expression) throws OgnlException {
        return compile(expression, null);
    }

    private void ognlSet(String expr, Map<String, Object> context, Object root, Object value, Map<String, Object> checkContext, TreeValidator... treeValidators) throws OgnlException {
        Object tree = toTree(expr);
        for (TreeValidator validator : treeValidators) {
            validator.validate(tree, checkContext);
        }
        Ognl.setValue(tree, context, root, value);
    }

    private <T> T ognlGet(String expr, Map<String, Object> context, Object root, Class<T> resultType, Map<String, Object> checkContext, TreeValidator... treeValidators) throws OgnlException {
        Object tree = toTree(expr);
        for (TreeValidator validator : treeValidators) {
            validator.validate(tree, checkContext);
        }
        return (T) Ognl.getValue(tree, context, root, resultType);
    }

    private Object toTree(String expr) throws OgnlException {
        Object tree = null;
        if (enableExpressionCache) {
            tree = expressionCache.get(expr);
        }
        if (tree instanceof OgnlException) {
            // OgnlException was cached, rethrow it with empty stack trace (refilling the stack trace is expensive)
            clearStackTraceAndRethrow(tree);
        }
        if (tree == null) {
            try {
                tree = ognlGuard.parseExpression(expr);
            } catch (OgnlException e) {
                tree = e;
            }
            if (enableExpressionCache) {
                expressionCache.put(expr, tree);
            }
            if (tree instanceof OgnlException) {
                // Rethrow OgnlException after caching
                throw (OgnlException) tree;
            }
        }
        if (EXPR_BLOCKED.equals(tree)) {
            throw new OgnlException("Expression blocked by OgnlGuard: " + expr);
        }
        return tree;
    }

    private void clearStackTraceAndRethrow(Object ognlException) throws OgnlException {
        OgnlException e = (OgnlException) ognlException;
        e.setStackTrace(new StackTraceElement[0]);
        if (e.getCause() != null) {
            e.getCause().setStackTrace(new StackTraceElement[0]);
        }
        throw e;
    }

    public Object compile(String expression, Map<String, Object> context) throws OgnlException {
        Object tree = toTree(expression);
        checkEnableEvalExpression(tree, context);
        return tree;
    }

    private void checkEnableEvalExpression(Object tree, Map<String, Object> context) throws OgnlException {
        if (!enableEvalExpression && isEvalExpression(tree, context)) {
            throw new OgnlException("Eval expressions/chained expressions have been disabled!");
        }
    }

    private void checkSimpleMethod(Object tree, Map<String, Object> context) throws OgnlException {
        if (!isSimpleMethod(tree, context)) {
            throw new OgnlException("It isn't a simple method which can be called!");
        }
    }

    private void checkEvalExpression(Object tree, Map<String, Object> context) throws OgnlException {
        if (isEvalExpression(tree, context)) {
            throw new OgnlException("Eval expression/chained expressions cannot be used as parameter name");
        }
    }

    private void checkArithmeticExpression(Object tree, Map<String, Object> context) throws OgnlException {
        if (isArithmeticExpression(tree, context)) {
            throw new OgnlException("Arithmetic expressions cannot be used as parameter name");
        }
    }

    /**
     * Copies the properties in the object "from" and sets them in the object "to"
     * using specified type converter, or {@link XWorkConverter} if none
     * is specified.
     *
     * @param from       the source object
     * @param to         the target object
     * @param context    the action context we're running under
     * @param exclusions collection of method names to excluded from copying ( can be null)
     * @param inclusions collection of method names to included copying  (can be null)
     *                   note if exclusions AND inclusions are supplied and not null nothing will get copied.
     */
    public void copy(final Object from, final Object to, final Map<String, Object> context, Collection<String> exclusions, Collection<String> inclusions) {
        copy(from, to, context, exclusions, inclusions, null);
    }

    /**
     * Copies the properties in the object "from" and sets them in the object "to"
     * only setting properties defined in the given "editable" class (or interface)
     * using specified type converter, or {@link XWorkConverter} if none
     * is specified.
     *
     * @param from       the source object
     * @param to         the target object
     * @param context    the action context we're running under
     * @param exclusions collection of method names to excluded from copying ( can be null)
     * @param inclusions collection of method names to included copying  (can be null)
     *                   note if exclusions AND inclusions are supplied and not null nothing will get copied.
     * @param editable the class (or interface) to restrict property setting to
     */
    public void copy(final Object from,
                     final Object to,
                     final Map<String, Object> context,
                     Collection<String> exclusions,
                     Collection<String> inclusions,
                     Class<?> editable) {
        if (from == null || to == null) {
            LOG.warn(
                    "Skipping attempt to copy from, or to, a null source.", new RuntimeException());
            return;
        }

        final Map<String, Object> contextFrom = createDefaultContext(from);
        final Map<String, Object> contextTo = createDefaultContext(to);

        PropertyDescriptor[] fromPds;
        PropertyDescriptor[] toPds;

        try {
            fromPds = getPropertyDescriptors(from);
            if (editable != null) {
                toPds = getPropertyDescriptors(editable);
            } else {
                toPds = getPropertyDescriptors(to);
            }
        } catch (IntrospectionException e) {
            LOG.error("An error occurred", e);
            return;
        }

        Map<String, PropertyDescriptor> toPdHash = new HashMap<>();

        for (PropertyDescriptor toPd : toPds) {
            toPdHash.put(toPd.getName(), toPd);
        }

        for (PropertyDescriptor fromPd : fromPds) {
            if (fromPd.getReadMethod() == null) {
                continue;
            }

            if (exclusions != null && exclusions.contains(fromPd.getName()) ||
                    inclusions != null && !inclusions.contains(fromPd.getName())) {
                continue;
            }

            PropertyDescriptor toPd = toPdHash.get(fromPd.getName());
            if (toPd == null || toPd.getWriteMethod() == null) {
                continue;
            }

            try {
                Object value = ognlGet(fromPd.getName(),
                        contextFrom,
                        from,
                        null,
                        context,
                        this::checkEnableEvalExpression);
                ognlSet(fromPd.getName(), contextTo, to, value, context);
            } catch (OgnlException e) {
                LOG.debug("Got OGNL exception", e);
            }
        }
    }


    /**
     * Copies the properties in the object "from" and sets them in the object "to"
     * using specified type converter, or {@link XWorkConverter} if none
     * is specified.
     *
     * @param from    the source object
     * @param to      the target object
     * @param context the action context we're running under
     */
    public void copy(Object from, Object to, Map<String, Object> context) {
        copy(from, to, context, null, null);
    }

    /**
     * Gets the java beans property descriptors for the given source.
     *
     * @param source the source object.
     * @return property descriptors.
     * @throws IntrospectionException is thrown if an exception occurs during introspection.
     */
    public PropertyDescriptor[] getPropertyDescriptors(Object source) throws IntrospectionException {
        BeanInfo beanInfo = getBeanInfo(source);
        return beanInfo.getPropertyDescriptors();
    }


    /**
     * Get's the java beans property descriptors for the given class.
     *
     * @param clazz the source object.
     * @return property descriptors.
     * @throws IntrospectionException is thrown if an exception occurs during introspection.
     */
    public PropertyDescriptor[] getPropertyDescriptors(Class<?> clazz) throws IntrospectionException {
        BeanInfo beanInfo = getBeanInfo(clazz);
        return beanInfo.getPropertyDescriptors();
    }

    /**
     * Creates a Map with read properties for the given source object.
     * <p>
     * If the source object does not have a read property (i.e. write-only) then
     * the property is added to the map with the value <code>here is no read method for property-name</code>.
     * </p>
     *
     * @param source the source object.
     * @return a Map with (key = read property name, value = value of read property).
     * @throws IntrospectionException is thrown if an exception occurs during introspection.
     * @throws OgnlException          is thrown by OGNL if the property value could not be retrieved
     */
    public Map<String, Object> getBeanMap(final Object source) throws IntrospectionException, OgnlException {
        Map<String, Object> beanMap = new HashMap<>();
        final Map<String, Object> sourceMap = createDefaultContext(source);
        PropertyDescriptor[] propertyDescriptors = getPropertyDescriptors(source);
        for (PropertyDescriptor propertyDescriptor : propertyDescriptors) {
            final String propertyName = propertyDescriptor.getDisplayName();
            Method readMethod = propertyDescriptor.getReadMethod();
            if (readMethod != null) {
                final Object value = ognlGet(propertyName, sourceMap, source, null, null, this::checkEnableEvalExpression);
                beanMap.put(propertyName, value);
            } else {
                beanMap.put(propertyName, "There is no read method for " + propertyName);
            }
        }
        return beanMap;
    }

    /**
     * Get's the java bean info for the given source object. Calls getBeanInfo(Class c).
     *
     * @param from the source object.
     * @return java bean info.
     * @throws IntrospectionException is thrown if an exception occurs during introspection.
     */
    public BeanInfo getBeanInfo(Object from) throws IntrospectionException {
        return getBeanInfo(from.getClass());
    }


    /**
     * Get's the java bean info for the given source.
     *
     * @param clazz the source class.
     * @return java bean info.
     * @throws IntrospectionException is thrown if an exception occurs during introspection.
     */
    public BeanInfo getBeanInfo(Class<?> clazz) throws IntrospectionException {
        try {
            return beanInfoCache.computeIfAbsent(clazz, k -> {
                try {
                    return Introspector.getBeanInfo(k, Object.class);
                } catch (IntrospectionException e) {
                    throw new IllegalArgumentException(e);
                }
            });
        } catch (IllegalArgumentException e) {
            if (e.getCause() instanceof IntrospectionException innerEx) {
                throw innerEx;
            }
            throw e;
        }
    }

    void internalSetProperty(String name, Object value, Object o, Map<String, Object> context, boolean throwPropertyExceptions) throws ReflectionException{
        try {
            setValue(name, context, o, value);
        } catch (OgnlException e) {
            Throwable reason = e.getReason();
            if (reason instanceof SecurityException) {
                LOG.error("Could not evaluate this expression due to security constraints: [{}]", name, e);
            }
            String msg = "Caught OgnlException while setting property '" + name + "' on type '" + o.getClass().getName() + "'.";
            Throwable exception = (reason == null) ? e : reason;

            if (throwPropertyExceptions) {
                throw new ReflectionException(msg, exception);
            } else if (devMode) {
                LOG.warn(msg, exception);
            }
        }
    }

    protected Map<String, Object> createDefaultContext(Object root) {
        return createDefaultContext(root, null);
    }

    protected Map<String, Object> createDefaultContext(Object root, ClassResolver resolver) {
        if (resolver == null) {
            resolver = container.getInstance(RootAccessor.class);
            if (resolver == null) {
                throw new IllegalStateException("Cannot find ClassResolver");
            }
        }
        return Ognl.createDefaultContext(root, container.getInstance(SecurityMemberAccess.class), resolver, defaultConverter);
    }

    @FunctionalInterface
    private interface TreeValidator {
        void validate(Object tree, Map<String, Object> context) throws OgnlException;
    }
}