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 1540a26af37f9df4ed0c3790666b23e6670a02e2 Author: Serge Huber <[email protected]> AuthorDate: Thu Sep 17 12:26:53 2026 +0200 Deny public MVEL eval APIs from the scripting class loader. MVEL stays enabled. Nested eval entry points are always denied, including inner-class, array, slash, and zero-width name forms. Forbid patterns also match after invisible-character stripping. --- manual/src/main/asciidoc/configuration.adoc | 7 +- .../main/resources/etc/custom.system.properties | 4 +- package/src/main/resources/etc/mvel-forbid.json | 2 +- .../apache/unomi/scripting/ExpressionFilter.java | 15 ++- .../scripting/SecureFilteringClassLoader.java | 122 ++++++++++++++++----- .../unomi/scripting/ExpressionFilterTest.java | 46 ++++++++ .../unomi/scripting/MvelScriptExecutorTest.java | 62 +++++++++-- .../scripting/SecureFilteringClassLoaderTest.java | 91 +++++++++++++++ 8 files changed, 307 insertions(+), 42 deletions(-) diff --git a/manual/src/main/asciidoc/configuration.adoc b/manual/src/main/asciidoc/configuration.adoc index 5f51a0561..c67b8a034 100644 --- a/manual/src/main/asciidoc/configuration.adoc +++ b/manual/src/main/asciidoc/configuration.adoc @@ -550,7 +550,9 @@ The second layer is the expression filtering system, that uses an allow-listing 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. +access classes that have been allowed. Public MVEL eval APIs are always denied, including when they are referenced +through inner classes, array types, or names with invisible characters. That class-loader check is the control that +must hold even if an expression reaches the engine. This multi-layered approach makes it possible to retain a high level of security even if one layer is poorly configured or abused. @@ -687,8 +689,9 @@ 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. +# 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 4dd435a78..645d742ba 100644 --- a/package/src/main/resources/etc/custom.system.properties +++ b/package/src/main/resources/etc/custom.system.properties @@ -31,8 +31,10 @@ org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD} org.apache.unomi.healthcheck.password=${env:UNOMI_HEALTHCHECK_PASSWORD} # 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/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 d843c43d5..f7b60e0b2 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,9 @@ 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; public class MvelScriptExecutorTest { @@ -42,14 +44,23 @@ 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 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 @@ -115,6 +126,41 @@ 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 + 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..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")); + } +}
