diff --git a/.gitignore b/.gitignore index 311c0a2be..c011a020b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ target/ # NetBeans files nb-configuration.xml nbactions.xml +/CLAUDE.md diff --git a/src/changes/changes.xml b/src/changes/changes.xml index ed248075b..9260fa1c6 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -38,6 +38,7 @@ Add messages when throwing NullPointerException. 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. 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. + 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. Bump org.apache.commons:commons-parent from 102 to 104. diff --git a/src/main/java/org/apache/commons/jexl3/JexlArithmetic.java b/src/main/java/org/apache/commons/jexl3/JexlArithmetic.java index 6cfccba21..8e05fb575 100644 --- a/src/main/java/org/apache/commons/jexl3/JexlArithmetic.java +++ b/src/main/java/org/apache/commons/jexl3/JexlArithmetic.java @@ -218,6 +218,15 @@ public interface Uberspect { */ 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). */ + public static final int REGEX_PATTERN_MAX_LENGTH = 2048; + + /** + * 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). + */ + public static final int MAX_BIGINTEGER_DIGITS = 256; + /** * Attempts transformation of potential array in an abstract list or leave as is. *

An array (as in int[]) is not convenient to call methods so when encountered we turn them into lists

@@ -340,6 +349,7 @@ public Object add(final Object left, final Object right) { ? 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 +380,14 @@ public Object add(final Object left, final Object right) { // 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)); } @@ -590,11 +603,17 @@ public Boolean contains(final Object container, final Object value) { return false; } // use arithmetic / pattern matching ? - if (container instanceof java.util.regex.Pattern) { - return ((java.util.regex.Pattern) container).matcher(value.toString()).matches(); - } - if (container instanceof CharSequence) { - return value.toString().matches(container.toString()); + Pattern pattern = null; + if (container instanceof Pattern) { + pattern = (Pattern) container; + controlRegexLength(pattern.pattern().length()); + } else if (container instanceof CharSequence) { + String regex = container.toString(); + controlRegexLength(regex.length()); + pattern = Pattern.compile(regex); + } + if (pattern != null) { + return pattern.matcher(new InterruptibleCharSequence(value.toString())).matches(); } // try contains on map key if (container instanceof Map) { @@ -607,6 +626,19 @@ public Boolean contains(final Object container, final Object value) { return collectionContains(container, value); } + /** + * Checks the length of a regex pattern string. + * + * @param length The length of the regex pattern string + * @throws ArithmeticException if the length exceeds {@link #REGEX_PATTERN_MAX_LENGTH} + */ + private static void controlRegexLength(int length) { + if (length > REGEX_PATTERN_MAX_LENGTH) { + throw new ArithmeticException( + "regular expression too long: " + length + " > " + REGEX_PATTERN_MAX_LENGTH); + } + } + /** * The result of +,/,-,*,% when both operands are null. * @@ -908,6 +940,28 @@ public boolean equals(final Object left, final Object right) { return compare(left, right, EQ) == 0; } + /** + * Guards a BigInteger result against exceeding the arithmetic context's precision (JEXL-security f013). + *

When {@link MathContext#getPrecision()} is zero (unlimited), no limit is enforced. + * Otherwise, the BigInteger must fit within approximately that many significant decimal digits.

+ * + * @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 long precision = getMathContext().getPrecision(); + if (precision > 0) { + // precision 0 means unlimited; otherwise, one decimal digit ≈ log2(10) ≈ 10/3 bits + final long 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 +1428,19 @@ public Object multiply(final Object left, final Object right) { 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 long precision = getMathContext().getPrecision(); + final int lBitLength = l.bitLength(); + final int rBitLength = r.bitLength(); + final long bitLengthLimit = precision * 10 / 3 + 1; + if (precision > 0 && lBitLength + rBitLength > bitLengthLimit) { + throw new ArithmeticException("BigInteger precision exceeded: limit=" + bitLengthLimit + + ", leftBitLength=" + lBitLength + + ", rightBitLength=" + rBitLength + + ", contextPrecision=" + precision); + } final BigInteger result = l.multiply(r); return narrowBigInteger(left, right, result); } @@ -1746,9 +1810,9 @@ private BigDecimal parseBigDecimal(final String arg) throws ArithmeticException */ 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); + throw new CoercionException("BigInteger coercion: ("+ arg +")", e); } } @@ -2065,7 +2129,7 @@ public Object subtract(final Object left, final Object right) { 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 +2499,42 @@ public Object xor(final Object left, final Object right) { 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 new InterruptibleCharSequence(seq.substring(start, end)); + } + + @Override + public String toString() { + return seq; + } + } } diff --git a/src/main/java/org/apache/commons/jexl3/JexlBuilder.java b/src/main/java/org/apache/commons/jexl3/JexlBuilder.java index 3e52f6027..338894bd0 100644 --- a/src/main/java/org/apache/commons/jexl3/JexlBuilder.java +++ b/src/main/java/org/apache/commons/jexl3/JexlBuilder.java @@ -165,7 +165,7 @@ public static void setDefaultOptions(final String... flags) { /** * Builds the JEXL 3.7 hardened default feature set. *

Starting from {@link #FULL} (the pre-3.7 feature set), this disables {@code new(...)}, - * global side-effects, pragmas, and annotations, and enables lexical scoping and lexical shade. + * global side-effects, pragmas, annotations, namespace instantiation, and enables lexical scoping and lexical shade. * Loops are left enabled so scripts can iterate; expressions never allow loops since they are * parsed as a single expression.

* @@ -180,7 +180,8 @@ private static JexlFeatures secureFeatures() { .annotation(false) .loops(true) .lexical(true) - .lexicalShade(true); + .lexicalShade(true) + .namespaceInstantiation(false); } /** The JexlUberspect instance. */ 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 2d18e8869..227c337e4 100644 --- a/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java +++ b/src/main/java/org/apache/commons/jexl3/internal/Interpreter.java @@ -24,6 +24,7 @@ import java.util.concurrent.Callable; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.regex.Pattern; import org.apache.commons.jexl3.JexlArithmetic; import org.apache.commons.jexl3.JexlContext; @@ -1377,12 +1378,49 @@ protected Object visit(final ASTEQSNode node, final Object data) { @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. + * The regex string length is validated before compilation (JEXL-security f012). + * Uses double-check locking: first check without lock, then synchronized recheck-and-set to avoid + * holding the lock during expensive Pattern.compile() when the same compiled script runs concurrently. + */ + private static Object resolvePattern(final JexlNode rightNode, final Object right) { + if (right instanceof CharSequence && rightNode instanceof JexlNode.Constant) { + // First check with lock to avoid expensive Pattern.compile() if already cached + synchronized (rightNode) { + Object cached = rightNode.jjtGetValue(); + if (cached instanceof Pattern) { + return cached; + } + } + // Compile without holding lock + final String regex = right.toString(); + if (regex.length() > JexlArithmetic.REGEX_PATTERN_MAX_LENGTH) { + throw new ArithmeticException("regular expression too long: " + regex.length() + + " > " + JexlArithmetic.REGEX_PATTERN_MAX_LENGTH); + } + final Pattern compiled = Pattern.compile(regex); + // Double-check and set under lock + synchronized (rightNode) { + Object cached = rightNode.jjtGetValue(); + if (!(cached instanceof Pattern)) { + rightNode.jjtSetValue(compiled); + } + return cached instanceof Pattern ? cached : compiled; + } + } + return right; + } + @Override protected Object visit(final ASTEWNode node, final Object data) { final Object left = node.jjtGetChild(0).jjtAccept(this, data); @@ -1728,7 +1766,8 @@ protected Object visit(final ASTNotNode node, final Object data) { @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 41810ee4e..af07f0835 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 boolean contains(final JexlCache.Reference node, final JexlOperator opera // 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 e79f80fcc..9eaf4d49c 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,9 @@ import java.text.DecimalFormatSymbols; import java.util.Locale; +import org.apache.commons.jexl3.JexlArithmetic; +import org.apache.commons.jexl3.JexlEngine; + /** * Parses number literals. */ @@ -32,6 +35,37 @@ public final class NumberParser implements Serializable { */ private static final long serialVersionUID = 1L; + + /** + * Returns the maximum digit count allowed for a BigInteger literal in the given base. + * When a JEXL engine with a bounded MathContext is active on the current thread, the + * engine's precision is converted to an equivalent bit limit, then to max digits for the base. + * Otherwise MAX_BIGINTEGER_DIGITS applies. + * At runtime, JexlArithmetic.checkBigIntegerPrecision() enforces the bit-length limit (f014, f013). + */ + private static int maxBigIntegerDigits(final int base) { + final JexlEngine engine = JexlEngine.getThreadEngine(); + if (engine != null) { + final long precision = engine.getArithmetic().getMathContext().getPrecision(); + if (precision > 0 && precision < Integer.MAX_VALUE) { + // Use the same bit formula as checkBigIntegerPrecision: precision * 10/3 + 1 bits + // For a given base B with log2(B) bits per digit, max_digits = max_bits / log2(B) + final int maxBits = (int) (precision * 10 / 3 + 1); + final int bitsPerDigit; + if (base == 16) { + bitsPerDigit = 4; // log2(16) = 4 + } else if (base == 8) { + bitsPerDigit = 3; // log2(8) = 3 + } else { + // base 10: log2(10) ≈ 3.32, approximate as 10/3 + return (maxBits * 3) / 10; + } + return maxBits / bitsPerDigit; + } + } + return JexlArithmetic.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) { @@ -89,6 +123,7 @@ NumberParser assignNatural(final boolean negative, final String natural) { } else { base = 10; } + final int maxDigits = maxBigIntegerDigits(base); // switch on suffix if any final int last = s.length() - 1; switch (s.charAt(last)) { @@ -102,6 +137,11 @@ NumberParser assignNatural(final boolean negative, final String natural) { 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 +157,11 @@ NumberParser assignNatural(final boolean negative, final String natural) { 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 198d3666c..572b525d6 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,13 @@ 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); + String msg = et != null && et.image != null && !et.image.isEmpty() + ? et.image + : "invalid number literal"; + throw new JexlException.Parsing(ji, msg).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 257dcb4cd..8854bcb74 100644 --- a/src/test/java/org/apache/commons/jexl3/ArithmeticTest.java +++ b/src/test/java/org/apache/commons/jexl3/ArithmeticTest.java @@ -37,7 +37,9 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; @@ -2409,4 +2411,105 @@ void setOptions(final JexlOptions options) { 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 (256 + 1 digits + 'H') must fail to parse + final char[] digits = new char[JexlArithmetic.MAX_BIGINTEGER_DIGITS + 1]; + 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() throws InterruptedException { + final JexlEngine jexl = new JexlBuilder().cancellable(true).create(); + final JexlScript script = jexl.createScript("x =~ y", "x", "y"); + final String evilPattern = "(a+)+b"; + + for (int size = 256; size <= 4096; size *= 2) { + final char[] chars = new char[size]; + java.util.Arrays.fill(chars, 'a'); + final String evilValue = new String(chars) + "c"; + + final AtomicReference caught = new AtomicReference<>(); + final CountDownLatch started = new CountDownLatch(1); + final Thread t = new Thread(() -> { + try { + started.countDown(); + script.execute(null, evilValue, evilPattern); + } catch (final Throwable e) { + caught.set(e); + } + }); + + t.start(); + started.await(); + + final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); + while (t.isAlive() && System.nanoTime() < deadline) { + t.interrupt(); + Thread.yield(); + t.join(10L); + } + + assertFalse(t.isAlive(), "Thread should have completed after interruption (regex should be interruptible)"); + + final Throwable thrown = caught.get(); + if (thrown instanceof JexlException.Cancel) { + return; + } + if (thrown != null) { + fail("Expected JexlException.Cancel when interrupted during matching, got " + + thrown.getClass().getSimpleName(), thrown); + } + } + + fail("Did not observe JexlException.Cancel during interrupted regex matching"); + } }