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 + } + } }
