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

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new cf52160eb Deny public MVEL eval APIs from the scripting class loader 
(#877)
cf52160eb is described below

commit cf52160eb63a45480fb76f717a7f7aebe9ca3984
Author: Serge Huber <[email protected]>
AuthorDate: Thu Sep 24 06:35:34 2026 +0200

    Deny public MVEL eval APIs from the scripting class loader (#877)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 THREAT_MODEL.md                                    |   5 +-
 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      | 130 ++++++++++++++++-----
 .../internal/ExpressionFilterFactoryImpl.java      |  19 ++-
 .../unomi/scripting/ExpressionFilterTest.java      |  46 ++++++++
 .../unomi/scripting/MvelScriptExecutorTest.java    | 114 ++++++++++++++++--
 .../scripting/SecureFilteringClassLoaderTest.java  | 118 +++++++++++++++++++
 .../internal/ExpressionFilterFactoryImplTest.java  |  87 ++++++++++++++
 12 files changed, 540 insertions(+), 76 deletions(-)

diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index f536032ef..e39ce6145 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -115,6 +115,7 @@ Per-surface trust table *(all inferred unless noted):*
 - **No protection if the admin REST/GraphQL APIs are exposed unauthenticated / 
with default creds** — keeping the admin surface off the public network + 
authenticated is the operator's job (pending §5a ruling). *(inferred)*
 - **No confidentiality/integrity for the ES/OS backend or its network** — 
Unomi assumes a secured backend; it does not defend an exposed Elasticsearch. 
*(inferred)*
 - **Not a sandbox for admin-authored expressions/plugins** — a trusted author 
with condition/scripting authority can run server-side logic by design; that 
power is not contained. *(inferred)* **False friend:** the presence of the 
scripting/expression allow-list protects the *public* surface; it is not a 
sandbox that makes arbitrary admin-authored expressions safe.
+  - **Which layer is the control (verified 2026-09-23).** The MVEL defense is 
layered, and the **expression-text filter is the primary, non-optional 
control**: the allow-list of pre-vetted expressions (12 anchored plugin 
templates by default) plus the `mvel-forbid.json` forbid patterns (which reject 
reflection tokens such as `getClass`, `forName`, `ClassLoader`, `invoke`, 
`Method`, `Class`). The `SecureFilteringClassLoader` is **defense in depth 
only**: it gates class names the engine re [...]
 - **No guarantee of correctness of analytics/segmentation under adversarial 
event injection** beyond the access-control boundary. *(inferred)*
 - **Well-known classes left to the caller/operator:** expression-injection 
(the CVE class — defended by constraining the public surface), 
event/PII-exposure via a misconfigured public endpoint, and DoS via event 
floods. *(documented history; framing inferred)*
 
@@ -143,6 +144,7 @@ Per-surface trust table *(all inferred unless noted):*
 
 - "Unomi evaluates OGNL/MVEL expressions → RCE" — by-design for **trusted 
admin-authored** conditions; the public surface is constrained (post-CVE). A 
report is `VALID` only if it shows **public/unauthenticated** input reaching 
expression evaluation; otherwise `OUT-OF-MODEL: trusted-input` / `BY-DESIGN`. 
*(documented — CVE fixes)*
 - "Scripting / reflection present in `scripting` module" — needs the 
public-reachability test (§4) before it is a finding. *(inferred)*
+- "MVEL reflection (`getClass`/`forName`/`getClassLoader`) bypasses the 
`SecureFilteringClassLoader`" — **true but by design of the layering**, not a 
class-loader bug: the class loader only gates class-*naming*; reflection is 
stopped by the expression-text filter above it (§9). `VALID` only if it shows 
*public/untrusted* input reaching the engine (i.e. past the expression 
allow-list), or the shipped forbid patterns failing to reject the reflection 
tokens; a report that merely names the c [...]
 - "No auth on the context endpoint" — the public ingestion endpoint is 
unauthenticated **by design**; the protection is schema validation + the event 
allow-list, not authentication. *(inferred)*
 - "Elasticsearch reachable / no TLS" — operator deployment responsibility 
(§9/§10). *(inferred)*
 - "Admin can run dangerous operation X" — out-of-model: admin is trusted (§7). 
*(inferred)*
@@ -178,10 +180,11 @@ Per-surface trust table *(all inferred unless noted):*
 **Wave 2 — the public boundary & its defenses:**
 4. Is **JSON-Schema validation** of incoming events on by default, and does it 
reject non-conforming/unknown events? Is it the intended primary input defense 
at the public boundary? → §8.
 5. What exactly restricts the **public** surface from reaching OGNL/MVEL 
expression evaluation today (the post-CVE allow-list / protected-events / 
third-party-key mechanism — the `ThirdPartyServer` server-id / key / IP 
allow-list / allowed-event-types config in `configuration.adoc`) — and is it on 
by default? → §4/§5a/§8.
+   - **Partially resolved (2026-09-23), for the scripting-engine layer:** once 
an expression reaches the MVEL engine, the control that restricts it is the 
`ExpressionFilter` (allow-list of pre-vetted expressions + `mvel-forbid.json` 
forbid regexes), on by default. The `SecureFilteringClassLoader` is defense in 
depth and gates only class-naming, not reflection (see §9). The *reachability* 
question — how untrusted `script::`/`parameter::` values could reach the engine 
on `/cxs/context.json [...]
 6. Are there bounds on public event size / batch / rate, or is flood 
protection the operator's (WAF/rate-limit) concern? → §8/§11a.
 
 **Wave 3 — expressions, scopes, backend:**
-7. Confirm that OGNL/MVEL expression power is **by-design for trusted 
admin-authored** conditions and is **not** a sandbox — so a finding is `VALID` 
only when *public/untrusted* input reaches it. → §9/§11a.
+7. Confirm that OGNL/MVEL expression power is **by-design for trusted 
admin-authored** conditions and is **not** a sandbox — so a finding is `VALID` 
only when *public/untrusted* input reaches it. → §9/§11a. **Confirmed 
(2026-09-23):** not a sandbox; the class-loader layer gates class-naming only 
and does not stop reflection — the expression-text filter is the primary 
control. Recorded in §9 and §11a.
 8. What is the profile/**scope** isolation model — can an authenticated API 
client read/modify data outside its scope, and is that boundary something Unomi 
enforces or the integrator's concern? → §8.
 8a. **Tenant isolation (UNOMI-139):** can an API client with credentials for 
tenant A read or modify tenant B's profiles, segments, or rules — and is that 
boundary enforced by Unomi (`TenantService` / 
`SecurityService.getTenantEncryptionKey`) or delegated to the integrator? → 
§8/§10. *(maintainer-flagged — sergehuber)*
 9. Is the Elasticsearch/OpenSearch backend assumed trusted/secured-by-operator 
(so backend-exposure reports are out-of-model)? → §3/§9.
diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index 5f51a0561..f77684741 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -549,8 +549,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.
@@ -687,7 +701,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 4dd435a78..5bc1c152c 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -31,7 +31,11 @@ 
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.
-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 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/MvelScriptExecutor.java 
b/scripting/src/main/java/org/apache/unomi/scripting/MvelScriptExecutor.java
index cb706050f..6cd53e0ad 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;
@@ -51,34 +49,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..171989f46 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,26 @@ 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. MVEL's own compiler 
internals are loaded by the
+     * MVEL bundle class loader, not through this filtering loader, so denying 
these does not affect
+     * compilation of allow-listed expressions. Wildcard entries cover whole 
gadget packages so a
+     * future MVEL release cannot reopen the hole by adding a new eval-capable 
class.
+     */
+    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.*"
+    )));
+
     private Set<String> allowedClasses = null;
     private Set<String> forbiddenClasses = null;
 
@@ -34,32 +55,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 +103,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/main/java/org/apache/unomi/scripting/internal/ExpressionFilterFactoryImpl.java
 
b/scripting/src/main/java/org/apache/unomi/scripting/internal/ExpressionFilterFactoryImpl.java
index c7c75a175..764214054 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;
 
 /**
  * OSGi-backed factory that loads allowed and forbidden expression patterns 
for script execution.
@@ -74,8 +75,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));
             }
         }
 
@@ -91,7 +92,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<>();
@@ -100,8 +101,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..89e937f18
--- /dev/null
+++ 
b/scripting/src/test/java/org/apache/unomi/scripting/SecureFilteringClassLoaderTest.java
@@ -0,0 +1,118 @@
+/*
+ * 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_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"));
+    }
+}

Reply via email to