This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch deny-mvel-public-eval-apis
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 2d76f588a5697dc7998ad47d0465cd8e52ff26e5
Author: Serge Huber <[email protected]>
AuthorDate: Wed Sep 9 09:24:16 2026 +0200

    Harden MVEL sandbox so public eval APIs stay unreachable.
    
    Keep the 3.1 default-off switch and existing script:: rules. Always
    deny nested eval entry points (including inner-class, array, slash,
    and zero-width name tricks), tighten script-prefix sanitizing, and
    make forbid patterns match eval() after invisible-character stripping.
---
 manual/src/main/asciidoc/configuration.adoc        |   3 +-
 .../main/resources/etc/custom.system.properties    |   4 +-
 package/src/main/resources/etc/mvel-forbid.json    |   2 +-
 .../spi/conditions/ConditionContextHelper.java     |  39 +++++++
 .../spi/conditions/ConditionContextHelperTest.java |   9 ++
 .../unomi/rest/endpoints/ContextJsonEndpoint.java  |   2 +-
 .../rest/endpoints/ContextJsonEndpointTest.java    |   6 +
 .../apache/unomi/scripting/ExpressionFilter.java   |  15 ++-
 .../scripting/SecureFilteringClassLoader.java      | 122 ++++++++++++++++-----
 .../unomi/scripting/ExpressionFilterTest.java      |  46 ++++++++
 .../unomi/scripting/MvelScriptExecutorTest.java    |  26 +++++
 .../scripting/SecureFilteringClassLoaderTest.java  |  91 +++++++++++++++
 12 files changed, 331 insertions(+), 34 deletions(-)

diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index 9c26ed223..0ef39dfc7 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -697,8 +697,9 @@ Alongside with the allow-listing technology, there are new 
configuration paramet
 
org.apache.unomi.scripting.mvel.enabled=${env:UNOMI_SCRIPTING_MVEL_ENABLED:-false}
 
 # These parameters control the list of classes that are allowed or forbidden 
when executing expressions.
+# Forbid is applied first. Public MVEL eval APIs are always denied, even if 
this list is emptied.
 
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.*}
-org.apache.unomi.scripting.forbid=${env:UNOMI_FORBID_SCRIPTING_CLASSES:-}
+org.apache.unomi.scripting.forbid=${env:UNOMI_FORBID_SCRIPTING_CLASSES:-org.mvel2.MVEL}
 
 # 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.
 
org.apache.unomi.scripting.filter.activated=${env:UNOMI_SCRIPTING_FILTER_ACTIVATED:-true}
diff --git a/package/src/main/resources/etc/custom.system.properties 
b/package/src/main/resources/etc/custom.system.properties
index e932ea715..05c4a9e95 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -36,8 +36,10 @@ 
org.apache.unomi.healthcheck.password=${env:UNOMI_HEALTHCHECK_PASSWORD}
 
org.apache.unomi.scripting.mvel.enabled=${env:UNOMI_SCRIPTING_MVEL_ENABLED:-false}
 
 # These parameters control the list of classes that are allowed or forbidden 
when executing expressions.
+# The forbid list is applied first, and public MVEL eval APIs are always 
denied even if this list is emptied.
+# org.mvel2.* compiler classes stay on the allow wildcard so allow-listed 
expressions can still compile.
 
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}
-org.apache.unomi.scripting.forbid=${env:UNOMI_FORBID_SCRIPTING_CLASSES:-}
+org.apache.unomi.scripting.forbid=${env:UNOMI_FORBID_SCRIPTING_CLASSES:-org.mvel2.MVEL}
 
 # 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/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/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java
 
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java
index bb37c1985..2c5192e6d 100644
--- 
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java
+++ 
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java
@@ -704,6 +704,45 @@ public class ConditionContextHelper {
         return false;
     }
 
+    /**
+     * Detects {@code script::} / {@code parameter::} after stripping 
invisible characters, trimming,
+     * and ignoring case (and fullwidth colons). Used by public-request 
sanitizers so prefix tricks
+     * cannot skip filtering. Resolution still requires the exact prefixes in
+     * {@link #isParameterReference(Object)}.
+     *
+     * @param value the value to inspect
+     * @return {@code true} when the value looks like a script or parameter 
reference
+     */
+    public static boolean looksLikeScriptOrParameterReference(Object value) {
+        if (!(value instanceof String)) {
+            return false;
+        }
+        String normalized = stripInvisibleCharacters((String) 
value).trim().replace('\uFF1A', ':');
+        return startsWithIgnoreCase(normalized, SCRIPT_EXPRESSION_PREFIX) ||
+                startsWithIgnoreCase(normalized, PARAMETER_REFERENCE_PREFIX);
+    }
+
+    private static boolean startsWithIgnoreCase(String value, String prefix) {
+        return value.regionMatches(true, 0, prefix, 0, prefix.length());
+    }
+
+    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();
+    }
+
     /**
      * Folds an object's string representation to ASCII.
      *
diff --git 
a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelperTest.java
 
b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelperTest.java
index 3ee6e6f11..ef043215f 100644
--- 
a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelperTest.java
+++ 
b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelperTest.java
@@ -632,6 +632,15 @@ public class ConditionContextHelperTest {
         assertFalse(ConditionContextHelper.isParameterReference(null));
         assertFalse(ConditionContextHelper.isParameterReference(42));
         
assertFalse(ConditionContextHelper.isParameterReference("parameter:not-a-reference"));
+        
assertFalse(ConditionContextHelper.looksLikeScriptOrParameterReference("equals"));
+        
assertFalse(ConditionContextHelper.looksLikeScriptOrParameterReference("notascript::payload"));
+        assertFalse(ConditionContextHelper.isParameterReference(" 
SCRIPT::evil"));
+        
assertTrue(ConditionContextHelper.looksLikeScriptOrParameterReference(" 
SCRIPT::evil"));
+        
assertTrue(ConditionContextHelper.looksLikeScriptOrParameterReference("Script::evil"));
+        
assertTrue(ConditionContextHelper.looksLikeScriptOrParameterReference("\uFEFFscript::evil"));
+        
assertTrue(ConditionContextHelper.looksLikeScriptOrParameterReference("scr\u200Bipt::evil"));
+        
assertTrue(ConditionContextHelper.looksLikeScriptOrParameterReference("PARAMETER::key"));
+        
assertTrue(ConditionContextHelper.looksLikeScriptOrParameterReference("script\uFF1A\uFF1Aevil"));
     }
 
     @Test
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java 
b/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
index c36b663dc..c9530c083 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
@@ -500,7 +500,7 @@ public class ContextJsonEndpoint {
     private Object sanitizeValue(Object value) {
         if (value instanceof String) {
             String stringValue = (String) value;
-            if (ConditionContextHelper.isParameterReference(value)) {
+            if 
(ConditionContextHelper.looksLikeScriptOrParameterReference(value)) {
                 LOGGER.warn("Scripting detected in context request, filtering 
out. See debug level for more information");
                 LOGGER.debug("Scripting detected in context request with value 
{}, filtering out...", value);
                 return null;
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/endpoints/ContextJsonEndpointTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/endpoints/ContextJsonEndpointTest.java
index b29946802..a2d8df779 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/endpoints/ContextJsonEndpointTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/endpoints/ContextJsonEndpointTest.java
@@ -74,6 +74,12 @@ class ContextJsonEndpointTest {
     void sanitizeValue_filtersScriptAndParameterReferences() throws Exception {
         
assertNull(invokeSanitizeValue("script::Runtime.getRuntime().exec(\"touch 
/tmp/evil\")"));
         assertNull(invokeSanitizeValue("parameter::eventTypeId"));
+        assertNull(invokeSanitizeValue(" SCRIPT::evil"));
+        assertNull(invokeSanitizeValue("Script::evil"));
+        assertNull(invokeSanitizeValue("\uFEFFscript::evil"));
+        assertNull(invokeSanitizeValue("scr\u200Bipt::evil"));
+        assertNull(invokeSanitizeValue("PARAMETER::eventTypeId"));
+        assertEquals("mentions script:: in documentation", 
invokeSanitizeValue("mentions script:: in documentation"));
     }
 
     @Test
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 e6ba08ee9..a6b32f2e1 100644
--- a/scripting/src/main/java/org/apache/unomi/scripting/ExpressionFilter.java
+++ b/scripting/src/main/java/org/apache/unomi/scripting/ExpressionFilter.java
@@ -49,7 +49,12 @@ public class ExpressionFilter {
      * @return the expression when accepted, or {@code null} when filtered out
      */
     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;
@@ -70,4 +75,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/SecureFilteringClassLoader.java
 
b/scripting/src/main/java/org/apache/unomi/scripting/SecureFilteringClassLoader.java
index 6f59ca16f..e07c51cec 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,20 @@ 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}. Compiler classes 
under {@code org.mvel2.*}
+     * remain available for compiling allow-listed expressions.
+     */
+    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"
+    )));
+
     private Set<String> allowedClasses = null;
     private Set<String> forbiddenClasses = null;
 
@@ -40,26 +55,19 @@ public class SecureFilteringClassLoader extends ClassLoader 
{
                 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 +97,96 @@ 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 (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/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 fa7e4f6ce..78780c562 100644
--- 
a/scripting/src/test/java/org/apache/unomi/scripting/MvelScriptExecutorTest.java
+++ 
b/scripting/src/test/java/org/apache/unomi/scripting/MvelScriptExecutorTest.java
@@ -32,6 +32,7 @@ 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;
 
 public class MvelScriptExecutorTest {
@@ -70,6 +71,16 @@ public class MvelScriptExecutorTest {
         assertEquals(2, ((Number) result).intValue());
     }
 
+    @Test
+    public void testNestedPublicEvalDoesNotRunWhenEnabled() {
+        System.setProperty(MvelScriptExecutor.ENABLED_PROPERTY, "true");
+        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 testMVELSecurity() throws IOException {
         System.setProperty(MvelScriptExecutor.ENABLED_PROPERTY, "true");
@@ -134,6 +145,21 @@ 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);
+        }
+    }
+
     private static ExpressionFilterFactory emptyAllowList() {
         return new ExpressionFilterFactory() {
             @Override
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..cf2c8da72
--- /dev/null
+++ 
b/scripting/src/test/java/org/apache/unomi/scripting/SecureFilteringClassLoaderTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.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_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 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"));
+    }
+}

Reply via email to