You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by js...@apache.org on 2026/10/14 15:32:01 UTC
(unomi) branch unomi-3.0.x updated: Deny public MVEL eval APIs from the scripting class loader (unomi-3.0.x) (#892)
This is an automated email from the ASF dual-hosted git repository.
jsinovassin pushed a commit to branch unomi-3.0.x
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/unomi-3.0.x by this push:
new f7ee05092 Deny public MVEL eval APIs from the scripting class loader (unomi-3.0.x) (#892)
f7ee05092 is described below
commit f7ee05092fbeebe567bd3c5038eabb64aacc079b
Author: Jonathan SINOVASSIN-NAIK <58...@users.noreply.github.com>
AuthorDate: Wed Oct 14 17:31:54 2026 +0200
Deny public MVEL eval APIs from the scripting class loader (unomi-3.0.x) (#892)
Co-authored-by: Serge Huber <sh...@apache.org>
Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
manual/src/main/asciidoc/configuration.adoc | 23 ++-
.../main/resources/etc/custom.system.properties | 6 +-
package/src/main/resources/etc/mvel-forbid.json | 2 +-
.../apache/unomi/scripting/ExpressionFilter.java | 15 +-
.../apache/unomi/scripting/MvelScriptExecutor.java | 51 ++++---
.../scripting/SecureFilteringClassLoader.java | 160 +++++++++++++++++----
.../internal/ExpressionFilterFactoryImpl.java | 19 ++-
.../unomi/scripting/ExpressionFilterTest.java | 46 ++++++
.../unomi/scripting/MvelScriptExecutorTest.java | 114 +++++++++++++--
.../scripting/SecureFilteringClassLoaderTest.java | 138 ++++++++++++++++++
.../internal/ExpressionFilterFactoryImplTest.java | 87 +++++++++++
11 files changed, 586 insertions(+), 75 deletions(-)
diff --git a/manual/src/main/asciidoc/configuration.adoc b/manual/src/main/asciidoc/configuration.adoc
index c10368f0e..b0434a502 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -208,8 +208,22 @@ injected layer of scripts are removed.
The second layer is the expression filtering system, that uses an allow-listing mechanism to only accept pre-vetted
expressions (through configuration and deployment on the server side). Any unrecognized expression will not be accepted.
-Finally, once the script starts executing in the scripting engine, a filtering class loader will only let the script
-access classes that have been allowed.
+Finally, once the script starts executing in the scripting engine, a filtering class loader restricts which classes a
+script may load *by name*. No `org.mvel2` class is on the allow list, so an expression can never name an MVEL class
+(its eval engines, its JSR-223 script engine, its shell or its compiler) through `new`, a static reference or an
+import; MVEL's own internals are loaded from the MVEL bundle and are unaffected. As a safety net for a loosened
+configuration, the public MVEL eval APIs are also always denied in code, including when they are referenced through
+inner classes, array types, or names with invisible characters, even if the forbid list is emptied or the allow list
+is set to `all`.
+
+This class-loader restriction is defense in depth, not a complete sandbox. It only gates class names that the engine
+resolves through it; it cannot stop reflection that a running expression performs on objects it already holds, such as
+`getClass()`, `getClassLoader().loadClass(...)` or `Class.forName(...)`, because those calls resolve through the JDK
+and MVEL bundle class loaders, not this one. The expression filtering layer above is therefore the primary,
+non-optional control: its allow-list of pre-vetted expressions and its forbid patterns (which reject reflection tokens
+such as `getClass`, `forName`, `invoke` and `Class`) are what actually stop arbitrary expressions from reaching the
+engine. Do not disable expression filtering or trim its forbid list on the assumption that the class loader alone will
+contain a hostile expression.
This multi-layered approach makes it possible to retain a high level of security even if one layer is poorly
configured or abused.
@@ -337,7 +351,10 @@ Alongside with the allow-listing technology, there are new configuration paramet
[source]
----
# These parameters control the list of classes that are allowed or forbidden when executing expressions.
-org.apache.unomi.scripting.allow=${env:UNOMI_ALLOW_SCRIPTING_CLASSES:-org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,org.mvel2.*}
+# Forbid is applied first. No org.mvel2 class is allowed, so expressions cannot name an MVEL class; MVEL's
+# own internals load from the MVEL bundle, not this filter. Public MVEL eval APIs are also always denied in
+# code, even if this forbid list is emptied or the allow list is set to "all".
+org.apache.unomi.scripting.allow=${env:UNOMI_ALLOW_SCRIPTING_CLASSES:-org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,java.lang.String}
org.apache.unomi.scripting.forbid=${env:UNOMI_FORBID_SCRIPTING_CLASSES:-}
# This parameter controls the whole expression filtering system. It is not recommended to turn it off. The main reason to turn it off would be to check if it is interfering with something, but it should always be active in production.
diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties
index 7a5d31e11..54a16e548 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -25,7 +25,11 @@ ${optionals}=unomi.custom.system.properties
org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD:-karaf}
# These parameters control the list of classes that are allowed or forbidden when executing expressions.
-org.apache.unomi.scripting.allow=${env:UNOMI_ALLOW_SCRIPTING_CLASSES:-org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,org.mvel2.*,java.lang.String}
+# The forbid list is applied first. No org.mvel2 class is on the allow list, so expressions cannot name
+# any MVEL class (eval engines, shell, compiler); MVEL's own internals load from the MVEL bundle class
+# loader, not this filter, so allow-listed expressions still compile. Public MVEL eval APIs are also
+# always denied in code even if this forbid list is emptied or the allow list is set to "all".
+org.apache.unomi.scripting.allow=${env:UNOMI_ALLOW_SCRIPTING_CLASSES:-org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,java.lang.String}
org.apache.unomi.scripting.forbid=${env:UNOMI_FORBID_SCRIPTING_CLASSES:-}
# This parameter controls the whole expression filtering system. It is not recommended to turn it off. The main reason
diff --git a/package/src/main/resources/etc/mvel-forbid.json b/package/src/main/resources/etc/mvel-forbid.json
index 07e8ee622..d36dbf46f 100644
--- a/package/src/main/resources/etc/mvel-forbid.json
+++ b/package/src/main/resources/etc/mvel-forbid.json
@@ -15,5 +15,5 @@
".*forName.*",
".*Socket.*",
".*DriverManager.*",
- "eval"
+ "(?s).*eval\\s*\\(.*"
]
\ No newline at end of file
diff --git a/scripting/src/main/java/org/apache/unomi/scripting/ExpressionFilter.java b/scripting/src/main/java/org/apache/unomi/scripting/ExpressionFilter.java
index 985ed17e6..abe924abd 100644
--- a/scripting/src/main/java/org/apache/unomi/scripting/ExpressionFilter.java
+++ b/scripting/src/main/java/org/apache/unomi/scripting/ExpressionFilter.java
@@ -37,7 +37,12 @@ public class ExpressionFilter {
}
public String filter(String expression) {
- if (forbiddenExpressionPatterns != null && expressionMatches(expression, forbiddenExpressionPatterns)) {
+ if (expression == null) {
+ return null;
+ }
+ if (forbiddenExpressionPatterns != null &&
+ (expressionMatches(expression, forbiddenExpressionPatterns) ||
+ expressionMatches(canonicalizeForForbid(expression), forbiddenExpressionPatterns))) {
LOGGER.warn("Expression filtered because forbidden. See debug log level for more information");
LOGGER.debug("Expression {} is forbidden by expression filter", expression);
return null;
@@ -58,4 +63,12 @@ public class ExpressionFilter {
}
return false;
}
+
+ /**
+ * Strips format/control characters so forbid patterns still match when those characters are
+ * inserted into a gadget. Allow-list matching stays on the raw string (fail-closed).
+ */
+ static String canonicalizeForForbid(String expression) {
+ return SecureFilteringClassLoader.stripInvisibleCharacters(expression);
+ }
}
diff --git a/scripting/src/main/java/org/apache/unomi/scripting/MvelScriptExecutor.java b/scripting/src/main/java/org/apache/unomi/scripting/MvelScriptExecutor.java
index 8777b0f75..58e9aee75 100644
--- a/scripting/src/main/java/org/apache/unomi/scripting/MvelScriptExecutor.java
+++ b/scripting/src/main/java/org/apache/unomi/scripting/MvelScriptExecutor.java
@@ -29,8 +29,6 @@ import java.util.concurrent.ConcurrentHashMap;
*/
public class MvelScriptExecutor implements ScriptExecutor {
- private final static String INVALID_SCRIPT_MARKER = "--- Invalid Script Marker ---";
-
private Map<String, Serializable> mvelExpressions = new ConcurrentHashMap<>();
private SecureFilteringClassLoader secureFilteringClassLoader = new SecureFilteringClassLoader(getClass().getClassLoader());
private ExpressionFilterFactory expressionFilterFactory;
@@ -46,34 +44,35 @@ public class MvelScriptExecutor implements ScriptExecutor {
try {
Thread.currentThread().setContextClassLoader(secureFilteringClassLoader);
- if (!mvelExpressions.containsKey(script)) {
+ // Filter on every execution, not only on first compile: a script that was accepted and
+ // cached while the policy was looser must be rejected once the policy is tightened at
+ // runtime. Only compiled (accepted) expressions are cached, so a rejected script is
+ // never stored and cannot accumulate in the cache either.
+ if (expressionFilterFactory.getExpressionFilter("mvel").filter(script) == null) {
+ return null;
+ }
- if (expressionFilterFactory.getExpressionFilter("mvel").filter(script) == null) {
- mvelExpressions.put(script, INVALID_SCRIPT_MARKER);
- } else {
- ParserConfiguration parserConfiguration = new ParserConfiguration();
- parserConfiguration.setClassLoader(secureFilteringClassLoader);
- ParserContext parserContext = new ParserContext(parserConfiguration);
+ Serializable compiledExpression = mvelExpressions.get(script);
+ if (compiledExpression == null) {
+ ParserConfiguration parserConfiguration = new ParserConfiguration();
+ parserConfiguration.setClassLoader(secureFilteringClassLoader);
+ ParserContext parserContext = new ParserContext(parserConfiguration);
- // override hardcoded Class Literals that are inserted by default in MVEL and that may be a security risk
- parserContext.addImport("Runtime", String.class);
- parserContext.addImport("System", String.class);
- parserContext.addImport("ProcessBuilder", String.class);
- parserContext.addImport("Class", String.class);
- parserContext.addImport("ClassLoader", String.class);
- parserContext.addImport("Thread", String.class);
- parserContext.addImport("Compiler", String.class);
- parserContext.addImport("ThreadLocal", String.class);
- parserContext.addImport("SecurityManager", String.class);
+ // override hardcoded Class Literals that are inserted by default in MVEL and that may be a security risk
+ parserContext.addImport("Runtime", String.class);
+ parserContext.addImport("System", String.class);
+ parserContext.addImport("ProcessBuilder", String.class);
+ parserContext.addImport("Class", String.class);
+ parserContext.addImport("ClassLoader", String.class);
+ parserContext.addImport("Thread", String.class);
+ parserContext.addImport("Compiler", String.class);
+ parserContext.addImport("ThreadLocal", String.class);
+ parserContext.addImport("SecurityManager", String.class);
- mvelExpressions.put(script, MVEL.compileExpression(script, parserContext));
- }
- }
- if (mvelExpressions.containsKey(script) && mvelExpressions.get(script) != INVALID_SCRIPT_MARKER) {
- return MVEL.executeExpression(mvelExpressions.get(script), context);
- } else {
- return null;
+ compiledExpression = MVEL.compileExpression(script, parserContext);
+ mvelExpressions.put(script, compiledExpression);
}
+ return MVEL.executeExpression(compiledExpression, context);
} finally {
Thread.currentThread().setContextClassLoader(tccl);
}
diff --git a/scripting/src/main/java/org/apache/unomi/scripting/SecureFilteringClassLoader.java b/scripting/src/main/java/org/apache/unomi/scripting/SecureFilteringClassLoader.java
index 6f59ca16f..762041793 100644
--- a/scripting/src/main/java/org/apache/unomi/scripting/SecureFilteringClassLoader.java
+++ b/scripting/src/main/java/org/apache/unomi/scripting/SecureFilteringClassLoader.java
@@ -17,6 +17,7 @@
package org.apache.unomi.scripting;
import java.util.Arrays;
+import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
@@ -26,6 +27,53 @@ import java.util.Set;
*/
public class SecureFilteringClassLoader extends ClassLoader {
+ /**
+ * Public eval / runtime entry points that expressions must never load, even when the configured
+ * forbid list is empty or the allow list is {@code all}. This is a safety net for a loosened
+ * configuration: the primary control is that {@code org.mvel2} is not on the allow list at all,
+ * so expressions cannot name any MVEL class. Wildcard entries cover whole gadget packages so a
+ * future MVEL release cannot reopen the hole by adding a new eval-capable class.
+ *
+ * <p>On this line MVEL does ask this loader for part of its own machinery, which is why
+ * {@link #RUNTIME_INTERNAL_CLASSES} exists. The master version of this class states that the
+ * MVEL bundle class loader serves those classes; that is not what the integration tests of the
+ * 3.0 line observed.
+ */
+ static final Set<String> ALWAYS_FORBIDDEN_CLASSES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+ "org.mvel2.MVEL",
+ "org.mvel2.MVELRuntime",
+ "org.mvel2.MVELInterpretedRuntime",
+ "org.mvel2.templates.TemplateRuntime",
+ "org.mvel2.templates.TemplateCompiler",
+ "org.mvel2.MacroProcessor",
+ "org.mvel2.jsr223.*",
+ "org.mvel2.sh.*"
+ )));
+
+ /**
+ * MVEL internals that MVEL itself loads through the thread context class loader, which
+ * {@code MvelScriptExecutor} sets to this loader while an expression runs. They are exempt from
+ * the allow list and NOT from the forbid lists above, so a loosened configuration cannot use
+ * this exemption to reach an eval entry point.
+ *
+ * <p>{@code Accessor} is the interface a compiled property accessor implements, and
+ * {@code org.mvel2.integration} holds the variable-resolution interfaces an accessor calls
+ * through. Neither carries a compiler or a parser, so naming one from an expression buys
+ * nothing: there is no way to obtain an instance without a compiler, and every compiler entry
+ * point stays behind the allow list.
+ *
+ * <p>Measured rather than assumed, in two rounds of the integration suite of this branch.
+ * With no exemption the suite reports 36 failures and 5 errors, one of them
+ * {@code ClassNotFound org.mvel2.compiler.Accessor}. With {@code Accessor} alone it reports one
+ * error, {@code ClassNotFound org.mvel2.integration.VariableResolverFactory}, raised from
+ * {@code DynamicGetAccessor.getValue}. Every failing case is a rule action that evaluates an
+ * MVEL expression.
+ */
+ static final Set<String> RUNTIME_INTERNAL_CLASSES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+ "org.mvel2.compiler.Accessor",
+ "org.mvel2.integration.*"
+ )));
+
private Set<String> allowedClasses = null;
private Set<String> forbiddenClasses = null;
@@ -34,32 +82,25 @@ public class SecureFilteringClassLoader extends ClassLoader {
static {
String systemAllowedClasses = System.getProperty("org.apache.unomi.scripting.allow",
- "org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,org.mvel2.*,java.lang.String");
+ "org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,java.lang.String");
if (systemAllowedClasses != null) {
if ("all".equals(systemAllowedClasses.trim())) {
defaultAllowedClasses = null;
} else {
if (systemAllowedClasses.trim().length() > 0) {
- String[] systemAllowedClassesParts = systemAllowedClasses.split(",");
- defaultAllowedClasses = new HashSet<>();
- defaultAllowedClasses.addAll(Arrays.asList(systemAllowedClassesParts));
+ defaultAllowedClasses = parseClassList(systemAllowedClasses);
} else {
defaultAllowedClasses = null;
}
}
}
- String systemForbiddenClasses = System.getProperty("org.apache.unomi.scripting.forbid", null);
- if (systemForbiddenClasses != null) {
- if (systemForbiddenClasses.trim().length() > 0) {
- String[] systemForbiddenClassesParts = systemForbiddenClasses.split(",");
- defaultForbiddenClasses = new HashSet<>();
- defaultForbiddenClasses.addAll(Arrays.asList(systemForbiddenClassesParts));
- } else {
- defaultForbiddenClasses = null;
- }
+ String systemForbiddenClasses = System.getProperty("org.apache.unomi.scripting.forbid", "org.mvel2.MVEL");
+ if (systemForbiddenClasses != null && systemForbiddenClasses.trim().length() > 0) {
+ defaultForbiddenClasses = parseClassList(systemForbiddenClasses);
+ } else {
+ defaultForbiddenClasses = null;
}
-
}
ClassLoader delegate;
@@ -89,40 +130,99 @@ public class SecureFilteringClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
- if (forbiddenClasses != null && classNameMatches(forbiddenClasses, name)) {
- throw new ClassNotFoundException("Access to class " + name + " not allowed");
- }
- if (allowedClasses != null && !classNameMatches(allowedClasses, name)) {
- throw new ClassNotFoundException("Access to class " + name + " not allowed");
- }
+ assertPermitted(name);
return delegate.loadClass(name);
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
- if (forbiddenClasses != null && classNameMatches(forbiddenClasses, name)) {
+ assertPermitted(name);
+ return super.loadClass(name, resolve);
+ }
+
+ @Override
+ protected Class<?> findClass(String name) throws ClassNotFoundException {
+ assertPermitted(name);
+ return super.findClass(name);
+ }
+
+ private void assertPermitted(String name) throws ClassNotFoundException {
+ if (classNameMatches(ALWAYS_FORBIDDEN_CLASSES, name) ||
+ (forbiddenClasses != null && classNameMatches(forbiddenClasses, name))) {
throw new ClassNotFoundException("Access to class " + name + " not allowed");
}
+ if (classNameMatches(RUNTIME_INTERNAL_CLASSES, name)) {
+ return;
+ }
if (allowedClasses != null && !classNameMatches(allowedClasses, name)) {
throw new ClassNotFoundException("Access to class " + name + " not allowed");
}
- return super.loadClass(name, resolve);
}
- @Override
- protected Class<?> findClass(String name) throws ClassNotFoundException {
- return super.findClass(name);
+ private static Set<String> parseClassList(String classList) {
+ Set<String> classes = new HashSet<>();
+ for (String part : classList.split(",")) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ classes.add(trimmed);
+ }
+ }
+ return classes;
}
- private boolean classNameMatches(Set<String> classesToTest, String className) {
+ static boolean classNameMatches(Set<String> classesToTest, String className) {
+ String normalized = normalizeClassName(className);
for (String classToTest : classesToTest) {
- if (classToTest.endsWith("*")) {
- if (className.startsWith(classToTest.substring(0, classToTest.length() - 1))) return true;
- } else {
- if (className.equals(classToTest)) return true;
+ if (classToTest == null) {
+ continue;
+ }
+ String pattern = classToTest.trim();
+ if (pattern.isEmpty()) {
+ continue;
+ }
+ if (pattern.endsWith("*")) {
+ String prefix = pattern.substring(0, pattern.length() - 1);
+ if (normalized.startsWith(prefix)) {
+ return true;
+ }
+ } else if (normalized.equals(pattern) || normalized.startsWith(pattern + "$")) {
+ return true;
}
}
return false;
}
+ static String normalizeClassName(String className) {
+ if (className == null) {
+ return "";
+ }
+ String name = stripInvisibleCharacters(className).trim().replace('/', '.');
+ while (name.startsWith("[")) {
+ if (name.startsWith("[L") && name.endsWith(";")) {
+ name = name.substring(2, name.length() - 1);
+ } else if (name.length() >= 2) {
+ name = name.substring(1);
+ } else {
+ break;
+ }
+ }
+ return name;
+ }
+
+ static String stripInvisibleCharacters(String input) {
+ StringBuilder stripped = new StringBuilder(input.length());
+ for (int i = 0; i < input.length(); ) {
+ int codePoint = input.codePointAt(i);
+ i += Character.charCount(codePoint);
+ if (codePoint == 0) {
+ continue;
+ }
+ int type = Character.getType(codePoint);
+ if (type == Character.FORMAT || type == Character.CONTROL || type == Character.SURROGATE) {
+ continue;
+ }
+ stripped.appendCodePoint(codePoint);
+ }
+ return stripped.toString();
+ }
}
diff --git a/scripting/src/main/java/org/apache/unomi/scripting/internal/ExpressionFilterFactoryImpl.java b/scripting/src/main/java/org/apache/unomi/scripting/internal/ExpressionFilterFactoryImpl.java
index cdfd6f4d9..d05a1fef6 100644
--- a/scripting/src/main/java/org/apache/unomi/scripting/internal/ExpressionFilterFactoryImpl.java
+++ b/scripting/src/main/java/org/apache/unomi/scripting/internal/ExpressionFilterFactoryImpl.java
@@ -33,6 +33,7 @@ import java.io.IOException;
import java.net.URL;
import java.util.*;
import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
public class ExpressionFilterFactoryImpl implements ExpressionFilterFactory,BundleListener {
@@ -60,8 +61,8 @@ public class ExpressionFilterFactoryImpl implements ExpressionFilterFactory,Bund
String[] initialFilterCollectionParts = initialFilterCollections.split(",");
if (initialFilterCollectionParts != null) {
for (String initialFilterCollection : initialFilterCollectionParts) {
- allowedExpressionPatternsByCollection.put(initialFilterCollection, loadPatternsFromConfig("org.apache.unomi.scripting.filter."+initialFilterCollection+".allow"));
- forbiddenExpressionPatternsByCollection.put(initialFilterCollection, loadPatternsFromConfig("org.apache.unomi.scripting.filter."+initialFilterCollection+".forbid"));
+ allowedExpressionPatternsByCollection.put(initialFilterCollection, loadPatternsFromConfig("org.apache.unomi.scripting.filter."+initialFilterCollection+".allow", false));
+ forbiddenExpressionPatternsByCollection.put(initialFilterCollection, loadPatternsFromConfig("org.apache.unomi.scripting.filter."+initialFilterCollection+".forbid", true));
}
}
@@ -77,7 +78,7 @@ public class ExpressionFilterFactoryImpl implements ExpressionFilterFactory,Bund
}
}
- private Set<Pattern> loadPatternsFromConfig(String propertyKey) {
+ private Set<Pattern> loadPatternsFromConfig(String propertyKey, boolean forbidList) {
String patternsFile = System.getProperty(propertyKey, null);
if (StringUtils.isNotEmpty(patternsFile)) {
Set<Pattern> patterns = new HashSet<>();
@@ -86,8 +87,16 @@ public class ExpressionFilterFactoryImpl implements ExpressionFilterFactory,Bund
for (JsonNode jsonPattern : jsonPatterns) {
patterns.add(Pattern.compile(jsonPattern.asText()));
}
- } catch (IOException e) {
- LOGGER.error("Error while loading expressions definition from {}", propertyKey, e);
+ } catch (IOException | PatternSyntaxException e) {
+ // Fail closed: a configured patterns file that cannot be read or parsed must not
+ // silently disable filtering. For a forbid list, "reject everything" is a catch-all
+ // deny pattern; for an allow list, an empty set already rejects everything.
+ LOGGER.error("Error while loading expressions definition from {}; failing closed (rejecting all expressions for this filter)", propertyKey, e);
+ Set<Pattern> failClosed = new HashSet<>();
+ if (forbidList) {
+ failClosed.add(Pattern.compile("(?s).*"));
+ }
+ return failClosed;
}
return patterns;
}
diff --git a/scripting/src/test/java/org/apache/unomi/scripting/ExpressionFilterTest.java b/scripting/src/test/java/org/apache/unomi/scripting/ExpressionFilterTest.java
new file mode 100644
index 000000000..db881818a
--- /dev/null
+++ b/scripting/src/test/java/org/apache/unomi/scripting/ExpressionFilterTest.java
@@ -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.unomi.scripting;
+
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class ExpressionFilterTest {
+
+ @Test
+ public void filter_rejectsEvalEvenWithWhitespaceAndInvisibleChars() {
+ ExpressionFilter filter = new ExpressionFilter(null,
+ Collections.singleton(Pattern.compile("(?s).*eval\\s*\\(.*")));
+ assertNull(filter.filter("org.mvel2.MVEL.eval(\"1+1\")"));
+ assertNull(filter.filter("org.mvel2.MVEL.eval ( \"1+1\" )"));
+ assertNull(filter.filter("org.mvel2.MVEL.ev\u200Bal(\"1+1\")"));
+ assertEquals("1+1", filter.filter("1+1"));
+ }
+
+ @Test
+ public void filter_doesNotCanonicalizeForAllowList() {
+ ExpressionFilter filter = new ExpressionFilter(
+ Collections.singleton(Pattern.compile("\\Q1+1\\E")), null);
+ assertEquals("1+1", filter.filter("1+1"));
+ assertNull(filter.filter("1\u200B+1"));
+ }
+}
diff --git a/scripting/src/test/java/org/apache/unomi/scripting/MvelScriptExecutorTest.java b/scripting/src/test/java/org/apache/unomi/scripting/MvelScriptExecutorTest.java
index d843c43d5..539a5b98b 100644
--- a/scripting/src/test/java/org/apache/unomi/scripting/MvelScriptExecutorTest.java
+++ b/scripting/src/test/java/org/apache/unomi/scripting/MvelScriptExecutorTest.java
@@ -29,7 +29,11 @@ import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
public class MvelScriptExecutorTest {
@@ -42,14 +46,45 @@ public class MvelScriptExecutorTest {
@Before
public void setup() {
- scriptExecutor.setExpressionFilterFactory(new ExpressionFilterFactory() {
- @Override
- public ExpressionFilter getExpressionFilter(String filterCollection) {
- Set<Pattern> allowedExpressions = new HashSet<>();
- Set<Pattern> forbiddenExpressions = new HashSet<>();
- return new ExpressionFilter(allowedExpressions, forbiddenExpressions);
- }
- });
+ scriptExecutor.setExpressionFilterFactory(emptyAllowList());
+ }
+
+ @Test
+ public void testAllowlistedArithmeticStillRuns() {
+ scriptExecutor.setExpressionFilterFactory(allowAllExpressions());
+ Object result = scriptExecutor.execute("1+1", new HashMap<String, Object>());
+ assertEquals(2, ((Number) result).intValue());
+ }
+
+ @Test
+ public void testTighteningPolicyRejectsAnAlreadyCachedScript() {
+ // accept and cache the compiled script under a permissive policy
+ scriptExecutor.setExpressionFilterFactory(allowAllExpressions());
+ assertEquals(2, ((Number) scriptExecutor.execute("1+1", new HashMap<String, Object>())).intValue());
+ // tighten the policy at runtime: the same (cached) script must now be rejected, not re-run from cache
+ scriptExecutor.setExpressionFilterFactory(emptyAllowList());
+ assertNull(scriptExecutor.execute("1+1", new HashMap<String, Object>()));
+ }
+
+ @Test
+ public void testNestedPublicEvalDoesNotRun() {
+ scriptExecutor.setExpressionFilterFactory(allowAllExpressions());
+ assertPublicEvalDoesNotReturnTwo("org.mvel2.MVEL.eval(\"1+1\")");
+ assertPublicEvalDoesNotReturnTwo("org.mvel2.MVEL.eval ( \"1+1\" )");
+ assertPublicEvalDoesNotReturnTwo("org.mvel2.MVEL.ev\u200Bal(\"1+1\")");
+ assertPublicEvalDoesNotReturnTwo("org.mvel2.templates.TemplateRuntime.eval(\"1+1\", new java.util.HashMap())");
+ }
+
+ @Test
+ public void testJsr223ScriptEngineGadgetIsBlocked() {
+ // Even with the expression text filter disabled, naming the JSR-223 MVEL engine (a nested-eval
+ // sink whose forbid-regex-evading methods are compiledScript/evaluate) must be refused because
+ // org.mvel2 is not on the allow list and the class is on the always-forbidden safety net.
+ scriptExecutor.setExpressionFilterFactory(allowAllExpressions());
+ assertClassLoaderRefused("new org.mvel2.jsr223.MvelScriptEngine().eval(\"1+1\", null)");
+ assertClassLoaderRefused(
+ "new org.mvel2.jsr223.MvelScriptEngine().evaluate("
+ + "new org.mvel2.jsr223.MvelScriptEngine().compiledScript(\"1+1\"), null)");
}
@Test
@@ -115,6 +150,69 @@ public class MvelScriptExecutorTest {
assertFalse("Vulnerability successfully executed ! File created at " + vulnFile.getCanonicalPath(), vulnFile.exists());
}
+ private void assertPublicEvalDoesNotReturnTwo(String expression) {
+ Object result = null;
+ try {
+ result = scriptExecutor.execute(expression, new HashMap<String, Object>());
+ } catch (Throwable t) {
+ // expected: class-loader or parser refuses the public eval API
+ }
+ if (result instanceof Number) {
+ assertNotEquals(2, ((Number) result).intValue());
+ } else {
+ assertNotEquals(2, result);
+ assertNotEquals(Integer.valueOf(2), result);
+ }
+ }
+
+ /**
+ * Stronger oracle than {@link #assertPublicEvalDoesNotReturnTwo}: it proves the class-loader deny
+ * actually fired (a {@link ClassNotFoundException} for a disallowed class somewhere in the cause
+ * chain) rather than the expression merely failing to compile for an unrelated reason.
+ */
+ private void assertClassLoaderRefused(String expression) {
+ Object result = null;
+ Throwable caught = null;
+ try {
+ result = scriptExecutor.execute(expression, new HashMap<String, Object>());
+ } catch (Throwable t) {
+ caught = t;
+ }
+ assertNull("expression must not have produced a value: " + expression, result);
+ boolean refusedByClassLoader = false;
+ for (Throwable t = caught; t != null; t = t.getCause()) {
+ if (t instanceof ClassNotFoundException && String.valueOf(t.getMessage()).contains("not allowed")) {
+ refusedByClassLoader = true;
+ break;
+ }
+ if (t.getCause() == t) {
+ break;
+ }
+ }
+ assertTrue("expected the filtering class loader to refuse a class in: " + expression
+ + " but got: " + caught, refusedByClassLoader);
+ }
+
+ private static ExpressionFilterFactory emptyAllowList() {
+ return new ExpressionFilterFactory() {
+ @Override
+ public ExpressionFilter getExpressionFilter(String filterCollection) {
+ Set<Pattern> allowedExpressions = new HashSet<>();
+ Set<Pattern> forbiddenExpressions = new HashSet<>();
+ return new ExpressionFilter(allowedExpressions, forbiddenExpressions);
+ }
+ };
+ }
+
+ private static ExpressionFilterFactory allowAllExpressions() {
+ return new ExpressionFilterFactory() {
+ @Override
+ public ExpressionFilter getExpressionFilter(String filterCollection) {
+ return new ExpressionFilter(null, null);
+ }
+ };
+ }
+
private static Event generateMockEvent() {
Event mockEvent = new Event();
CustomItem targetItem = new CustomItem();
diff --git a/scripting/src/test/java/org/apache/unomi/scripting/SecureFilteringClassLoaderTest.java b/scripting/src/test/java/org/apache/unomi/scripting/SecureFilteringClassLoaderTest.java
new file mode 100644
index 000000000..22eb29ebb
--- /dev/null
+++ b/scripting/src/test/java/org/apache/unomi/scripting/SecureFilteringClassLoaderTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.unomi.scripting;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class SecureFilteringClassLoaderTest {
+
+ private final ClassLoader parent = SecureFilteringClassLoaderTest.class.getClassLoader();
+
+ @Test
+ public void loadClass_allowsListedExactName() throws ClassNotFoundException {
+ Set<String> allowed = new HashSet<>(Collections.singletonList("java.lang.String"));
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(allowed, null, parent);
+ assertEquals(String.class, loader.loadClass("java.lang.String"));
+ }
+
+ @Test
+ public void loadClass_allowsWildcardPrefix() throws ClassNotFoundException {
+ Set<String> allowed = new HashSet<>(Collections.singletonList("org.mvel2.*"));
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(allowed, null, parent);
+ assertEquals(org.mvel2.compiler.CompiledExpression.class,
+ loader.loadClass("org.mvel2.compiler.CompiledExpression"));
+ }
+
+ @Test
+ public void loadClass_allowsTheMvelRuntimeInternalOutsideTheAllowList() throws ClassNotFoundException {
+ Set<String> allowed = new HashSet<>(Collections.singletonList("java.lang.String"));
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(allowed, null, parent);
+ assertEquals(org.mvel2.compiler.Accessor.class, loader.loadClass("org.mvel2.compiler.Accessor"));
+ assertEquals(org.mvel2.integration.VariableResolverFactory.class,
+ loader.loadClass("org.mvel2.integration.VariableResolverFactory"));
+ }
+
+ @Test
+ public void loadClass_theRuntimeInternalExemptionDoesNotReachAnEvalApi() {
+ Set<String> allowed = new HashSet<>(Collections.singletonList("java.lang.String"));
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(allowed, null, parent);
+ assertEquals("Access to class org.mvel2.MVEL not allowed",
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.MVEL")).getMessage());
+ assertEquals("Access to class org.mvel2.compiler.ExpressionCompiler not allowed",
+ assertThrows(ClassNotFoundException.class,
+ () -> loader.loadClass("org.mvel2.compiler.ExpressionCompiler")).getMessage());
+ }
+
+ @Test
+ public void loadClass_alwaysForbidsPublicEvalApiEvenWhenForbidListIsEmpty() {
+ Set<String> allowed = new HashSet<>(Collections.singletonList("org.mvel2.*"));
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(allowed, Collections.emptySet(), parent);
+ ClassNotFoundException thrown = assertThrows(ClassNotFoundException.class,
+ () -> loader.loadClass("org.mvel2.MVEL"));
+ assertEquals("Access to class org.mvel2.MVEL not allowed", thrown.getMessage());
+ }
+
+ @Test
+ public void loadClass_forbidsInnerClassAndArrayAndSlashForms() {
+ Set<String> allowed = new HashSet<>(Collections.singletonList("org.mvel2.*"));
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(allowed, null, parent);
+ assertEquals("Access to class org.mvel2.MVEL$Foo not allowed",
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.MVEL$Foo")).getMessage());
+ assertEquals("Access to class [Lorg.mvel2.MVEL; not allowed",
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("[Lorg.mvel2.MVEL;")).getMessage());
+ assertEquals("Access to class org/mvel2/MVEL not allowed",
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org/mvel2/MVEL")).getMessage());
+ }
+
+ @Test
+ public void loadClass_forbidsEvalApiWhenInvisibleCharactersAreInserted() {
+ Set<String> allowed = new HashSet<>(Collections.singletonList("org.mvel2.*"));
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(allowed, null, parent);
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.MV\u200BEL"));
+ }
+
+ @Test
+ public void loadClass_forbidsTemplateRuntime() {
+ Set<String> allowed = new HashSet<>(Collections.singletonList("org.mvel2.*"));
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(allowed, null, parent);
+ assertThrows(ClassNotFoundException.class,
+ () -> loader.loadClass("org.mvel2.templates.TemplateRuntime"));
+ }
+
+ @Test
+ public void loadClass_forbidsJsr223AndShellGadgetsEvenWhenAllowAll() {
+ // allowedClasses == null models "allow=all"; the always-forbidden safety net must still deny
+ // the JSR-223 script engine and the MVEL shell, whole packages, not just the six named classes.
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(null, Collections.emptySet(), parent);
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.jsr223.MvelScriptEngine"));
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.jsr223.MvelCompiledScript"));
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.jsr223.MvelScriptEngineFactory"));
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.sh.ShellSession"));
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.sh.command.basic.ObjectInspector"));
+ }
+
+ @Test
+ public void loadClass_deniesAllMvelClassesUnderTheDefaultNarrowedAllowList() {
+ // The shipped default allow list contains no org.mvel2 entry, so no MVEL class is loadable from
+ // an expression, while a legitimately allow-listed JDK class still resolves.
+ Set<String> allowed = new HashSet<>(Arrays.asList(
+ "org.apache.unomi.api.Event", "java.lang.Object", "java.util.Map",
+ "java.util.HashMap", "java.lang.Integer", "java.lang.String"));
+ SecureFilteringClassLoader loader = new SecureFilteringClassLoader(allowed, null, parent);
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.MVEL"));
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.jsr223.MvelScriptEngine"));
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.compiler.ExpressionCompiler"));
+ assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mvel2.PropertyAccessor"));
+ }
+
+ @Test
+ public void classNameMatches_trimsConfiguredPatterns() {
+ Set<String> forbidden = new HashSet<>(Collections.singletonList(" org.mvel2.MVEL "));
+ assertTrue(SecureFilteringClassLoader.classNameMatches(forbidden, "org.mvel2.MVEL"));
+ assertFalse(SecureFilteringClassLoader.classNameMatches(forbidden, "org.mvel2.compiler.CompiledExpression"));
+ }
+}
diff --git a/scripting/src/test/java/org/apache/unomi/scripting/internal/ExpressionFilterFactoryImplTest.java b/scripting/src/test/java/org/apache/unomi/scripting/internal/ExpressionFilterFactoryImplTest.java
new file mode 100644
index 000000000..515d0637d
--- /dev/null
+++ b/scripting/src/test/java/org/apache/unomi/scripting/internal/ExpressionFilterFactoryImplTest.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.unomi.scripting.internal;
+
+import org.apache.unomi.scripting.ExpressionFilter;
+import org.junit.Test;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class ExpressionFilterFactoryImplTest {
+
+ private static final String ALLOW = "org.apache.unomi.scripting.filter.mvel.allow";
+ private static final String FORBID = "org.apache.unomi.scripting.filter.mvel.forbid";
+
+ private ExpressionFilter buildFilterWith(String forbidFileContents, String allowFileContents) throws Exception {
+ String prevAllow = System.getProperty(ALLOW);
+ String prevForbid = System.getProperty(FORBID);
+ File forbidFile = null;
+ File allowFile = null;
+ try {
+ if (forbidFileContents != null) {
+ forbidFile = File.createTempFile("mvel-forbid", ".json");
+ Files.write(forbidFile.toPath(), forbidFileContents.getBytes(StandardCharsets.UTF_8));
+ System.setProperty(FORBID, forbidFile.getAbsolutePath());
+ } else {
+ System.clearProperty(FORBID);
+ }
+ if (allowFileContents != null) {
+ allowFile = File.createTempFile("mvel-allow", ".json");
+ Files.write(allowFile.toPath(), allowFileContents.getBytes(StandardCharsets.UTF_8));
+ System.setProperty(ALLOW, allowFile.getAbsolutePath());
+ } else {
+ System.clearProperty(ALLOW);
+ }
+ ExpressionFilterFactoryImpl factory = new ExpressionFilterFactoryImpl();
+ factory.init(); // bundleContext is null: only the configured files are loaded
+ return factory.getExpressionFilter("mvel");
+ } finally {
+ restore(ALLOW, prevAllow);
+ restore(FORBID, prevForbid);
+ if (forbidFile != null) { forbidFile.delete(); }
+ if (allowFile != null) { allowFile.delete(); }
+ }
+ }
+
+ private static void restore(String key, String value) {
+ if (value == null) {
+ System.clearProperty(key);
+ } else {
+ System.setProperty(key, value);
+ }
+ }
+
+ @Test
+ public void forbidFileThatFailsToParse_failsClosed() throws Exception {
+ // malformed JSON in the forbid file must not silently disable the forbid layer
+ ExpressionFilter filter = buildFilterWith("{ this is not valid json", null);
+ assertNull("a broken forbid file must reject all expressions", filter.filter("1+1"));
+ }
+
+ @Test
+ public void forbidFileThatParses_doesNotFailClosed() throws Exception {
+ // a valid forbid file forbids only what it lists and leaves everything else runnable
+ ExpressionFilter filter = buildFilterWith("[ \".*Runtime.*\" ]", null);
+ assertEquals("1+1", filter.filter("1+1"));
+ assertNull(filter.filter("something.Runtime.something"));
+ }
+}