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

henrib pushed a commit to branch JEXL-471
in repository https://gitbox.apache.org/repos/asf/commons-jexl.git

commit bb11f28aeed638aa4b6ee96a8c494fda0006e107
Author: Henrib <[email protected]>
AuthorDate: Sun Aug 30 13:27:59 2026 +0200

    JEXL-471 : Runtime hardening: Constrain BigInteger operations and ensure 
regex interruptibility
    
    1. Make regex matching (=~ operator) interruptible
       * Wrap matched string in InterruptibleCharSequence
       * Samples Thread.isInterrupted() every 256 chars
       * Throws ArithmeticException -> JexlException.Cancel on interruption
       * Add length guard on regex patterns (max 2048 chars)
    
    2. Enforce MathContext precision on BigInteger arithmetic
       * Move checkBigIntegerPrecision() outside try-catch in 
add()/subtract()/etc
       * Prevent ArithmeticException from being silently swallowed
       * Bounded results prevent memory exhaustion
    
    3. Prevent O(n²) DoS from huge BigInteger literals at parse time
       * Cap literal digit count by MathContext.getPrecision()
       * Fallback to hardcoded 256-digit limit if no precision configured
       * NumberFormatException wraps as JexlException.Parsing
    
    Tests added:
       * testRegexMatchingInterruptible()
       * testRegexPatternTooLong()
       * testBigIntegerArithmeticPrecisionCap()
       * testBigIntegerLiteralTooLong()
    
    Co-Authored-By: Claude <[email protected]>
---
 src/changes/changes.xml                            |  1 +
 .../org/apache/commons/jexl3/JexlArithmetic.java   | 89 ++++++++++++++++++++--
 .../apache/commons/jexl3/internal/Interpreter.java | 25 +++++-
 .../apache/commons/jexl3/internal/Operator.java    |  3 +
 .../apache/commons/jexl3/parser/NumberParser.java  | 37 +++++++++
 .../org/apache/commons/jexl3/parser/Parser.jjt     |  4 +
 .../org/apache/commons/jexl3/ArithmeticTest.java   | 71 +++++++++++++++++
 7 files changed, 221 insertions(+), 9 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index ed248075..9260fa1c 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -38,6 +38,7 @@
             <action dev="ggregory" type="fix" due-to="Gary Gregory">Add 
messages when throwing NullPointerException.</action>
             <action dev="henrib" due-to="Claude" type="fix" 
issue="JEXL-468">Improve robustness of introspection permissions and sandbox 
delegation: close nested-class and interface-method denial gaps, fix 
class-initialization ordering, gate sandbox iteration, fix permission-parser 
polarity, deny second-stage compiler surface under RESTRICTED, and enforce 
parser feature restrictions in sub-parsers.</action>
             <action dev="henrib" due-to="Claude" type="fix" 
issue="JEXL-470">Fix several parser and interpreter correctness issues: Unicode 
escape hex-digit validation, safe-navigation array-access child indexing, regex 
escape preservation, empty()/size() error and cancellation propagation, and 
switch+continue semantics.</action>
+            <action dev="henrib" due-to="Claude" type="fix" 
issue="JEXL-471">Runtime hardening: make regex matching (=~ operator) 
interruptible, enforce MathContext precision on BigInteger arithmetic results, 
and prevent O(n²) DoS from huge BigInteger literals at parse time.</action>
             <!-- UPDATE -->
             <action type="update" dev="ggregory" due-to="Gary Gregory">Bump 
org.apache.commons:commons-parent from 102 to 104.</action>
         </release>
diff --git a/src/main/java/org/apache/commons/jexl3/JexlArithmetic.java 
b/src/main/java/org/apache/commons/jexl3/JexlArithmetic.java
index 6cfccba2..ce34317a 100644
--- a/src/main/java/org/apache/commons/jexl3/JexlArithmetic.java
+++ b/src/main/java/org/apache/commons/jexl3/JexlArithmetic.java
@@ -218,6 +218,9 @@ public class JexlArithmetic {
      */
     public static final Pattern FLOAT_PATTERN = 
Pattern.compile("^[+-]?\\d*(\\.\\d*)?([eE][+-]?\\d+)?$");
 
+    /** Maximum length of a regex pattern string for the {@code =~} operator 
(JEXL-security f012). */
+    protected static final int REGEX_PATTERN_MAX_LENGTH = 2048;
+
     /**
      * Attempts transformation of potential array in an abstract list or leave 
as is.
      * <p>An array (as in int[]) is not convenient to call methods so when 
encountered we turn them into lists</p>
@@ -340,6 +343,7 @@ public class JexlArithmetic {
                             ? left instanceof String || right instanceof String
                             : left instanceof String && right instanceof 
String;
         if (!strconcat) {
+            BigInteger bigIntResult = null;
             try {
                 final boolean strictCast = isStrict(JexlOperator.ADD);
                 // if both (non-null) args fit as long
@@ -370,11 +374,14 @@ public class JexlArithmetic {
                 // otherwise treat as BigInteger
                 final BigInteger l = toBigInteger(strictCast, left);
                 final BigInteger r = toBigInteger(strictCast, right);
-                final BigInteger result = l.add(r);
-                return narrowBigInteger(left, right, result);
+                bigIntResult = l.add(r);
             } catch (final ArithmeticException nfe) {
                 // ignore and continue in sequence
             }
+            // precision check is outside the catch so it is not silently 
swallowed (f013)
+            if (bigIntResult != null) {
+                return narrowBigInteger(left, right, 
checkBigIntegerPrecision(bigIntResult));
+            }
         }
         return (left == null ? "" : toString(left)).concat(right == null ? "" 
: toString(right));
     }
@@ -591,10 +598,14 @@ public class JexlArithmetic {
         }
         // use arithmetic / pattern matching ?
         if (container instanceof java.util.regex.Pattern) {
-            return ((java.util.regex.Pattern) 
container).matcher(value.toString()).matches();
+            return ((java.util.regex.Pattern) container).matcher(new 
InterruptibleCharSequence(value.toString())).matches();
         }
         if (container instanceof CharSequence) {
-            return value.toString().matches(container.toString());
+            final String regex = container.toString();
+            if (regex.length() > REGEX_PATTERN_MAX_LENGTH) {
+                throw new ArithmeticException("regular expression too long: " 
+ regex.length() + " > " + REGEX_PATTERN_MAX_LENGTH);
+            }
+            return Pattern.compile(regex).matcher(new 
InterruptibleCharSequence(value.toString())).matches();
         }
         // try contains on map key
         if (container instanceof Map<?, ?>) {
@@ -908,6 +919,28 @@ public class JexlArithmetic {
         return compare(left, right, EQ) == 0;
     }
 
+    /**
+     * Guards a BigInteger result against exceeding the arithmetic context's 
precision (JEXL-security f013).
+     * <p>When {@link MathContext#getPrecision()} is zero (unlimited), no 
limit is enforced.
+     * Otherwise, the BigInteger must fit within approximately that many 
significant decimal digits.</p>
+     *
+     * @param big the value to check
+     * @return big unchanged if within the limit
+     * @throws ArithmeticException when the limit is exceeded
+     */
+    protected BigInteger checkBigIntegerPrecision(final BigInteger big) {
+        final int precision = getMathContext().getPrecision();
+        if (precision > 0) {
+            // precision 0 means unlimited; otherwise, one decimal digit ≈ 
log2(10) ≈ 10/3 bits
+            final int maxBits = precision * 10 / 3 + 1;
+            if (big.bitLength() > maxBits) {
+                throw new ArithmeticException(
+                  "BigInteger precision exceeded: " + big.bitLength() + " bits 
for " + precision + "-digit context");
+            }
+        }
+        return big;
+    }
+
     /**
      * The MathContext instance used for +,-,/,*,% operations on big decimals.
      *
@@ -1374,9 +1407,13 @@ public class JexlArithmetic {
             final double r = toDouble(strictCast, right);
             return l * r;
         }
-        // otherwise treat as BigInteger
+        // otherwise treat as BigInteger; pre-check bit-length sum to avoid 
O(n²) on huge operands
         final BigInteger l = toBigInteger(strictCast, left);
         final BigInteger r = toBigInteger(strictCast, right);
+        final int precision = getMathContext().getPrecision();
+        if (precision > 0 && l.bitLength() + r.bitLength() > precision * 10 / 
3 + 1) {
+            throw new ArithmeticException("BigInteger precision exceeded");
+        }
         final BigInteger result = l.multiply(r);
         return narrowBigInteger(left, right, result);
     }
@@ -1746,7 +1783,7 @@ public class JexlArithmetic {
      */
     private BigInteger parseBigInteger(final String arg) throws 
ArithmeticException {
         try {
-            return arg.isEmpty()? BigInteger.ZERO : new BigInteger(arg);
+            return arg.isEmpty() ? BigInteger.ZERO : 
checkBigIntegerPrecision(new BigInteger(arg));
         } catch (final NumberFormatException e) {
             throw new CoercionException("BigDecimal coercion: ("+ arg +")", e);
         }
@@ -2065,7 +2102,7 @@ public class JexlArithmetic {
         final BigInteger l = toBigInteger(strictCast, left);
         final BigInteger r = toBigInteger(strictCast, right);
         final BigInteger result = l.subtract(r);
-        return narrowBigInteger(left, right, result);
+        return narrowBigInteger(left, right, checkBigIntegerPrecision(result));
     }
 
     /**
@@ -2435,4 +2472,42 @@ public class JexlArithmetic {
         final long r = toLong(right);
         return l ^ r;
     }
+
+    /**
+     * A CharSequence wrapper that throws ArithmeticException if the current 
thread is interrupted.
+     * Used as the input to {@code Pattern.matcher()} so that 
catastrophic-backtracking regex matches
+     * remain responsive to JEXL cancellation (which sets the thread interrupt 
flag).
+     * The interrupt flag is checked every 256 {@code charAt} calls to limit 
overhead.
+     */
+    private static final class InterruptibleCharSequence implements 
CharSequence {
+        private final String seq;
+        private int count;
+
+        private InterruptibleCharSequence(final String s) {
+            this.seq = s;
+        }
+
+        @Override
+        public char charAt(final int index) {
+            if ((++count & 0xff) == 0 && 
Thread.currentThread().isInterrupted()) {
+                throw new ArithmeticException("Operation interrupted");
+            }
+            return seq.charAt(index);
+        }
+
+        @Override
+        public int length() {
+            return seq.length();
+        }
+
+        @Override
+        public CharSequence subSequence(final int start, final int end) {
+            return seq.subSequence(start, end);
+        }
+
+        @Override
+        public String toString() {
+            return seq;
+        }
+    }
 }
diff --git a/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java 
b/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java
index 2d18e886..e4842b89 100644
--- a/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java
+++ b/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java
@@ -22,6 +22,7 @@ import java.util.Iterator;
 import java.util.Objects;
 import java.util.Queue;
 import java.util.concurrent.Callable;
+import java.util.regex.Pattern;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
 
@@ -1377,12 +1378,31 @@ public class Interpreter extends InterpreterBase {
     @Override
     protected Object visit(final ASTERNode node, final Object data) {
         final Object left = node.jjtGetChild(0).jjtAccept(this, data);
-        final Object right = node.jjtGetChild(1).jjtAccept(this, data);
+        final JexlNode rightNode = node.jjtGetChild(1);
+        final Object right = resolvePattern(rightNode, 
rightNode.jjtAccept(this, data));
         // note the arguments inversion between 'in'/'matches' and 'contains'
         // if x in y then y contains x
         return operators.contains(node, JexlOperator.CONTAINS, right, left);
     }
 
+    /**
+     * If the right operand of {@code =~} / {@code !~} is a string literal, 
compile it to a Pattern once and
+     * cache the result in the node's value slot (same mechanism as negated 
numeric literals).
+     * Dynamic string values (from variables) are returned unchanged.
+     */
+    private static Object resolvePattern(final JexlNode rightNode, final 
Object right) {
+        if (right instanceof CharSequence && rightNode instanceof 
JexlNode.Constant) {
+            final Object cached = rightNode.jjtGetValue();
+            if (cached instanceof Pattern) {
+                return cached;
+            }
+            final Pattern compiled = Pattern.compile(right.toString());
+            rightNode.jjtSetValue(compiled);
+            return compiled;
+        }
+        return right;
+    }
+
     @Override
     protected Object visit(final ASTEWNode node, final Object data) {
         final Object left = node.jjtGetChild(0).jjtAccept(this, data);
@@ -1728,7 +1748,8 @@ public class Interpreter extends InterpreterBase {
     @Override
     protected Object visit(final ASTNRNode node, final Object data) {
         final Object left = node.jjtGetChild(0).jjtAccept(this, data);
-        final Object right = node.jjtGetChild(1).jjtAccept(this, data);
+        final JexlNode rightNode = node.jjtGetChild(1);
+        final Object right = resolvePattern(rightNode, 
rightNode.jjtAccept(this, data));
         // note the arguments inversion between (not) 'in'/'matches' and  
(not) 'contains'
         // if x not-in y then y not-contains x
         return operators.contains(node, JexlOperator.NOT_CONTAINS, right, 
left);
diff --git a/src/main/java/org/apache/commons/jexl3/internal/Operator.java 
b/src/main/java/org/apache/commons/jexl3/internal/Operator.java
index 41810ee4..af07f083 100644
--- a/src/main/java/org/apache/commons/jexl3/internal/Operator.java
+++ b/src/main/java/org/apache/commons/jexl3/internal/Operator.java
@@ -363,6 +363,9 @@ public final class Operator implements 
JexlOperator.Uberspect {
             // not-contains is !contains
             return JexlOperator.CONTAINS == operator == contained;
         } catch (final Exception any) {
+            if (Thread.currentThread().isInterrupted()) {
+                throw new JexlException.Cancel(node instanceof JexlNode ? 
(JexlNode) node : null);
+            }
             return operatorError(node, operator, any, false);
         }
     }
diff --git a/src/main/java/org/apache/commons/jexl3/parser/NumberParser.java 
b/src/main/java/org/apache/commons/jexl3/parser/NumberParser.java
index e79f80fc..595f72b4 100644
--- a/src/main/java/org/apache/commons/jexl3/parser/NumberParser.java
+++ b/src/main/java/org/apache/commons/jexl3/parser/NumberParser.java
@@ -23,6 +23,8 @@ import java.text.DecimalFormat;
 import java.text.DecimalFormatSymbols;
 import java.util.Locale;
 
+import org.apache.commons.jexl3.JexlEngine;
+
 /**
  * Parses number literals.
  */
@@ -32,6 +34,30 @@ public final class NumberParser implements Serializable {
      */
     private static final long serialVersionUID = 1L;
 
+    /**
+     * Hard upper bound on BigInteger literal digits when no engine precision 
is configured.
+     * Acts as a parse-time DoS guard independent of any MathContext 
(JEXL-security f014).
+     */
+    static final int MAX_BIGINTEGER_DIGITS = 256;
+
+    /**
+     * Returns the maximum digit count allowed for a BigInteger literal.
+     * When a JEXL engine with a bounded MathContext is active on the current 
thread, the
+     * engine's precision (in decimal digits) is used as the limit; otherwise 
MAX_BIGINTEGER_DIGITS applies.
+     * At runtime, JexlArithmetic.checkBigIntegerPrecision() enforces a 
stricter bit-length limit
+     * based on the same precision (f014, f013).
+     */
+    private static int maxBigIntegerDigits() {
+        final JexlEngine engine = JexlEngine.getThreadEngine();
+        if (engine != null) {
+            final int precision = 
engine.getArithmetic().getMathContext().getPrecision();
+            if (precision > 0) {
+                return precision;
+            }
+        }
+        return MAX_BIGINTEGER_DIGITS;
+    }
+
     /** JEXL locale-neutral big decimal format. */
     static final DecimalFormat BIGDF = new DecimalFormat("0.0b", new 
DecimalFormatSymbols(Locale.ROOT));
     private static boolean isNegative(final Token token) {
@@ -77,6 +103,7 @@ public final class NumberParser implements Serializable {
         String s = natural;
         Number result;
         Class<? extends Number> rclass;
+        final int maxDigits = maxBigIntegerDigits();
         // determine the base
         final int base;
         if (s.charAt(0) == '0') {
@@ -102,6 +129,11 @@ public final class NumberParser implements Serializable {
             case 'h':
             case 'H': {
                 rclass = BigInteger.class;
+                if (last > maxDigits) {
+                    throw new NumberFormatException(
+                      "BigInteger literal too long: " + last
+                      + " > " + maxDigits);
+                }
                 final BigInteger bi = new BigInteger(s.substring(0, last), 
base);
                 result = negative? bi.negate() : bi;
                 break;
@@ -117,6 +149,11 @@ public final class NumberParser implements Serializable {
                         final long l = Long.parseLong(s, base);
                         result = negative? -l : l;
                     } catch (final NumberFormatException take3) {
+                        if (s.length() > maxDigits) {
+                            throw new NumberFormatException(
+                              "BigInteger literal too long: " + s.length()
+                              + " > " + maxDigits);
+                        }
                         final BigInteger bi = new BigInteger(s, base);
                         result = negative? bi.negate() : bi;
                     }
diff --git a/src/main/java/org/apache/commons/jexl3/parser/Parser.jjt 
b/src/main/java/org/apache/commons/jexl3/parser/Parser.jjt
index 198d3666..48c4af62 100644
--- a/src/main/java/org/apache/commons/jexl3/parser/Parser.jjt
+++ b/src/main/java/org/apache/commons/jexl3/parser/Parser.jjt
@@ -103,6 +103,10 @@ public final class Parser extends JexlParser
             JexlInfo ji = et == null ? info : info.at(et.beginLine, 
et.beginColumn);
             String msg = et == null ? xparse.getMessage() : et.image;
             throw new JexlException.Parsing(ji, msg).clean();
+        } catch (NumberFormatException xnfe) {
+            Token et = errorToken(jj_lastpos, jj_scanpos, token.next, token);
+            JexlInfo ji = et == null ? info : info.at(et.beginLine, 
et.beginColumn);
+            throw new JexlException.Parsing(ji, xnfe.getMessage()).clean();
         } finally {
             token_source.defaultLexState = DEFAULT;
             token_source.ignoredTokens = Collections.emptySet();
diff --git a/src/test/java/org/apache/commons/jexl3/ArithmeticTest.java 
b/src/test/java/org/apache/commons/jexl3/ArithmeticTest.java
index 257dcb4c..ebf24ff9 100644
--- a/src/test/java/org/apache/commons/jexl3/ArithmeticTest.java
+++ b/src/test/java/org/apache/commons/jexl3/ArithmeticTest.java
@@ -2409,4 +2409,75 @@ class ArithmeticTest extends JexlTestCase {
         assertEquals("zero", jexl.createExpression("array.0").evaluate(jc));
         assertEquals("one", jexl.createExpression("array.1").evaluate(jc));
     }
+
+    // ----- security fixes f012 / f013 / f014 -----
+
+    /**
+     * f014: BigInteger literal with more digits than MAX_BIGINTEGER_DIGITS 
must be rejected at parse time.
+     */
+    @Test
+    void testBigIntegerLiteralTooLong() {
+        // normal H-literal still works
+        assertNotNull(JEXL.createScript("42H"));
+        // a literal just over the cap (4096 + 1 digits + 'H') must fail to 
parse
+        final char[] digits = new char[4097];
+        java.util.Arrays.fill(digits, '1');
+        final String huge = new String(digits) + "H";
+        assertThrows(JexlException.Parsing.class, () -> 
JEXL.createScript(huge));
+    }
+
+    /**
+     * f013: BigInteger arithmetic results that exceed the MathContext 
precision must throw.
+     */
+    @Test
+    void testBigIntegerArithmeticPrecisionCap() {
+        // precision=3 caps at ~11 bits (formula: 3 * 10 / 3 + 1 = 11), so 
results > 2047 are rejected
+        final JexlArithmetic bounded = new JexlArithmetic(true, new 
MathContext(3), JexlArithmetic.BIGD_SCALE);
+        final JexlEngine jexl = new JexlBuilder().arithmetic(bounded).create();
+        // small values are fine
+        assertEquals(new BigInteger("3"), jexl.createScript("a + b", "a", "b")
+                .execute(null, BigInteger.ONE, BigInteger.valueOf(2L)));
+        // result > 2047 is rejected: 1500 + 1000 = 2500, bitLength=12 > 11
+        assertThrows(JexlException.class, () ->
+                jexl.createScript("a + b", "a", "b")
+                    .execute(null, BigInteger.valueOf(1500L), 
BigInteger.valueOf(1000L)));
+        // multiply pre-check: 64 (7 bits) * 64 (7 bits), sum of operand bits 
= 14 > 11
+        assertThrows(JexlException.class, () ->
+                jexl.createScript("a * b", "a", "b")
+                    .execute(null, BigInteger.valueOf(64L), 
BigInteger.valueOf(64L)));
+    }
+
+    /**
+     * f012: a regex pattern string longer than REGEX_PATTERN_MAX_LENGTH must 
throw.
+     */
+    @Test
+    void testRegexPatternTooLong() {
+        final JexlEngine jexl = new JexlBuilder().strict(true).create();
+        final JexlScript script = jexl.createScript("x =~ y", "x", "y");
+        final char[] chars = new char[JexlArithmetic.REGEX_PATTERN_MAX_LENGTH 
+ 1];
+        java.util.Arrays.fill(chars, 'a');
+        final String longPattern = new String(chars);
+        assertThrows(JexlException.class, () -> script.execute(null, "abc", 
longPattern));
+    }
+
+    /**
+     * f012: regex matching must respond to thread interruption so a 
catastrophic-backtracking
+     * pattern does not hang a cancellable engine indefinitely.
+     */
+    @Test
+    void testRegexMatchingInterruptible() {
+        final JexlEngine jexl = new JexlBuilder().cancellable(true).create();
+        // Classic catastrophic-backtracking: (a+)+b against a non-matching 
string
+        final JexlScript script = jexl.createScript("x =~ y", "x", "y");
+        final String evilPattern = "(a+)+b";
+        final char[] chars = new char[20];
+        java.util.Arrays.fill(chars, 'a');
+        final String evilValue = new String(chars) + "c";
+        try {
+            Thread.currentThread().interrupt();
+            assertThrows(JexlException.Cancel.class, () -> 
script.execute(null, evilValue, evilPattern));
+        } finally {
+            Thread.interrupted(); // clear so it does not leak into other tests
+        }
+    }
 }

Reply via email to