This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch bound-diagnostic-and-script-caches in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 7c792a7abd2168a83bf4f225418bfaf3360d778c Author: Serge Huber <[email protected]> AuthorDate: Tue Sep 8 21:01:08 2026 +0200 Bound diagnostic type tracking and the MVEL expression cache. Public input can feed unique ids and rejected scripts; keep those maps from growing without a cap. --- .../apache/unomi/scripting/MvelScriptExecutor.java | 46 ++++++++++++++++++---- .../unomi/scripting/MvelScriptExecutorTest.java | 34 ++++++++++++++++ .../services/impl/TypeResolutionServiceImpl.java | 34 ++++++++++++++-- .../services/impl/events/EventServiceImpl.java | 5 ++- .../impl/TypeResolutionServiceImplTest.java | 22 +++++++++++ 5 files changed, 129 insertions(+), 12 deletions(-) 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..dc01f3835 100644 --- a/scripting/src/main/java/org/apache/unomi/scripting/MvelScriptExecutor.java +++ b/scripting/src/main/java/org/apache/unomi/scripting/MvelScriptExecutor.java @@ -21,8 +21,13 @@ import org.mvel2.ParserConfiguration; import org.mvel2.ParserContext; import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; /** * MVEL script executor implementation @@ -31,7 +36,22 @@ public class MvelScriptExecutor implements ScriptExecutor { private final static String INVALID_SCRIPT_MARKER = "--- Invalid Script Marker ---"; - private Map<String, Serializable> mvelExpressions = new ConcurrentHashMap<>(); + private static final int DEFAULT_EXPRESSIONS_CACHE_MAX_SIZE = 1000; + + private final int expressionsCacheMaxSize = Integer.getInteger( + "org.apache.unomi.scripting.mvel.expressions.cache.max.size", DEFAULT_EXPRESSIONS_CACHE_MAX_SIZE); + + /** + * Size-bounded LRU cache keyed by a fixed-size hash of the script text. Rejected scripts can be + * unique and large, so an unbounded map keyed by the raw script would grow without limit. + */ + private final Map<String, Serializable> mvelExpressions = Collections.synchronizedMap( + new LinkedHashMap<String, Serializable>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry<String, Serializable> eldest) { + return size() > expressionsCacheMaxSize; + } + }); private SecureFilteringClassLoader secureFilteringClassLoader = new SecureFilteringClassLoader(getClass().getClassLoader()); private ExpressionFilterFactory expressionFilterFactory; @@ -51,10 +71,12 @@ public class MvelScriptExecutor implements ScriptExecutor { try { Thread.currentThread().setContextClassLoader(secureFilteringClassLoader); - if (!mvelExpressions.containsKey(script)) { + String scriptCacheKey = getScriptCacheKey(script); + Serializable compiledScript = mvelExpressions.get(scriptCacheKey); + if (compiledScript == null) { if (expressionFilterFactory.getExpressionFilter("mvel").filter(script) == null) { - mvelExpressions.put(script, INVALID_SCRIPT_MARKER); + compiledScript = INVALID_SCRIPT_MARKER; } else { ParserConfiguration parserConfiguration = new ParserConfiguration(); parserConfiguration.setClassLoader(secureFilteringClassLoader); @@ -71,11 +93,12 @@ public class MvelScriptExecutor implements ScriptExecutor { parserContext.addImport("ThreadLocal", String.class); parserContext.addImport("SecurityManager", String.class); - mvelExpressions.put(script, MVEL.compileExpression(script, parserContext)); + compiledScript = MVEL.compileExpression(script, parserContext); } + mvelExpressions.put(scriptCacheKey, compiledScript); } - if (mvelExpressions.containsKey(script) && mvelExpressions.get(script) != INVALID_SCRIPT_MARKER) { - return MVEL.executeExpression(mvelExpressions.get(script), context); + if (compiledScript != INVALID_SCRIPT_MARKER) { + return MVEL.executeExpression(compiledScript, context); } else { return null; } @@ -83,4 +106,13 @@ public class MvelScriptExecutor implements ScriptExecutor { Thread.currentThread().setContextClassLoader(tccl); } } + + private static String getScriptCacheKey(String script) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(script.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + return script; + } + } } 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..da2b0786c 100644 --- a/scripting/src/test/java/org/apache/unomi/scripting/MvelScriptExecutorTest.java +++ b/scripting/src/test/java/org/apache/unomi/scripting/MvelScriptExecutorTest.java @@ -23,6 +23,7 @@ import org.junit.Test; import java.io.File; import java.io.IOException; +import java.lang.reflect.Field; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -30,6 +31,7 @@ import java.util.Set; import java.util.regex.Pattern; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class MvelScriptExecutorTest { @@ -115,6 +117,38 @@ public class MvelScriptExecutorTest { assertFalse("Vulnerability successfully executed ! File created at " + vulnFile.getCanonicalPath(), vulnFile.exists()); } + @Test + public void testRejectedScriptCacheIsBounded() throws Exception { + System.setProperty("org.apache.unomi.scripting.mvel.expressions.cache.max.size", "10"); + try { + MvelScriptExecutor boundedExecutor = new MvelScriptExecutor(); + boundedExecutor.setExpressionFilterFactory(new ExpressionFilterFactory() { + @Override + public ExpressionFilter getExpressionFilter(String filterCollection) { + Set<Pattern> allowedExpressions = new HashSet<>(); + Set<Pattern> forbiddenExpressions = new HashSet<>(); + return new ExpressionFilter(allowedExpressions, forbiddenExpressions); + } + }); + String padding = "x".repeat(4096); + Map<String, Object> ctx = new HashMap<>(); + for (int i = 0; i < 1000; i++) { + boundedExecutor.execute("rejected-script-" + i + "-" + padding, ctx); + } + Field mvelExpressionsField = MvelScriptExecutor.class.getDeclaredField("mvelExpressions"); + mvelExpressionsField.setAccessible(true); + Map<?, ?> cache = (Map<?, ?>) mvelExpressionsField.get(boundedExecutor); + assertTrue("Rejected-script cache must be size-bounded but grew to " + cache.size(), + cache.size() <= 10); + for (Object cacheKey : cache.keySet()) { + assertTrue("Cache keys must be fixed-size hashes, not the raw script text", + ((String) cacheKey).length() <= 64); + } + } finally { + System.clearProperty("org.apache.unomi.scripting.mvel.expressions.cache.max.size"); + } + } + private static Event generateMockEvent() { Event mockEvent = new Event(); CustomItem targetItem = new CustomItem(); diff --git a/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java index 329756a75..0e99cc571 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java @@ -45,6 +45,12 @@ public class TypeResolutionServiceImpl implements TypeResolutionService { private static final Logger LOGGER = LoggerFactory.getLogger(TypeResolutionServiceImpl.class.getName()); private static final int MAX_RECURSION_DEPTH = 1000; + + /** + * Upper bound for diagnostic de-duplication collections. Some identifiers come from public + * input (for example condition type ids on context filters), so these sets must not grow without limit. + */ + private static final int MAX_TRACKED_DIAGNOSTIC_ENTRIES = 1000; private volatile DefinitionsService definitionsService; @@ -127,7 +133,7 @@ public class TypeResolutionServiceImpl implements TypeResolutionService { } ConditionType conditionType = definitionsService.getConditionType(conditionTypeId); if (conditionType == null) { - if (unresolvedConditionTypes.add(conditionTypeId)) { + if (addBoundedDiagnostic(unresolvedConditionTypes, conditionTypeId)) { LOGGER.warn("Couldn't resolve condition type: {} for {}", conditionTypeId, contextObjectName); } return false; @@ -217,6 +223,20 @@ public class TypeResolutionServiceImpl implements TypeResolutionService { } } + /** + * Adds a value to a bounded diagnostic de-duplication set. Returns {@code true} if the value + * was newly stored (the caller should log). Once the cap is reached, further values are ignored. + */ + private static boolean addBoundedDiagnostic(Set<String> set, String value) { + if (set.contains(value)) { + return false; + } + if (set.size() >= MAX_TRACKED_DIAGNOSTIC_ENTRIES) { + return false; + } + return set.add(value); + } + @Override public boolean resolveActionTypes(Rule rule, boolean ignoreErrors) { if (definitionsService == null) { @@ -228,7 +248,7 @@ public class TypeResolutionServiceImpl implements TypeResolutionService { if (rule.getActions() == null) { if (!ignoreErrors) { // Only warn once per rule to avoid log spam - if (warnedRulesWithNullActions.add(ruleId)) { + if (addBoundedDiagnostic(warnedRulesWithNullActions, ruleId)) { LOGGER.warn("Rule {}:{} has null actions", ruleId, rule.getMetadata() != null ? rule.getMetadata().getName() : "unknown"); } } @@ -237,7 +257,7 @@ public class TypeResolutionServiceImpl implements TypeResolutionService { if (rule.getActions().isEmpty()) { if (!ignoreErrors) { // Only warn once per rule to avoid log spam - if (warnedRulesWithNullActions.add(ruleId)) { + if (addBoundedDiagnostic(warnedRulesWithNullActions, ruleId)) { LOGGER.warn("Rule {}:{} has empty actions", ruleId, rule.getMetadata() != null ? rule.getMetadata().getName() : "unknown"); } } @@ -262,7 +282,7 @@ public class TypeResolutionServiceImpl implements TypeResolutionService { unresolvedActionTypes.remove(action.getActionTypeId()); action.setActionType(actionType); } else { - if (unresolvedActionTypes.add(action.getActionTypeId())) { + if (addBoundedDiagnostic(unresolvedActionTypes, action.getActionTypeId())) { LOGGER.warn("Couldn't resolve action type : {}", action.getActionTypeId()); } return false; @@ -454,6 +474,12 @@ public class TypeResolutionServiceImpl implements TypeResolutionService { } Map<String, InvalidObjectInfo> typeMap = invalidObjects.computeIfAbsent(objectType, k -> new ConcurrentHashMap<>()); + + if (!typeMap.containsKey(objectId) && typeMap.size() >= MAX_TRACKED_DIAGNOSTIC_ENTRIES) { + LOGGER.debug("Invalid object tracking for type {} is full ({} entries), not tracking {}", + objectType, MAX_TRACKED_DIAGNOSTIC_ENTRIES, objectId); + return; + } InvalidObjectInfo newInfo = new InvalidObjectInfo( objectType, diff --git a/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java index 3442a59ac..493cc03ca 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java @@ -48,6 +48,7 @@ public class EventServiceImpl implements EventService { /** Event-type chains already logged at recursion limit (see {@link #recursionChainKey}). */ private static final Set<String> LOGGED_RECURSION_CHAINS = ConcurrentHashMap.newKeySet(); + private static final int MAX_LOGGED_RECURSION_CHAINS = 1000; /** * Simple data class to hold event information for recursion tracking. @@ -221,7 +222,9 @@ public class EventServiceImpl implements EventService { // Original allowed depths 0-10 (11 calls), blocking at depth 11 if (eventStack.size() > MAX_RECURSION_DEPTH) { String chainKey = recursionChainKey(eventStack); - if (LOGGED_RECURSION_CHAINS.add(chainKey)) { + if (!LOGGED_RECURSION_CHAINS.contains(chainKey) + && LOGGED_RECURSION_CHAINS.size() < MAX_LOGGED_RECURSION_CHAINS + && LOGGED_RECURSION_CHAINS.add(chainKey)) { EventInfo currentEventInfo = new EventInfo(event); if (tracer != null) { tracer.trace("Max recursion depth reached for event: " + event.getEventType(), event.getItemId()); diff --git a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java index 9e62633e8..db69770ae 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -1362,5 +1362,27 @@ public class TypeResolutionServiceImplTest { assertTrue(info.getReason().contains("missingLeafType"), "Reason text must name the actual failing leaf type"); } + + @Test + public void unresolvedConditionTypeTracking_isBounded() { + when(definitionsService.getConditionType(startsWith("unknownType-"))).thenReturn(null); + + for (int i = 0; i < 2500; i++) { + Condition condition = new Condition(); + condition.setConditionTypeId("unknownType-" + i); + assertFalse(typeResolutionService.resolveConditionType(condition, "boundedness test")); + } + + java.lang.reflect.Field field; + try { + field = TypeResolutionServiceImpl.class.getDeclaredField("unresolvedConditionTypes"); + field.setAccessible(true); + Set<?> tracked = (Set<?>) field.get(typeResolutionService); + assertTrue(tracked.size() <= 1000, + "unresolved condition type tracking must stay bounded, but had " + tracked.size() + " entries"); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } }
