You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2026/09/18 15:21:42 UTC
(unomi) 03/04: Bound regular-expression evaluation in the matchesRegex operator
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch bound-matchesregex-condition-evaluation
in repository https://gitbox.apache.org/repos/asf/unomi.git
commit d9b518ea2d414a94df9cceb0be7cd9a80519181a
Author: Serge Huber <sh...@apache.org>
AuthorDate: Tue Aug 18 21:51:30 2026 +0200
Bound regular-expression evaluation in the matchesRegex operator
The matchesRegex condition operator compiled and evaluated a
caller-supplied regular expression with no bound on the work performed,
so a pathological pattern could consume a request-handling thread. Route
it through a guarded evaluation that caps the pattern and input lengths,
caches compiled patterns in a bounded LRU, and aborts once a fixed
character-access budget is exceeded. All limits are configurable via
org.apache.unomi.conditions.regex.* system properties, and regex
semantics are unchanged for well-behaved patterns.
Add tests covering normal matches, invalid patterns, catastrophic
backtracking, and oversized pattern/input rejection.
Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
.../conditions/PropertyConditionEvaluator.java | 98 +++++++++++++++++++++-
.../conditions/PropertyConditionEvaluatorTest.java | 41 +++++++++
2 files changed, 138 insertions(+), 1 deletion(-)
diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluator.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluator.java
index 8229fe6c1..82d0a379a 100644
--- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluator.java
+++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluator.java
@@ -40,6 +40,7 @@ import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;
import static org.apache.unomi.persistence.spi.conditions.DateUtils.getDate;
@@ -58,6 +59,28 @@ public class PropertyConditionEvaluator implements ConditionEvaluator {
private static final HardcodedPropertyAccessorRegistry hardcodedPropertyAccessorRegistry = new HardcodedPropertyAccessorRegistry();
private ExpressionFilterFactory expressionFilterFactory;
+ // The matchesRegex operator compiles and evaluates a regular expression that can originate from an
+ // untrusted, unauthenticated caller (a condition tree on the public context/eventcollector endpoints,
+ // or a GraphQL filter). java.util.regex is a backtracking engine, so an unbounded evaluation is a
+ // single-request CPU-exhaustion primitive. These guards bound the pattern length, the matched value
+ // length, and the total work the matcher may perform. All limits are overridable via system property
+ // for the rare deployment with legitimate larger needs.
+ private static final int MAX_REGEX_PATTERN_LENGTH =
+ Integer.getInteger("org.apache.unomi.conditions.regex.maxPatternLength", 512);
+ private static final int MAX_REGEX_INPUT_LENGTH =
+ Integer.getInteger("org.apache.unomi.conditions.regex.maxInputLength", 10000);
+ private static final long MAX_REGEX_CHAR_ACCESSES =
+ Long.getLong("org.apache.unomi.conditions.regex.maxCharAccesses", 1000000L);
+ private static final int MAX_CACHED_REGEX_PATTERNS = 1000;
+
+ private static final Map<String, Pattern> compiledRegexCache =
+ Collections.synchronizedMap(new LinkedHashMap<String, Pattern>(16, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<String, Pattern> eldest) {
+ return size() > MAX_CACHED_REGEX_PATTERNS;
+ }
+ });
+
private TracerService tracerService;
public void setUsePropertyConditionOptimizations(boolean usePropertyConditionOptimizations) {
@@ -441,7 +464,7 @@ public class PropertyConditionEvaluator implements ConditionEvaluator {
} else if (op.equals("endsWith")) {
return actualValue.toString().endsWith(expectedValue);
} else if (op.equals("matchesRegex")) {
- return expectedValue != null && Pattern.compile(expectedValue).matcher(actualValue.toString()).matches();
+ return expectedValue != null && matchesRegexSafely(expectedValue, actualValue.toString());
} else if (op.equals("in") || op.equals("inContains") || op.equals("notIn") || op.equals("hasSomeOf") || op.equals("hasNoneOf") || op.equals("all")) {
Collection<?> expectedValues = ConditionContextHelper.foldToASCII((Collection<?>) condition.getParameter("propertyValues"));
Collection<?> expectedValuesInteger = (Collection<?>) condition.getParameter("propertyValuesInteger");
@@ -485,6 +508,79 @@ public class PropertyConditionEvaluator implements ConditionEvaluator {
return false;
}
+ /**
+ * Evaluates the {@code matchesRegex} operator with ReDoS protections: the pattern and the matched
+ * value are length-capped, compiled patterns are cached, and the match runs against a budget-limited
+ * {@link CharSequence} so that catastrophic backtracking is cut off instead of pinning a request
+ * thread. Any guard violation or invalid pattern evaluates to {@code false} (the condition does not
+ * match) and is logged. Regex semantics are otherwise unchanged, so legitimate patterns behave as before.
+ */
+ protected static boolean matchesRegexSafely(String regex, String value) {
+ if (regex.length() > MAX_REGEX_PATTERN_LENGTH) {
+ LOGGER.warn("matchesRegex pattern longer than {} characters rejected", MAX_REGEX_PATTERN_LENGTH);
+ return false;
+ }
+ if (value.length() > MAX_REGEX_INPUT_LENGTH) {
+ LOGGER.warn("matchesRegex evaluation skipped: value longer than {} characters", MAX_REGEX_INPUT_LENGTH);
+ return false;
+ }
+ try {
+ Pattern pattern = compiledRegexCache.computeIfAbsent(regex, Pattern::compile);
+ return pattern.matcher(new BudgetedCharSequence(value, new long[]{MAX_REGEX_CHAR_ACCESSES})).matches();
+ } catch (PatternSyntaxException e) {
+ LOGGER.warn("matchesRegex evaluation skipped: invalid pattern: {}", e.getMessage());
+ return false;
+ } catch (RegexBudgetExceededException e) {
+ LOGGER.warn("matchesRegex evaluation aborted after {} character accesses (possible ReDoS pattern)",
+ MAX_REGEX_CHAR_ACCESSES);
+ return false;
+ }
+ }
+
+ private static final class RegexBudgetExceededException extends RuntimeException {
+ RegexBudgetExceededException() {
+ super(null, null, false, false);
+ }
+ }
+
+ /**
+ * A {@link CharSequence} wrapper that throws once a shared budget of {@code charAt} accesses is
+ * exhausted, bounding the total work a backtracking regex engine can perform on it. The budget is a
+ * single-element array so it is shared across the sub-sequences the engine may create.
+ */
+ private static final class BudgetedCharSequence implements CharSequence {
+ private final CharSequence delegate;
+ private final long[] budget;
+
+ BudgetedCharSequence(CharSequence delegate, long[] budget) {
+ this.delegate = delegate;
+ this.budget = budget;
+ }
+
+ @Override
+ public char charAt(int index) {
+ if (--budget[0] < 0) {
+ throw new RegexBudgetExceededException();
+ }
+ return delegate.charAt(index);
+ }
+
+ @Override
+ public int length() {
+ return delegate.length();
+ }
+
+ @Override
+ public CharSequence subSequence(int start, int end) {
+ return new BudgetedCharSequence(delegate.subSequence(start, end), budget);
+ }
+
+ @Override
+ public String toString() {
+ return delegate.toString();
+ }
+ }
+
protected Object getPropertyValue(Item item, String expression) throws Exception {
if (usePropertyConditionOptimizations) {
Object result = getHardcodedPropertyValue(item, expression);
diff --git a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluatorTest.java b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluatorTest.java
index dd29d597f..0fee8b8a1 100644
--- a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluatorTest.java
+++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluatorTest.java
@@ -35,6 +35,7 @@ import java.util.regex.Pattern;
import static junit.framework.TestCase.assertEquals;
import static junit.framework.TestCase.assertNull;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
public class PropertyConditionEvaluatorTest {
@@ -339,6 +340,46 @@ public class PropertyConditionEvaluatorTest {
assertFalse("Non-date expected value should not match lessThanOrEqualTo comparison", result);
}
+ @Test
+ public void testMatchesRegexStillMatchesNormalPatterns() {
+ assertTrue("Simple regex should still match",
+ propertyConditionEvaluator.isMatch("matchesRegex", "hello123", "[a-z]+\\d+", null, null, null, null, null));
+ assertFalse("Simple regex should still not match",
+ propertyConditionEvaluator.isMatch("matchesRegex", "123hello", "[a-z]+\\d+", null, null, null, null, null));
+ assertFalse("Invalid pattern should evaluate to false instead of throwing",
+ propertyConditionEvaluator.isMatch("matchesRegex", "anything", "([unclosed", null, null, null, null, null));
+ }
+
+ @Test(timeout = 5000)
+ public void testMatchesRegexCatastrophicBacktrackingIsBounded() {
+ // '(a+)+$' against a long run of 'a' followed by a non-matching character is a textbook
+ // exponential-backtracking (ReDoS) input; unbounded evaluation would run effectively forever.
+ StringBuilder subject = new StringBuilder();
+ for (int i = 0; i < 100; i++) {
+ subject.append('a');
+ }
+ subject.append('!');
+ assertFalse("Catastrophic pattern must be aborted and evaluate to false",
+ propertyConditionEvaluator.isMatch("matchesRegex", subject.toString(), "(a+)+$", null, null, null, null, null));
+ }
+
+ @Test
+ public void testMatchesRegexOversizedInputsAreRejected() {
+ StringBuilder longPattern = new StringBuilder("^");
+ for (int i = 0; i < 600; i++) {
+ longPattern.append('a');
+ }
+ assertFalse("Over-long pattern must be rejected",
+ propertyConditionEvaluator.isMatch("matchesRegex", "aaa", longPattern.toString(), null, null, null, null, null));
+
+ StringBuilder longValue = new StringBuilder();
+ for (int i = 0; i < 10001; i++) {
+ longValue.append('a');
+ }
+ assertFalse("Over-long value must be rejected",
+ propertyConditionEvaluator.isMatch("matchesRegex", longValue.toString(), "a*", null, null, null, null, null));
+ }
+
class HardcodedWorker implements Callable<Object> {
@Override