From 0496d6b0fe6b72fd12cca00599610fff74bea624 Mon Sep 17 00:00:00 2001 From: 0xbigapple Date: Wed, 4 Nov 2026 17:54:30 +0800 Subject: [PATCH 1/3] fix(jsonrpc): reject numeric request ids that are not long integers jsonrpc4j parses integral ids beyond the long range with asInt() and fractional ids with asDouble(), so on the public endpoint a request id like 9223372036854775808 was executed and answered with id 0, and distinct ids in a batch collided. Answer an Invalid Request that echoes the original id instead. The Admin endpoint already echoed such ids unchanged; it now rejects them too so both entry points share one rule. The shared request mapper reads floating-point literals as BigDecimal so the echoed id keeps its value instead of a double approximation. --- .../core/services/jsonrpc/JsonRpcMapper.java | 6 ++- .../jsonrpc/JsonRpcRequestValidator.java | 11 ++++ .../ipc/server/AdminJsonRpcRequestTest.java | 27 ++++++---- .../services/jsonrpc/JsonRpcServletTest.java | 50 +++++++++++++++++++ 4 files changed, 84 insertions(+), 10 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java index 6d461798907..b0d9957b7ea 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java @@ -2,8 +2,10 @@ import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import org.tron.core.Constant; @@ -20,7 +22,9 @@ public static ObjectMapper create() { .maxTokenCount(Constant.MAX_TOKEN_COUNT) .build()) .build(); - return new ObjectMapper(factory); + return JsonMapper.builder(factory) + .configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true) + .build(); } /** Creates a JSON-RPC error response, encoding an unknown ID as JSON null. */ diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcRequestValidator.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcRequestValidator.java index f7eada9fe53..f518f440d7d 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcRequestValidator.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcRequestValidator.java @@ -6,6 +6,8 @@ public final class JsonRpcRequestValidator { private static final String INVALID_REQUEST = "Invalid Request"; + public static final String INVALID_NUMERIC_ID_MESSAGE = + INVALID_REQUEST + ": numeric id must be an integer literal within the signed 64-bit range"; private JsonRpcRequestValidator() { } @@ -15,6 +17,9 @@ public static String invalidRequestMessage(JsonNode request) { if (request != null && request.isObject() && hasInvalidVersion(request)) { return INVALID_REQUEST + ": jsonrpc must be \"2.0\""; } + if (request != null && request.isObject() && hasInvalidNumericId(request)) { + return INVALID_NUMERIC_ID_MESSAGE; + } // An explicit null ID is invalid: jsonrpc4j treats it as a notification and drops the result. // Explicit null params retain the public endpoint's existing dispatch behavior. if (request == null || !request.isObject() @@ -31,6 +36,12 @@ public static boolean isValidResponseId(JsonNode id) { return id != null && (id.isTextual() || id.isNumber()); } + /** jsonrpc4j parses IDs beyond the long range with asInt() and fractional IDs with asDouble(). */ + private static boolean hasInvalidNumericId(JsonNode request) { + JsonNode id = request.get("id"); + return id != null && id.isNumber() && !(id.isIntegralNumber() && id.canConvertToLong()); + } + private static boolean hasInvalidVersion(JsonNode request) { JsonNode version = request.get("jsonrpc"); return version == null || !version.isTextual() || !"2.0".equals(version.asText()); diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java index b45be6001e8..6e492e6a4e1 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java @@ -32,6 +32,7 @@ import org.tron.core.services.admin.http.AdminRpcServlet; import org.tron.core.services.filter.HttpInterceptor; import org.tron.core.services.jsonrpc.JsonRpcMediaType; +import org.tron.core.services.jsonrpc.JsonRpcRequestValidator; /** Runs identical protocol assertions through the HTTP and IPC entry points. */ @RunWith(Parameterized.class) @@ -100,10 +101,21 @@ public void invalidIdTypesAreRejectedBeforeInvocation() throws Exception { } @Test - public void numericIdsOutsideLongRangeRetainTheirValue() throws Exception { + public void nonLongNumericIdsAreRejectedWithTheirValue() throws Exception { for (String id : new String[] {"9223372036854775808", "-9223372036854775809", - "18446744073709551617", "9223372036854775807.1", "-9223372036854775808.1", - "1e40", "-1e40"}) { + "18446744073709551617", "9223372036854775807.1", "-9223372036854775808.1", "1.5", + "9223372036854775807.0", "0.12345678901234567890123456789", "-0.5", "1e-20", "1e40", + "-1e40"}) { + JsonNode response = assertError(sender.send(REQUEST + ",\"id\":" + id + "}"), -32600, id); + Assert.assertEquals(JsonRpcRequestValidator.INVALID_NUMERIC_ID_MESSAGE, + response.path("error").path("message").asText()); + } + Mockito.verifyNoInteractions(adminJsonRpc); + } + + @Test + public void longRangeIdsRetainTheirValue() throws Exception { + for (String id : new String[] {"9223372036854775807", "-9223372036854775808"}) { JsonNode response = parse(sender.send(REQUEST + ",\"id\":" + id + "}")); assertId(id, response.get("id")); Assert.assertTrue(response.path("result").path("success").asBoolean()); @@ -115,8 +127,7 @@ public void numericIdsOutsideLongRangeRetainTheirValue() throws Exception { @Test public void acceptedIdsRetainTheirValueAndType() throws Exception { for (String id : new String[] {"\"null\"", "\"\"", "\"请求-7\"", "7", "0", - "9223372036854775807", "-9223372036854775808", "9007199254740993", - "0.12345678901234567890123456789", "9223372036854775807.0", "-0.5", "1e-20"}) { + "9223372036854775807", "-9223372036854775808", "9007199254740993"}) { JsonNode response = parse(sender.send(REQUEST + ",\"id\":" + id + "}")); assertId(id, response.get("id")); Assert.assertTrue(response.path("result").path("success").asBoolean()); @@ -230,8 +241,7 @@ public void invalidMethodParamsNotificationHasNoResponse() throws Exception { public void parameterBindingFailuresRetainIdsAndSilenceNotifications() throws Exception { for (String params : new String[] {"[{}]", "{\"endpoint\":{}}"}) { String request = REQUEST.replace(PARAMS, params); - for (String id : new String[] {"9", "\"request\"", "9223372036854775807", - "0.12345678901234567890123456789"}) { + for (String id : new String[] {"9", "\"request\"", "9223372036854775807"}) { JsonNode error = assertError(sender.send(request + ",\"id\":" + id + "}"), -32602, id) .get("error"); Assert.assertEquals("Failed to read method parameter at index 0", @@ -255,8 +265,7 @@ public void businessErrorNotificationHasNoResponse() throws Exception { public void errorResponsesRetainAcceptedIds() throws Exception { Mockito.when(adminJsonRpc.addPeer(ENDPOINT)) .thenThrow(new JsonRpcInvalidParamsException("Invalid admin parameters")); - for (String id : new String[] {"\"null\"", "9223372036854775807", - "0.12345678901234567890123456789"}) { + for (String id : new String[] {"\"null\"", "9223372036854775807"}) { JsonNode response = assertError(sender.send(REQUEST + ",\"id\":" + id + "}"), -32602, id); Assert.assertEquals("Invalid admin parameters", response.path("error").path("message") .asText()); diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java index 5c356db5494..f55e6186a34 100644 --- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java @@ -38,6 +38,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.lang.reflect.Method; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -1292,6 +1293,55 @@ public void batchWithNumericAndStringElements_allGetInvalidRequest() throws Exce } } + // --- Numeric ids that are not long integers --- + + @Test + public void invalidNumericId_isRejectedWithOriginalIdWithoutDispatch() throws Exception { + for (String id : new String[] {"9223372036854775808", "-9223372036854775809", + "18446744073709551617", "1.5", "9223372036854775807.1", "1e19", "-1e3"}) { + MockHttpServletResponse resp = doPost("{\"jsonrpc\":\"2.0\"," + + "\"method\":\"web3_clientVersion\",\"params\":[],\"id\":" + id + "}"); + assertEquals(200, resp.getStatus()); + assertInvalidRequest(MAPPER.readTree(resp.getContentAsByteArray()), + JsonRpcRequestValidator.INVALID_NUMERIC_ID_MESSAGE, id); + } + + verifyNoInteractions(mockRpcServer); + } + + @Test + public void longRangeIds_areServedWhileLargerIdIsRejectedInBatch() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + + MockHttpServletResponse resp = doPost("[" + + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":9223372036854775807}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":9223372036854775808}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":-9223372036854775808}]"); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertEquals(3, body.size()); + assertEquals(Long.MAX_VALUE, body.get(0).get("id").asLong()); + assertEquals("0x1", body.get(0).get("result").asText()); + assertInvalidRequest(body.get(1), JsonRpcRequestValidator.INVALID_NUMERIC_ID_MESSAGE, + "9223372036854775808"); + assertEquals(Long.MIN_VALUE, body.get(2).get("id").asLong()); + assertEquals("0x1", body.get(2).get("result").asText()); + verify(rpc, times(2)).getLatestBlockNum(); + } + + @Test + public void rejectedFractionalId_isEchoedWithoutPrecisionLoss() throws Exception { + for (String id : new String[] {"0.12345678901234567890123456789", "1e400"}) { + MockHttpServletResponse resp = doPost("{\"jsonrpc\":\"2.0\"," + + "\"method\":\"web3_clientVersion\",\"params\":[],\"id\":" + id + "}"); + JsonNode echoed = MAPPER.reader().with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .readTree(resp.getContentAsByteArray()).get("id"); + assertTrue(id, echoed.isNumber()); + assertEquals(id, 0, new BigDecimal(id).compareTo(echoed.decimalValue())); + } + + verifyNoInteractions(mockRpcServer); + } + // --- StreamReadConstraints: maxNestingDepth and maxTokenCount must be enforced --- @Test From f4772a018921ab2dcde16029c2a317a8a5bfcc15 Mon Sep 17 00:00:00 2001 From: 0xbigapple Date: Fri, 6 Nov 2026 10:08:54 +0800 Subject: [PATCH 2/3] fix(jsonrpc): keep float parsing of the shared mapper unchanged Echo a rejected fractional id as the parsed double. --- .../core/services/jsonrpc/JsonRpcMapper.java | 6 +-- .../services/jsonrpc/JsonRpcServletTest.java | 48 ++++++++++++++++--- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java index b0d9957b7ea..6d461798907 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcMapper.java @@ -2,10 +2,8 @@ import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.StreamReadConstraints; -import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import org.tron.core.Constant; @@ -22,9 +20,7 @@ public static ObjectMapper create() { .maxTokenCount(Constant.MAX_TOKEN_COUNT) .build()) .build(); - return JsonMapper.builder(factory) - .configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true) - .build(); + return new ObjectMapper(factory); } /** Creates a JSON-RPC error response, encoding an unknown ID as JSON null. */ diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java index f55e6186a34..f3db654e776 100644 --- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java @@ -38,7 +38,6 @@ import java.io.InputStream; import java.io.OutputStream; import java.lang.reflect.Method; -import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -1329,19 +1328,54 @@ public void longRangeIds_areServedWhileLargerIdIsRejectedInBatch() throws Except } @Test - public void rejectedFractionalId_isEchoedWithoutPrecisionLoss() throws Exception { - for (String id : new String[] {"0.12345678901234567890123456789", "1e400"}) { + public void rejectedFractionalId_isEchoedAsParsedDouble() throws Exception { + for (String id : new String[] {"0.12345678901234567890123456789", "1e-20", "1e300"}) { MockHttpServletResponse resp = doPost("{\"jsonrpc\":\"2.0\"," + "\"method\":\"web3_clientVersion\",\"params\":[],\"id\":" + id + "}"); - JsonNode echoed = MAPPER.reader().with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) - .readTree(resp.getContentAsByteArray()).get("id"); - assertTrue(id, echoed.isNumber()); - assertEquals(id, 0, new BigDecimal(id).compareTo(echoed.decimalValue())); + assertInvalidRequest(MAPPER.readTree(resp.getContentAsByteArray()), + JsonRpcRequestValidator.INVALID_NUMERIC_ID_MESSAGE, + Double.toString(Double.parseDouble(id))); + } + + verifyNoInteractions(mockRpcServer); + } + + @Test + public void exponentOverflowId_isRejected() throws Exception { + for (String id : new String[] {"1e400", "-1e400", "1e2147483648"}) { + MockHttpServletResponse resp = doPost("{\"jsonrpc\":\"2.0\"," + + "\"method\":\"web3_clientVersion\",\"params\":[],\"id\":" + id + "}"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertEquals(-32600, body.get("error").get("code").asInt()); + assertEquals(JsonRpcRequestValidator.INVALID_NUMERIC_ID_MESSAGE, + body.get("error").get("message").asText()); + // Jackson writes the parsed double, which has no JSON number form, as a string. + assertEquals(Double.toString(Double.parseDouble(id)), body.get("id").asText()); } verifyNoInteractions(mockRpcServer); } + @Test + public void nonBooleanFloatParam_isRejectedInSingleAndBatch() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + + for (String literal : new String[] {"1.0", "0.0", "1e0", "0e0", "1e2147483648"}) { + String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBlockByNumber\"," + + "\"params\":[\"latest\"," + literal + "],\"id\":1}"; + JsonNode single = MAPPER.readTree(doPost(request).getContentAsByteArray()); + JsonNode batch = MAPPER.readTree(doPost("[" + request + "]").getContentAsByteArray()); + assertEquals(literal, 1, batch.size()); + for (JsonNode response : new JsonNode[] {single, batch.get(0)}) { + assertEquals(literal, 1, response.get("id").asInt()); + assertEquals(literal, -32602, response.get("error").get("code").asInt()); + } + } + + verify(rpc, never()).ethGetBlockByNumber(any(), any()); + } + // --- StreamReadConstraints: maxNestingDepth and maxTokenCount must be enforced --- @Test From 29d873bb6f8a084e9dca19e8838cba43820bce0c Mon Sep 17 00:00:00 2001 From: 0xbigapple Date: Fri, 6 Nov 2026 11:05:58 +0800 Subject: [PATCH 3/3] fix(admin): answer exponent overflow with a parse error BigDecimal parsing throws NumberFormatException on it, which escaped the parse boundary of the Admin request handler. --- .../services/admin/AdminJsonRpcRequestHandler.java | 2 +- .../admin/ipc/server/AdminJsonRpcRequestTest.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java index 1e4a809f5ec..11366b4fe56 100644 --- a/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java +++ b/framework/src/main/java/org/tron/core/services/admin/AdminJsonRpcRequestHandler.java @@ -44,7 +44,7 @@ public byte[] handleRequest(byte[] body) throws IOException { request = OBJECT_MAPPER.reader() .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) .with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(body); - } catch (IOException e) { + } catch (IOException | NumberFormatException e) { return OBJECT_MAPPER.writeValueAsBytes(errorResponse(JsonError.PARSE_ERROR, null)); } diff --git a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java index 6e492e6a4e1..88f2addd6a9 100644 --- a/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java +++ b/framework/src/test/java/org/tron/core/services/admin/ipc/server/AdminJsonRpcRequestTest.java @@ -113,6 +113,16 @@ public void nonLongNumericIdsAreRejectedWithTheirValue() throws Exception { Mockito.verifyNoInteractions(adminJsonRpc); } + @Test + public void exponentOverflowIsAParseError() throws Exception { + for (String literal : new String[] {"1e2147483648", "-1e2147483648", "1e-2147483649"}) { + assertError(sender.send(REQUEST + ",\"id\":" + literal + "}"), -32700, "null"); + assertError(sender.send("{\"jsonrpc\":\"2.0\",\"method\":\"admin_addPeer\"," + + "\"params\":[" + literal + "],\"id\":1}"), -32700, "null"); + } + Mockito.verifyNoInteractions(adminJsonRpc); + } + @Test public void longRangeIdsRetainTheirValue() throws Exception { for (String id : new String[] {"9223372036854775807", "-9223372036854775808"}) {