Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ target/
# NetBeans files
nb-configuration.xml
nbactions.xml
/CLAUDE.md
1 change: 1 addition & 0 deletions src/changes/changes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
124 changes: 113 additions & 11 deletions src/main/java/org/apache/commons/jexl3/JexlArithmetic.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>An array (as in int[]) is not convenient to call methods so when encountered we turn them into lists</p>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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<?, ?>) {
Expand All @@ -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.
*
Expand Down Expand Up @@ -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).
* <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 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");
}
Comment thread
henrib marked this conversation as resolved.
}
return big;
}

/**
* The MathContext instance used for +,-,/,*,% operations on big decimals.
*
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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));
}

/**
Expand Down Expand Up @@ -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;
}
}
}
5 changes: 3 additions & 2 deletions src/main/java/org/apache/commons/jexl3/JexlBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public static void setDefaultOptions(final String... flags) {
/**
* Builds the JEXL 3.7 hardened default feature set.
* <p>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.</p>
*
Expand All @@ -180,7 +180,8 @@ private static JexlFeatures secureFeatures() {
.annotation(false)
.loops(true)
.lexical(true)
.lexicalShade(true);
.lexicalShade(true)
.namespaceInstantiation(false);
}

/** The JexlUberspect instance. */
Expand Down
43 changes: 41 additions & 2 deletions src/main/java/org/apache/commons/jexl3/internal/Interpreter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Comment thread
henrib marked this conversation as resolved.
Comment thread
henrib marked this conversation as resolved.

@Override
protected Object visit(final ASTEWNode node, final Object data) {
final Object left = node.jjtGetChild(0).jjtAccept(this, data);
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/org/apache/commons/jexl3/internal/Operator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/org/apache/commons/jexl3/parser/NumberParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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;
}
Comment thread
henrib marked this conversation as resolved.

/** 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) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
Loading
Loading