diff --git a/release-notes/CREDITS b/release-notes/CREDITS index aba6829e51..deecaa0d75 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -405,6 +405,9 @@ Aysha Afrah Ziya (@aysha-afrah26) * Fixed #6179: Apply `StreamReadConstraints` number length limit when coercing String to `double` [3.1.7] + * Fixed #6232: Validate `StreamReadConstraints` number length when coercing + `StringNode` to number + [3.1.8] @waydeshi * Reported #6127: Add `StreamReadConstraints` number len constraint to diff --git a/release-notes/VERSION b/release-notes/VERSION index 17ccdfb297..a0a2bfdc04 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -14,6 +14,9 @@ No changes since 3.1 #6206: Report cycle through `@JsonValue` accessor as `DatabindException` (instead of `StackOverflowError`) (fix by @pjfanning, w/ Claude code) +#6232: Validate `StreamReadConstraints` number length when coercing `StringNode` + to number + (fix by Aysha A-Z) #6236: Retain `JsonFormat.Shape` in `withFormat()` of `Month`, `MonthDay`, `Year` and `YearMonth` serializers (fix by @cowtowncoder, w/ Claude code) diff --git a/src/main/java/tools/jackson/databind/node/StringNode.java b/src/main/java/tools/jackson/databind/node/StringNode.java index 55bd7c1e60..2cb06f7061 100644 --- a/src/main/java/tools/jackson/databind/node/StringNode.java +++ b/src/main/java/tools/jackson/databind/node/StringNode.java @@ -243,12 +243,18 @@ public BigInteger asBigInteger() { @Override public BigInteger asBigInteger(BigInteger defaultValue) { + if (!_numberLengthInRange()) { + return defaultValue; + } BigInteger big = _tryParseAsBigInteger(); return (big == null) ? defaultValue : big; } @Override public Optional asBigIntegerOpt() { + if (!_numberLengthInRange()) { + return Optional.empty(); + } BigInteger big = _tryParseAsBigInteger(); return (big == null) ? Optional.empty() : Optional.of(big); } @@ -269,12 +275,18 @@ public float asFloat() @Override public float asFloat(float defaultValue) { + if (!_numberLengthInRange()) { + return defaultValue; + } Float F = _tryParseAsFloat(); return (F == null) ? defaultValue : F; } @Override public Optional asFloatOpt() { + if (!_numberLengthInRange()) { + return Optional.empty(); + } Float F = _tryParseAsFloat(); return (F == null) ? Optional.empty() : Optional.of(F); } @@ -295,12 +307,18 @@ public double asDouble() @Override public double asDouble(double defaultValue) { + if (!_numberLengthInRange()) { + return defaultValue; + } Double d = _tryParseAsDouble(); return (d == null) ? defaultValue : d; } @Override public OptionalDouble asDoubleOpt() { + if (!_numberLengthInRange()) { + return OptionalDouble.empty(); + } Double d = _tryParseAsDouble(); return (d == null) ? OptionalDouble.empty() : OptionalDouble.of(d); } @@ -319,12 +337,18 @@ public BigDecimal asDecimal() { @Override public BigDecimal asDecimal(BigDecimal defaultValue) { + if (!_numberLengthInRange()) { + return defaultValue; + } BigDecimal dec = _tryParseAsBigDecimal(); return (dec == null) ? defaultValue : dec; } @Override public Optional asDecimalOpt() { + if (!_numberLengthInRange()) { + return Optional.empty(); + } BigDecimal dec = _tryParseAsBigDecimal(); return (dec == null) ? Optional.empty() : Optional.of(dec); } @@ -362,6 +386,10 @@ protected Long _tryParseAsLong() { protected BigInteger _tryParseAsBigInteger() { if (NumberInput.looksLikeValidNumber(_value)) { + // Enforce number-length limit before the super-linear parse, same as + // deserializers do; no `StreamReadConstraints` available here so use + // `defaults()` (compare `DecimalNode`/`POJONode`, [databind#6214]) + StreamReadConstraints.defaults().validateIntegerLength(_value.length()); try { return NumberInput.parseBigInteger(_value, true); } catch (NumberFormatException e) { @@ -373,6 +401,7 @@ protected BigInteger _tryParseAsBigInteger() { protected Float _tryParseAsFloat() { if (NumberInput.looksLikeValidNumber(_value)) { + StreamReadConstraints.defaults().validateFPLength(_value.length()); try { return NumberInput.parseFloat(_value, true); } catch (NumberFormatException e) { @@ -384,6 +413,7 @@ protected Float _tryParseAsFloat() { protected Double _tryParseAsDouble() { if (NumberInput.looksLikeValidNumber(_value)) { + StreamReadConstraints.defaults().validateFPLength(_value.length()); try { return NumberInput.parseDouble(_value, true); } catch (NumberFormatException e) { @@ -395,6 +425,7 @@ protected Double _tryParseAsDouble() { protected BigDecimal _tryParseAsBigDecimal() { if (NumberInput.looksLikeValidNumber(_value)) { + StreamReadConstraints.defaults().validateFPLength(_value.length()); try { return NumberInput.parseBigDecimal(_value, true); } catch (NumberFormatException e) { @@ -403,6 +434,16 @@ protected BigDecimal _tryParseAsBigDecimal() { } return null; } + + // [databind#6214]-style number-length guard for the lenient default/`Optional` + // accessors: they must return default/empty rather than throw, so they check the + // limit up front (the strict accessors instead go via the `_tryParseAs...` helpers + // above, which surface `StreamConstraintsException`). Both `validateIntegerLength` + // and `validateFPLength` reject lengths past `getMaxNumberLength()`, so a single + // check covers integer and floating-point coercion alike. + private boolean _numberLengthInRange() { + return _value.length() <= StreamReadConstraints.defaults().getMaxNumberLength(); + } /* /********************************************************************** diff --git a/src/test/java/tools/jackson/databind/node/StringNodeNumberLengthTest.java b/src/test/java/tools/jackson/databind/node/StringNodeNumberLengthTest.java new file mode 100644 index 0000000000..588532a0bb --- /dev/null +++ b/src/test/java/tools/jackson/databind/node/StringNodeNumberLengthTest.java @@ -0,0 +1,100 @@ +package tools.jackson.databind.node; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Optional; +import java.util.OptionalDouble; + +import org.junit.jupiter.api.Test; + +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.exc.StreamConstraintsException; + +import tools.jackson.databind.testutil.DatabindTestUtil; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies that coercing a {@link StringNode} holding a "stringified" number to + * {@code BigInteger}/{@code BigDecimal}/{@code double}/{@code float} enforces the + * {@code StreamReadConstraints} number-length limit before the (super-linear) parse, + * the same way deserializers and (for scale) {@link DecimalNode} already do. + * + * @see [databind#6214] + */ +public class StringNodeNumberLengthTest extends DatabindTestUtil +{ + private final static int MAX_LEN = StreamReadConstraints.defaults().getMaxNumberLength(); + + // A numeric String comfortably past the default limit (arrives as a JSON String, + // so it is bounded only by max-string-length, not max-number-length) + private final static int OVER_LEN = MAX_LEN + 100; + private final static String OVER_LONG_INT = "9".repeat(OVER_LEN); + private final static String OVER_LONG_DECIMAL = "1." + "9".repeat(OVER_LEN); + + @Test + public void strictAccessorsRejectOverLongNumber() throws Exception + { + StringNode intNode = StringNode.valueOf(OVER_LONG_INT); + _verifyGuarded(() -> intNode.asBigInteger()); + _verifyGuarded(() -> intNode.asDouble()); + _verifyGuarded(() -> intNode.asFloat()); + _verifyGuarded(() -> intNode.asDecimal()); + + StringNode decNode = StringNode.valueOf(OVER_LONG_DECIMAL); + _verifyGuarded(() -> decNode.asDouble()); + _verifyGuarded(() -> decNode.asFloat()); + _verifyGuarded(() -> decNode.asDecimal()); + } + + @Test + public void lenientAccessorsReturnDefaultForOverLongNumber() throws Exception + { + StringNode node = StringNode.valueOf(OVER_LONG_INT); + + // default/Optional variants must NOT throw (same contract as any other + // non-convertible value): they return the default / empty instead + assertEquals(BigInteger.ONE, node.asBigInteger(BigInteger.ONE)); + assertFalse(node.asBigIntegerOpt().isPresent()); + + assertEquals(-1.0, node.asDouble(-1.0)); + assertFalse(node.asDoubleOpt().isPresent()); + + assertEquals(-1.0f, node.asFloat(-1.0f)); + assertFalse(node.asFloatOpt().isPresent()); + + assertEquals(BigDecimal.ONE, node.asDecimal(BigDecimal.ONE)); + assertFalse(node.asDecimalOpt().isPresent()); + } + + // Values within the limit must still coerce, unchanged + @Test + public void withinLimitStillCoerces() throws Exception + { + StringNode node = StringNode.valueOf("1234"); + assertEquals(new BigInteger("1234"), node.asBigInteger()); + assertEquals(new BigInteger("1234"), node.asBigInteger(BigInteger.ZERO)); + assertEquals(Optional.of(new BigInteger("1234")), node.asBigIntegerOpt()); + assertEquals(1234.0, node.asDouble()); + assertEquals(OptionalDouble.of(1234.0), node.asDoubleOpt()); + assertEquals(1234.0f, node.asFloat()); + assertEquals(new BigDecimal("1234"), node.asDecimal()); + + // length exactly at the limit is still accepted + StringNode atLimit = StringNode.valueOf("9".repeat(MAX_LEN)); + assertEquals(new BigInteger("9".repeat(MAX_LEN)), atLimit.asBigInteger()); + } + + private interface Coercion { Object convert(); } + + private void _verifyGuarded(Coercion c) throws Exception + { + try { + c.convert(); + fail("Should not pass: number length exceeds the configured maximum"); + } catch (StreamConstraintsException e) { + verifyException(e, "Number value length"); + verifyException(e, "exceeds the maximum allowed"); + } + } +}