TestNumberLengthLimit.java

package net.minidev.json.test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.math.BigDecimal;
import java.math.BigInteger;
import net.minidev.json.JSONObject;
import net.minidev.json.parser.JSONParser;
import net.minidev.json.parser.ParseException;
import org.junit.jupiter.api.Test;

/**
 * A number literal has no length limit, so {@code new BigInteger(String)} / {@code new
 * BigDecimal(String)} can be handed an arbitrarily long attacker controlled digit string. Their
 * cost grows superlinearly in the digit count: measured n^2.0 on Temurin 21.0.11 over the range
 * 100_000 to 1_600_000 digits, where a single 1,600,000 digit literal costs about 32 s of CPU on
 * one core.
 *
 * <p>These tests pin the limit down the same way {@code TestCVE202457699} pins the depth limit:
 * every predefined mode must reject the payload, and ordinary numbers must keep working.
 */
public class TestNumberLengthLimit {

  /** Long enough to be rejected, short enough that an unpatched parser still answers quickly. */
  private static final String LONG_INT = repeat('9', 2000);

  private static final String LONG_DEC = "1." + repeat('3', 2000);

  private static final int[] PREDEFINED_MODES = {
    JSONParser.MODE_PERMISSIVE,
    JSONParser.MODE_PERMISSIVE_WITH_INCOMPLETE,
    JSONParser.MODE_RFC4627,
    JSONParser.MODE_JSON_SIMPLE,
    JSONParser.MODE_STRICTEST,
  };

  @Test
  public void everyPredefinedModeShouldRejectLongIntegerLiteral() {
    for (int mode : PREDEFINED_MODES) {
      ParseException e =
          assertThrows(
              ParseException.class,
              () -> new JSONParser(mode).parse(LONG_INT),
              "mode " + mode + " accepted a " + LONG_INT.length() + " digit integer literal");
      assertEquals(ParseException.ERROR_UNEXPECTED_NUMBER_LENGTH, e.getErrorType());
    }
  }

  @Test
  public void everyPredefinedModeShouldRejectLongDecimalLiteral() {
    for (int mode : PREDEFINED_MODES) {
      ParseException e =
          assertThrows(
              ParseException.class,
              () -> new JSONParser(mode).parse(LONG_DEC),
              "mode " + mode + " accepted a " + LONG_DEC.length() + " char decimal literal");
      assertEquals(ParseException.ERROR_UNEXPECTED_NUMBER_LENGTH, e.getErrorType());
    }
  }

  /** The literal is normally nested in an object, as it would be in a request body. */
  @Test
  public void everyPredefinedModeShouldRejectLongLiteralInsideAnObject() {
    for (int mode : PREDEFINED_MODES) {
      assertThrows(
          ParseException.class,
          () -> new JSONParser(mode).parse("{\"x\":" + LONG_INT + "}"),
          "mode " + mode + " accepted a long integer literal inside an object");
      assertThrows(
          ParseException.class,
          () -> new JSONParser(mode).parse("[" + LONG_DEC + "]"),
          "mode " + mode + " accepted a long decimal literal inside an array");
    }
  }

  /** A negative literal takes the other branch of the sign test in parseNumber. */
  @Test
  public void negativeLongLiteralShouldBeRejected() {
    assertThrows(
        ParseException.class,
        () -> new JSONParser(JSONParser.MODE_PERMISSIVE).parse("-" + LONG_INT));
  }

  /** Exactly at the limit is still accepted; one over is not. Off by one guard. */
  @Test
  public void limitBoundaryIsInclusive() throws Exception {
    String atLimit = repeat('9', 1000);
    String overLimit = repeat('9', 1001);

    Object ok = new JSONParser(JSONParser.MODE_PERMISSIVE).parse(atLimit);
    assertInstanceOf(BigInteger.class, ok);
    assertEquals(new BigInteger(atLimit), ok);

    assertThrows(
        ParseException.class, () -> new JSONParser(JSONParser.MODE_PERMISSIVE).parse(overLimit));
  }

  /** The limit must not disturb numbers of ordinary size, on any of the numeric branches. */
  @Test
  public void ordinaryNumbersAreUnaffected() throws Exception {
    JSONParser p = new JSONParser(JSONParser.MODE_PERMISSIVE);
    assertEquals(0, p.parse("0"));
    assertEquals(-1, p.parse("-1"));
    assertEquals(42, p.parse("42"));
    assertEquals(
        Long.MAX_VALUE, new JSONParser(JSONParser.MODE_PERMISSIVE).parse("9223372036854775807"));
    assertEquals(
        new BigInteger("9223372036854775808"),
        new JSONParser(JSONParser.MODE_PERMISSIVE).parse("9223372036854775808"));
    assertEquals(1.5d, new JSONParser(JSONParser.MODE_PERMISSIVE).parse("1.5"));
    assertEquals(
        new BigDecimal("1.2345678901234567890123"),
        new JSONParser(JSONParser.MODE_PERMISSIVE).parse("1.2345678901234567890123"));

    JSONObject o =
        (JSONObject) new JSONParser(JSONParser.MODE_PERMISSIVE).parse("{\"a\":1,\"b\":2.5e3}");
    assertEquals(1, o.get("a"));
  }

  /**
   * The point of making the limit opt-OUT: a caller that hand builds its own bitmask, and so never
   * hears about a new flag, is protected anyway.
   *
   * <p>This bitmask is not hypothetical. It is the one com.nimbusds:oauth2-oidc-sdk 11.38.2 uses in
   * com/nimbusds/oauth2/sdk/util/JSONUtils.java, in both parseJSON and parseJSONKeepingOrder. That
   * SDK declares json-smart at compile scope, and it already sets LIMIT_JSON_DEPTH because the
   * CVE-2024-57699 fix had to be applied by hand downstream. An opt-in limit would have left it
   * exposed a second time.
   */
  @Test
  public void handBuiltDownstreamBitmaskIsProtectedWithoutChanges() {
    int oidcSdkBitmask =
        JSONParser.USE_HI_PRECISION_FLOAT
            | JSONParser.ACCEPT_TAILLING_SPACE
            | JSONParser.LIMIT_JSON_DEPTH;

    assertThrows(
        ParseException.class,
        () -> new JSONParser(oidcSdkBitmask).parse("{\"x\":" + LONG_INT + "}"),
        "hand built downstream bitmask accepted a long integer literal");
    assertThrows(
        ParseException.class,
        () -> new JSONParser(oidcSdkBitmask).parse("{\"x\":" + LONG_DEC + "}"),
        "hand built downstream bitmask accepted a long decimal literal");
  }

  /**
   * The escape hatch: an application that genuinely needs arbitrary precision literals opts out
   * explicitly.
   */
  @Test
  public void limitCanBeOptedOutOfExplicitly() throws Exception {
    int noLimit = JSONParser.MODE_PERMISSIVE | JSONParser.UNRESTRICTED_NUMBER_LENGTH;
    Object r = new JSONParser(noLimit).parse(LONG_INT);
    assertInstanceOf(BigInteger.class, r);
    assertEquals(new BigInteger(LONG_INT), r);
  }

  /** No predefined mode may switch the limit off. This is what CVE-2024-57699 asked for. */
  @Test
  public void noPredefinedModeDisablesTheLimit() {
    for (int mode : PREDEFINED_MODES) {
      assertEquals(
          0,
          mode & JSONParser.UNRESTRICTED_NUMBER_LENGTH,
          "mode " + mode + " disables the number length limit");
    }
  }

  /** The rejection message must not echo the offending token, which can be megabytes long. */
  @Test
  public void errorMessageDoesNotEchoTheOffendingToken() {
    ParseException e =
        assertThrows(
            ParseException.class, () -> new JSONParser(JSONParser.MODE_PERMISSIVE).parse(LONG_INT));
    assertTrue(
        e.getMessage().length() < 200, "message should not embed the token: " + e.getMessage());
    assertTrue(e.getMessage().contains("2000"), e.getMessage());
  }

  private static String repeat(char c, int n) {
    StringBuilder sb = new StringBuilder(n);
    for (int i = 0; i < n; i++) {
      sb.append(c);
    }
    return sb.toString();
  }
}