From 68a580c3951f350ba79fc4aaa2d877dd177259f3 Mon Sep 17 00:00:00 2001 From: waynercheung Date: Wed, 11 Nov 2026 18:28:17 +0800 Subject: [PATCH 1/7] fix(jsonrpc): reject invalid UTF-8 request bytes Check that the request body is well-formed UTF-8 before parsing it. Jackson accepts overlong encodings, UTF-16 surrogates and code points above U+10FFFF, none of which are valid UTF-8 (RFC 3629). A body that is not well-formed UTF-8 now gets a -32700 parse error with a null id, and nothing is dispatched. This applies to single, batch and notification requests alike. Compatibility: well-formed UTF-8, including a byte order mark and non-ASCII text in parameters, is unchanged. Encoding detection is kept only for bodies that are also well-formed UTF-8, so an ASCII body sent as UTF-16 without a byte order mark is still accepted. A UTF-16 or UTF-32 body whose raw bytes are not well-formed UTF-8, such as one that starts with a byte order mark, now gets -32700. --- .../core/services/jsonrpc/JsonRpcServlet.java | 6 + .../jsonrpc/JsonRpcServletJettyTest.java | 40 ++++++ .../services/jsonrpc/JsonRpcServletTest.java | 129 ++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index 2615c3fe76f..caea0cf833d 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Utf8; import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; import com.googlecode.jsonrpc4j.JsonRpcMethod; @@ -119,6 +120,11 @@ private void doPostInternal(HttpServletRequest req, HttpServletResponse resp) th // Transport IOException from readBody propagates as HTTP 500 (genuine IO failure). byte[] body = readBody(req.getInputStream()); + // Jackson accepts overlong and surrogate UTF-8 sequences, so check the bytes first. + if (!Utf8.isWellFormed(body)) { + writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, "JSON parse error", null, false); + return; + } JsonNode rootNode; try { rootNode = MAPPER.readTree(body); diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java index 71642a6c7ff..cefaedfff51 100644 --- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.googlecode.jsonrpc4j.JsonRpcMethod; import com.googlecode.jsonrpc4j.JsonRpcServer; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.lang.reflect.Field; import java.net.SocketException; @@ -15,6 +16,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import javax.servlet.DispatcherType; import javax.servlet.Filter; import javax.servlet.FilterChain; @@ -31,6 +33,7 @@ import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ByteArrayEntity; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; @@ -193,6 +196,41 @@ public void successfulRequestWithProductionFilterPreservesResponse() throws Exce } } + @Test + public void malformedUtf8BodyReturnsJsonParseErrorOverHttp() throws Exception { + URI endpoint = startServer(true); + // 0xC1 0xAF is an overlong encoding of 'o' in test_ok. + byte[] body = concat("{\"jsonrpc\":\"2.0\",\"method\":\"test_".getBytes(StandardCharsets.UTF_8), + new byte[] {(byte) 0xC1, (byte) 0xAF}, + "k\",\"params\":[],\"id\":7}".getBytes(StandardCharsets.UTF_8)); + + assertParseErrorOverHttp(endpoint, body); + Assert.assertEquals(0, fatalService.okCalls.get()); + } + + private void assertParseErrorOverHttp(URI endpoint, byte[] body) throws IOException { + HttpPost request = new HttpPost(endpoint); + request.setEntity(new ByteArrayEntity(body, ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = client.execute(request)) { + Assert.assertEquals(200, response.getStatusLine().getStatusCode()); + Assert.assertEquals("application/json-rpc", + ContentType.get(response.getEntity()).getMimeType()); + ObjectMapper mapper = new ObjectMapper(); + Assert.assertEquals(mapper.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32700," + + "\"message\":\"JSON parse error\"},\"id\":null}"), + mapper.readTree(EntityUtils.toByteArray(response.getEntity()))); + } + } + + private static byte[] concat(byte[]... parts) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] part : parts) { + out.write(part, 0, part.length); + } + return out.toByteArray(); + } + public interface FatalService { @JsonRpcMethod("test_fatal") @@ -205,6 +243,7 @@ public interface FatalService { private static class FatalServiceImpl implements FatalService { private final AtomicBoolean invoked = new AtomicBoolean(); + private final AtomicInteger okCalls = new AtomicInteger(); @Override public String fatal() { @@ -214,6 +253,7 @@ public String fatal() { @Override public String ok() { + okCalls.incrementAndGet(); return "ok"; } } 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 763d70c2570..20d77ba38f9 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 @@ -34,11 +34,13 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.googlecode.jsonrpc4j.JsonRpcMethod; import com.googlecode.jsonrpc4j.JsonRpcServer; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.lang.management.ManagementFactory; import java.lang.reflect.Method; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -103,8 +105,106 @@ public void emptyBody_returnsParseError() throws Exception { assertEquals(-32700, body.get("error").get("code").asInt()); } + // --- request body bytes --- + + @Test + public void malformedUtf8_isRejectedBeforeDispatch() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + when(rpc.web3ClientVersion()).thenReturn("client"); + // Each sequence replaces the leading 'w' of web3_clientVersion. The overlong forms decode + // to 'w' in a lenient parser, so they reach the real method unless rejected up front. + byte[][] sequences = { + {(byte) 0xC1, (byte) 0xB7}, + {(byte) 0xE0, (byte) 0x81, (byte) 0xB7}, + {(byte) 0xED, (byte) 0xA0, (byte) 0x80}, + {(byte) 0xF4, (byte) 0x90, (byte) 0x80, (byte) 0x80}}; + for (byte[] sequence : sequences) { + byte[] single = concat(utf8("{\"jsonrpc\":\"2.0\",\"method\":\""), sequence, + utf8("eb3_clientVersion\",\"id\":1}")); + byte[] notification = concat(utf8("{\"jsonrpc\":\"2.0\",\"method\":\""), sequence, + utf8("eb3_clientVersion\"}")); + byte[] batch = concat(utf8("["), single, utf8("]")); + for (byte[] body : Arrays.asList(single, notification, batch)) { + assertParseError(Arrays.toString(body), doPost(body)); + } + } + verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); + verify(rpc, never()).web3ClientVersion(); + } + + @Test + public void wellFormedNonAsciiInput_isDispatched() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + when(rpc.web3Sha3(any())).thenReturn("0x1"); + String chinese = "中文"; + String emoji = "😀"; + String maxCodePoint = "􏿿"; + String[] params = {chinese, emoji, maxCodePoint, "\\u00e9"}; + String[] expected = {chinese, emoji, maxCodePoint, "é"}; + for (int i = 0; i < params.length; i++) { + MockHttpServletResponse resp = doPost("{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\"," + + "\"params\":[\"" + params[i] + "\"],\"id\":1}"); + assertEquals(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x1\"}"), + MAPPER.readTree(resp.getContentAsByteArray())); + verify(rpc).web3Sha3(expected[i]); + } + } + + @Test + public void utf8ByteOrderMark_isAccepted() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + byte[] body = concat(new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}, + utf8("{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}")); + + MockHttpServletResponse resp = doPost(body); + + assertEquals(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x1\"}"), + MAPPER.readTree(resp.getContentAsByteArray())); + verify(rpc).getLatestBlockNum(); + } + + @Test + public void utf16WithoutByteOrderMark_keepsEncodingDetection() throws Exception { + // Characterizes the current behavior: these UTF-16 bytes are also well-formed UTF-8 byte-wise + // (U+4E2D encodes as 4E 2D) and Jackson detects the encoding. + TronJsonRpc rpc = useRealRpcServer(); + when(rpc.web3Sha3(any())).thenReturn("0x1"); + String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}"; + String chinese = "{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\",\"params\":[\"中\"],\"id\":1}"; + for (Charset charset + : Arrays.asList(StandardCharsets.UTF_16LE, StandardCharsets.UTF_16BE)) { + for (String body : Arrays.asList(request, chinese)) { + MockHttpServletResponse resp = doPost(body.getBytes(charset)); + assertEquals(charset.name() + " " + body, + MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x1\"}"), + MAPPER.readTree(resp.getContentAsByteArray())); + } + } + verify(rpc, times(2)).getLatestBlockNum(); + verify(rpc, times(2)).web3Sha3("中"); + } + // --- batch size limit --- + @Test + public void utf16BodyThatIsNotWellFormedUtf8_isRejected() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + when(rpc.web3Sha3(any())).thenReturn("0x1"); + String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}"; + String nonAscii = "{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\",\"params\":[\"é\"],\"id\":1}"; + List bodies = Arrays.asList( + request.getBytes(StandardCharsets.UTF_16), + concat(new byte[] {(byte) 0xFF, (byte) 0xFE}, request.getBytes(StandardCharsets.UTF_16LE)), + nonAscii.getBytes(StandardCharsets.UTF_16LE), + nonAscii.getBytes(StandardCharsets.UTF_16BE)); + for (byte[] body : bodies) { + assertParseError(Arrays.toString(body), doPost(body)); + } + verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); + verify(rpc, never()).getLatestBlockNum(); + verify(rpc, never()).web3Sha3(any()); + } + @Test public void batchExceedsLimit_returnsExceedLimitAsArray() throws Exception { CommonParameter.getInstance().jsonRpcMaxBatchSize = 2; @@ -1858,12 +1958,41 @@ private MockHttpServletResponse doPost(String body) throws Exception { return resp; } + private MockHttpServletResponse doPost(byte[] body) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/jsonrpc"); + request.setContent(body); + MockHttpServletResponse resp = new MockHttpServletResponse(); + servlet.callDoPost(request, resp); + return resp; + } + private void doPost(String body, HttpServletResponse response) throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("POST", "/jsonrpc"); request.setContent(body.getBytes(StandardCharsets.UTF_8)); servlet.callDoPost(request, response); } + private static void assertParseError(String message, MockHttpServletResponse response) + throws IOException { + assertEquals(message, 200, response.getStatus()); + assertEquals(message, "application/json-rpc", response.getContentType()); + assertEquals(message, MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32700," + + "\"message\":\"JSON parse error\"},\"id\":null}"), + MAPPER.readTree(response.getContentAsByteArray())); + } + + private static byte[] utf8(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } + + private static byte[] concat(byte[]... parts) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] part : parts) { + out.write(part, 0, part.length); + } + return out.toByteArray(); + } + private void assertCleanupFailureDoesNotReplaceFatal(HttpServletResponse response) throws Exception { StackOverflowError fatal = new StackOverflowError("original-fatal-marker"); From b4bca6910c488726ca443f07fe29c2358a00201d Mon Sep 17 00:00:00 2001 From: waynercheung Date: Wed, 11 Nov 2026 18:28:18 +0800 Subject: [PATCH 2/7] fix(jsonrpc): reject trailing request content Parse the request body with FAIL_ON_TRAILING_TOKENS so that it must hold exactly one JSON value. Previously the node answered the first value and silently dropped anything after it, so a client that sent two requests received one response with no error. Anything other than whitespace after the first value now gets a -32700 parse error with a null id, and nothing is dispatched. Trailing whitespace is still accepted. Only the HTTP JSON-RPC servlet is changed; the shared mapper is not, and the admin endpoints already reject trailing content. BREAKING CHANGE: a body such as `{...} {...}` or `{...}}` used to return the result of the first request and now returns -32700. Clients that send several requests in one body must send them as a batch array instead. --- .../core/services/jsonrpc/JsonRpcServlet.java | 6 +- .../jsonrpc/JsonRpcServletJettyTest.java | 10 +++ .../services/jsonrpc/JsonRpcServletTest.java | 71 ++++++++++++++++--- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index caea0cf833d..92b4d9a41df 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -2,8 +2,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.exc.StreamConstraintsException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.VisibleForTesting; @@ -40,6 +42,8 @@ public class JsonRpcServlet extends RateLimiterServlet { private static final ObjectMapper MAPPER = JsonRpcMapper.create(); private static final ObjectMapper OUTBOUND_MAPPER = new ObjectMapper(); + private static final ObjectReader REQUEST_READER = + MAPPER.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private enum JsonRpcError { PARSE_ERROR(-32700), @@ -127,7 +131,7 @@ private void doPostInternal(HttpServletRequest req, HttpServletResponse resp) th } JsonNode rootNode; try { - rootNode = MAPPER.readTree(body); + rootNode = REQUEST_READER.readTree(body); if (rootNode == null || rootNode.isMissingNode()) { writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, "JSON parse error", null, false); return; diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java index cefaedfff51..02064acb8ab 100644 --- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java @@ -208,6 +208,16 @@ public void malformedUtf8BodyReturnsJsonParseErrorOverHttp() throws Exception { Assert.assertEquals(0, fatalService.okCalls.get()); } + @Test + public void trailingContentReturnsJsonParseErrorOverHttp() throws Exception { + URI endpoint = startServer(true); + String request = "{\"jsonrpc\":\"2.0\",\"method\":\"test_ok\",\"params\":[],\"id\":7}"; + + assertParseErrorOverHttp(endpoint, + (request + " " + request).getBytes(StandardCharsets.UTF_8)); + Assert.assertEquals(0, fatalService.okCalls.get()); + } + private void assertParseErrorOverHttp(URI endpoint, byte[] body) throws IOException { HttpPost request = new HttpPost(endpoint); request.setEntity(new ByteArrayEntity(body, ContentType.APPLICATION_JSON)); 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 20d77ba38f9..3e120ec7965 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 @@ -184,8 +184,6 @@ public void utf16WithoutByteOrderMark_keepsEncodingDetection() throws Exception verify(rpc, times(2)).web3Sha3("中"); } - // --- batch size limit --- - @Test public void utf16BodyThatIsNotWellFormedUtf8_isRejected() throws Exception { TronJsonRpc rpc = useRealRpcServer(); @@ -205,6 +203,62 @@ public void utf16BodyThatIsNotWellFormedUtf8_isRejected() throws Exception { verify(rpc, never()).web3Sha3(any()); } + @Test + public void trailingContent_isRejectedBeforeDispatch() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}"; + String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\"}"; + List bodies = Arrays.asList( + request + " " + request, + request + "\n" + request, + request + request, + request + " xyz", + request + "}", + "[" + request + "] " + request, + "[" + request + "]]", + notification + " " + notification, + "1 2", + "\"x\" {"); + + for (String body : bodies) { + assertParseError(body, doPost(body)); + } + verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); + verify(rpc, never()).getLatestBlockNum(); + } + + @Test + public void trailingWhitespace_isAccepted() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}"; + JsonNode expected = MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x1\"}"); + + for (String whitespace : Arrays.asList(" ", "\n", "\r\n", "\t", " \r\n\t ")) { + MockHttpServletResponse resp = doPost(request + whitespace); + assertEquals(whitespace, expected, MAPPER.readTree(resp.getContentAsByteArray())); + } + MockHttpServletResponse batch = doPost("[" + request + "]\n"); + assertEquals(MAPPER.createArrayNode().add(expected), + MAPPER.readTree(batch.getContentAsByteArray())); + MockHttpServletResponse withBom = doPost(concat( + new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}, utf8(request + "\r\n"))); + assertEquals(expected, MAPPER.readTree(withBom.getContentAsByteArray())); + verify(rpc, times(7)).getLatestBlockNum(); + } + + @Test + public void utf16WithoutByteOrderMark_rejectsTrailingContent() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}"; + for (Charset charset : Arrays.asList(StandardCharsets.UTF_16LE, StandardCharsets.UTF_16BE)) { + assertParseError(charset.name(), doPost((request + " " + request).getBytes(charset))); + } + verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); + verify(rpc, never()).getLatestBlockNum(); + } + + // --- batch size limit --- + @Test public void batchExceedsLimit_returnsExceedLimitAsArray() throws Exception { CommonParameter.getInstance().jsonRpcMaxBatchSize = 2; @@ -422,14 +476,13 @@ public void methodNameVariants_areAnsweredWithoutDispatch() throws Exception { public void allowedNameOutsideTopLevelMethod_isNotDispatched() throws Exception { TronJsonRpc rpc = useRealRpcServer(); String unknown = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_unknownMethod\""; - List bodies = Arrays.asList( - unknown + ",\"params\":[{\"method\":\"eth_blockNumber\"}],\"id\":1}", - unknown + ",\"id\":1}{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":2}"); - for (String body : bodies) { - MockHttpServletResponse resp = doPost(body); - assertEquals(methodNotFound("1"), MAPPER.readTree(resp.getContentAsByteArray())); - } + MockHttpServletResponse nested = doPost( + unknown + ",\"params\":[{\"method\":\"eth_blockNumber\"}],\"id\":1}"); + assertEquals(methodNotFound("1"), MAPPER.readTree(nested.getContentAsByteArray())); + MockHttpServletResponse trailing = doPost( + unknown + ",\"id\":1}{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":2}"); + assertParseError("trailing request", trailing); verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); verify(rpc, never()).getLatestBlockNum(); } From 0a577bb1aaa85ac08f9113ff79d99fd05b0aa34d Mon Sep 17 00:00:00 2001 From: waynercheung Date: Thu, 12 Nov 2026 14:42:21 +0800 Subject: [PATCH 3/7] fix(jsonrpc): return a parse error for undecodable request bodies Catch every IOException from parsing the request body, not only JsonProcessingException, and limit the try block to the parse call. Jackson's UTF-32 decoder reports a malformed or truncated code unit as a CharConversionException, which is not a JsonProcessingException, so it escaped the servlet and the client got an empty response. Such a body now gets a -32700 parse error with a null id, and nothing is dispatched. --- .../core/services/jsonrpc/JsonRpcServlet.java | 19 ++++++++-------- .../services/jsonrpc/JsonRpcServletTest.java | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index 92b4d9a41df..89c6373ae85 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -132,16 +132,15 @@ private void doPostInternal(HttpServletRequest req, HttpServletResponse resp) th JsonNode rootNode; try { rootNode = REQUEST_READER.readTree(body); - if (rootNode == null || rootNode.isMissingNode()) { - writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, "JSON parse error", null, false); - return; - } - } catch (JsonProcessingException e) { - if (e instanceof StreamConstraintsException) { - writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, e.getMessage(), null, false); - } else { - writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, "JSON parse error", null, false); - } + } catch (IOException e) { + // The body is already in memory, so an IOException here is a decoding failure. + String message = e instanceof StreamConstraintsException + ? e.getMessage() : "JSON parse error"; + writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, message, null, false); + return; + } + if (rootNode == null || rootNode.isMissingNode()) { + writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, "JSON parse error", null, false); return; } 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 3e120ec7965..de589374f58 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 @@ -246,6 +246,28 @@ public void trailingWhitespace_isAccepted() throws Exception { verify(rpc, times(7)).getLatestBlockNum(); } + @Test + public void undecodableUtf32Body_returnsParseError() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + byte[] request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}" + .getBytes(Charset.forName("UTF-32BE")); + byte[] padding = new byte[4096]; + for (int i = 3; i < padding.length; i += 4) { + padding[i] = ' '; + } + // Jackson's UTF-32 decoder reports these as a CharConversionException, an IOException that + // is not a JsonProcessingException. + List bodies = Arrays.asList( + concat(request, new byte[] {'\n'}), + concat(request, new byte[] {0, 0, ' '}), + concat(request, padding, new byte[] {0, 0x11, 0, 0})); + for (byte[] body : bodies) { + assertParseError(Arrays.toString(body), doPost(body)); + } + verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); + verify(rpc, never()).getLatestBlockNum(); + } + @Test public void utf16WithoutByteOrderMark_rejectsTrailingContent() throws Exception { TronJsonRpc rpc = useRealRpcServer(); From 1d841d42ed6313cdc91615310d7b65ba9457c7d1 Mon Sep 17 00:00:00 2001 From: waynercheung Date: Thu, 12 Nov 2026 14:43:55 +0800 Subject: [PATCH 4/7] test(jsonrpc): cover raw two-byte UTF-8 request parameters Add a raw two-byte UTF-8 parameter (U+07FF) to the well-formed input test; the existing two-byte case was a JSON escape and so plain ASCII on the wire. Build the four-byte parameters from code points so that they are visible in the source. --- .../tron/core/services/jsonrpc/JsonRpcServletTest.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 de589374f58..127f8c0a375 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 @@ -136,11 +136,12 @@ public void malformedUtf8_isRejectedBeforeDispatch() throws Exception { public void wellFormedNonAsciiInput_isDispatched() throws Exception { TronJsonRpc rpc = useRealRpcServer(); when(rpc.web3Sha3(any())).thenReturn("0x1"); + String twoBytes = new String(Character.toChars(0x7FF)); String chinese = "中文"; - String emoji = "😀"; - String maxCodePoint = "􏿿"; - String[] params = {chinese, emoji, maxCodePoint, "\\u00e9"}; - String[] expected = {chinese, emoji, maxCodePoint, "é"}; + String emoji = new String(Character.toChars(0x1F600)); + String maxCodePoint = new String(Character.toChars(0x10FFFF)); + String[] params = {twoBytes, chinese, emoji, maxCodePoint, "\\u00e9"}; + String[] expected = {twoBytes, chinese, emoji, maxCodePoint, "é"}; for (int i = 0; i < params.length; i++) { MockHttpServletResponse resp = doPost("{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\"," + "\"params\":[\"" + params[i] + "\"],\"id\":1}"); From bf6d23afe2e85f0aa2912a1367d5604a1864465d Mon Sep 17 00:00:00 2001 From: waynercheung Date: Thu, 12 Nov 2026 15:05:08 +0800 Subject: [PATCH 5/7] fix(jsonrpc): accept only UTF-8 request bodies Also reject a request body that contains a NUL byte, as the admin endpoint already does. A NUL byte cannot appear in a UTF-8 JSON text that the parser accepts, but Jackson takes one in the first four bytes as a sign of UTF-16 or UTF-32 and decodes the body that way. Compatibility: UTF-8 request bodies, with or without a byte order mark, are unchanged, and the JSON escape \u0000 still works. A UTF-16 or UTF-32 body without a byte order mark used to be decoded as such when its raw bytes were also well-formed UTF-8; it now gets a -32700 parse error with a null id. --- .../core/services/jsonrpc/JsonRpcServlet.java | 6 +- .../services/jsonrpc/JsonRpcServletTest.java | 58 +++++++++++-------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index 89c6373ae85..129d067165d 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Utf8; +import com.google.common.primitives.Bytes; import com.googlecode.jsonrpc4j.ErrorResolver.JsonError; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; import com.googlecode.jsonrpc4j.JsonRpcMethod; @@ -124,8 +125,9 @@ private void doPostInternal(HttpServletRequest req, HttpServletResponse resp) th // Transport IOException from readBody propagates as HTTP 500 (genuine IO failure). byte[] body = readBody(req.getInputStream()); - // Jackson accepts overlong and surrogate UTF-8 sequences, so check the bytes first. - if (!Utf8.isWellFormed(body)) { + // Jackson accepts overlong, surrogate and above-U+10FFFF UTF-8 sequences and treats NUL + // bytes as a sign of UTF-16 or UTF-32, so check the bytes first. + if (!Utf8.isWellFormed(body) || Bytes.contains(body, (byte) 0)) { writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, "JSON parse error", null, false); return; } 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 127f8c0a375..79da10888b1 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 @@ -165,24 +165,47 @@ public void utf8ByteOrderMark_isAccepted() throws Exception { } @Test - public void utf16WithoutByteOrderMark_keepsEncodingDetection() throws Exception { - // Characterizes the current behavior: these UTF-16 bytes are also well-formed UTF-8 byte-wise - // (U+4E2D encodes as 4E 2D) and Jackson detects the encoding. + public void utf16AndUtf32WithoutByteOrderMark_areRejected() throws Exception { TronJsonRpc rpc = useRealRpcServer(); - when(rpc.web3Sha3(any())).thenReturn("0x1"); String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}"; String chinese = "{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\",\"params\":[\"中\"],\"id\":1}"; - for (Charset charset - : Arrays.asList(StandardCharsets.UTF_16LE, StandardCharsets.UTF_16BE)) { + for (String charset : Arrays.asList("UTF-16LE", "UTF-16BE", "UTF-32LE", "UTF-32BE")) { for (String body : Arrays.asList(request, chinese)) { - MockHttpServletResponse resp = doPost(body.getBytes(charset)); - assertEquals(charset.name() + " " + body, - MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x1\"}"), - MAPPER.readTree(resp.getContentAsByteArray())); + assertParseError(charset + " " + body, doPost(body.getBytes(Charset.forName(charset)))); } } - verify(rpc, times(2)).getLatestBlockNum(); - verify(rpc, times(2)).web3Sha3("中"); + verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); + verify(rpc, never()).getLatestBlockNum(); + verify(rpc, never()).web3Sha3(any()); + } + + @Test + public void rawNulByte_isRejected() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + String prefix = "{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\",\"params\":[\"a"; + String suffix = "b\"],\"id\":1}"; + List bodies = Arrays.asList( + concat(utf8(prefix), new byte[] {0}, utf8(suffix)), + concat(utf8(prefix + suffix), new byte[] {0}), + concat(new byte[] {0}, utf8(prefix + suffix))); + for (byte[] body : bodies) { + assertParseError(Arrays.toString(body), doPost(body)); + } + verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); + verify(rpc, never()).web3Sha3(any()); + } + + @Test + public void escapedNulCharacter_isDispatched() throws Exception { + TronJsonRpc rpc = useRealRpcServer(); + when(rpc.web3Sha3(any())).thenReturn("0x1"); + + MockHttpServletResponse resp = doPost("{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\"," + + "\"params\":[\"a\\u0000b\"],\"id\":1}"); + + assertEquals(MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x1\"}"), + MAPPER.readTree(resp.getContentAsByteArray())); + verify(rpc).web3Sha3("a" + (char) 0 + "b"); } @Test @@ -269,17 +292,6 @@ public void undecodableUtf32Body_returnsParseError() throws Exception { verify(rpc, never()).getLatestBlockNum(); } - @Test - public void utf16WithoutByteOrderMark_rejectsTrailingContent() throws Exception { - TronJsonRpc rpc = useRealRpcServer(); - String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}"; - for (Charset charset : Arrays.asList(StandardCharsets.UTF_16LE, StandardCharsets.UTF_16BE)) { - assertParseError(charset.name(), doPost((request + " " + request).getBytes(charset))); - } - verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); - verify(rpc, never()).getLatestBlockNum(); - } - // --- batch size limit --- @Test From c2ea817fdd5efa885488258f13fc4e9f588ad90e Mon Sep 17 00:00:00 2001 From: waynercheung Date: Thu, 12 Nov 2026 20:56:36 +0800 Subject: [PATCH 6/7] refactor(jsonrpc): narrow parsing catch to JsonProcessingException Request bodies that are not well-formed UTF-8 or that contain a NUL byte are rejected before parsing, so the parser only sees UTF-8 input held in memory and reports every failure as a JsonProcessingException. Go back to catching that type. The try block still covers only the parse call. No behavior change. --- .../java/org/tron/core/services/jsonrpc/JsonRpcServlet.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index 129d067165d..bf93dd0d5f1 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -134,8 +134,7 @@ private void doPostInternal(HttpServletRequest req, HttpServletResponse resp) th JsonNode rootNode; try { rootNode = REQUEST_READER.readTree(body); - } catch (IOException e) { - // The body is already in memory, so an IOException here is a decoding failure. + } catch (JsonProcessingException e) { String message = e instanceof StreamConstraintsException ? e.getMessage() : "JSON parse error"; writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, message, null, false); From dfd2806ce81778c70defd2cd2897538cb4846794 Mon Sep 17 00:00:00 2001 From: waynercheung Date: Thu, 12 Nov 2026 20:56:36 +0800 Subject: [PATCH 7/7] test(jsonrpc): drop request body cases already covered by the byte check - Remove the UTF-32 decoding test; its bodies contain NUL bytes and are rejected before parsing, like the other UTF-16 and UTF-32 cases. - Merge the UTF-16 test with a byte order mark into the UTF-16/UTF-32 rejection test and add a U+00E9 request to it. - Drop stubs for methods that the rejection tests never reach. - Use Guava Bytes.concat instead of local helpers. --- .../jsonrpc/JsonRpcServletJettyTest.java | 11 +--- .../services/jsonrpc/JsonRpcServletTest.java | 63 +++---------------- 2 files changed, 12 insertions(+), 62 deletions(-) diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java index 02064acb8ab..c448d7ea0f9 100644 --- a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletJettyTest.java @@ -1,10 +1,11 @@ package org.tron.core.services.jsonrpc; +import static com.google.common.primitives.Bytes.concat; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.googlecode.jsonrpc4j.JsonRpcMethod; import com.googlecode.jsonrpc4j.JsonRpcServer; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.lang.reflect.Field; import java.net.SocketException; @@ -233,14 +234,6 @@ private void assertParseErrorOverHttp(URI endpoint, byte[] body) throws IOExcept } } - private static byte[] concat(byte[]... parts) { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - for (byte[] part : parts) { - out.write(part, 0, part.length); - } - return out.toByteArray(); - } - public interface FatalService { @JsonRpcMethod("test_fatal") 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 79da10888b1..e8ecb5f90f0 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 @@ -1,5 +1,6 @@ package org.tron.core.services.jsonrpc; +import static com.google.common.primitives.Bytes.concat; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -34,7 +35,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.googlecode.jsonrpc4j.JsonRpcMethod; import com.googlecode.jsonrpc4j.JsonRpcServer; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -110,7 +110,6 @@ public void emptyBody_returnsParseError() throws Exception { @Test public void malformedUtf8_isRejectedBeforeDispatch() throws Exception { TronJsonRpc rpc = useRealRpcServer(); - when(rpc.web3ClientVersion()).thenReturn("client"); // Each sequence replaces the leading 'w' of web3_clientVersion. The overlong forms decode // to 'w' in a lenient parser, so they reach the real method unless rejected up front. byte[][] sequences = { @@ -165,15 +164,22 @@ public void utf8ByteOrderMark_isAccepted() throws Exception { } @Test - public void utf16AndUtf32WithoutByteOrderMark_areRejected() throws Exception { + public void utf16AndUtf32Bodies_areRejected() throws Exception { TronJsonRpc rpc = useRealRpcServer(); String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}"; String chinese = "{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\",\"params\":[\"中\"],\"id\":1}"; + String accented = "{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\",\"params\":[\"é\"],\"id\":1}"; for (String charset : Arrays.asList("UTF-16LE", "UTF-16BE", "UTF-32LE", "UTF-32BE")) { - for (String body : Arrays.asList(request, chinese)) { + for (String body : Arrays.asList(request, chinese, accented)) { assertParseError(charset + " " + body, doPost(body.getBytes(Charset.forName(charset)))); } } + List withByteOrderMark = Arrays.asList( + request.getBytes(StandardCharsets.UTF_16), + concat(new byte[] {(byte) 0xFF, (byte) 0xFE}, request.getBytes(StandardCharsets.UTF_16LE))); + for (byte[] body : withByteOrderMark) { + assertParseError(Arrays.toString(body), doPost(body)); + } verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); verify(rpc, never()).getLatestBlockNum(); verify(rpc, never()).web3Sha3(any()); @@ -208,25 +214,6 @@ public void escapedNulCharacter_isDispatched() throws Exception { verify(rpc).web3Sha3("a" + (char) 0 + "b"); } - @Test - public void utf16BodyThatIsNotWellFormedUtf8_isRejected() throws Exception { - TronJsonRpc rpc = useRealRpcServer(); - when(rpc.web3Sha3(any())).thenReturn("0x1"); - String request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}"; - String nonAscii = "{\"jsonrpc\":\"2.0\",\"method\":\"web3_sha3\",\"params\":[\"é\"],\"id\":1}"; - List bodies = Arrays.asList( - request.getBytes(StandardCharsets.UTF_16), - concat(new byte[] {(byte) 0xFF, (byte) 0xFE}, request.getBytes(StandardCharsets.UTF_16LE)), - nonAscii.getBytes(StandardCharsets.UTF_16LE), - nonAscii.getBytes(StandardCharsets.UTF_16BE)); - for (byte[] body : bodies) { - assertParseError(Arrays.toString(body), doPost(body)); - } - verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); - verify(rpc, never()).getLatestBlockNum(); - verify(rpc, never()).web3Sha3(any()); - } - @Test public void trailingContent_isRejectedBeforeDispatch() throws Exception { TronJsonRpc rpc = useRealRpcServer(); @@ -270,28 +257,6 @@ public void trailingWhitespace_isAccepted() throws Exception { verify(rpc, times(7)).getLatestBlockNum(); } - @Test - public void undecodableUtf32Body_returnsParseError() throws Exception { - TronJsonRpc rpc = useRealRpcServer(); - byte[] request = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"id\":1}" - .getBytes(Charset.forName("UTF-32BE")); - byte[] padding = new byte[4096]; - for (int i = 3; i < padding.length; i += 4) { - padding[i] = ' '; - } - // Jackson's UTF-32 decoder reports these as a CharConversionException, an IOException that - // is not a JsonProcessingException. - List bodies = Arrays.asList( - concat(request, new byte[] {'\n'}), - concat(request, new byte[] {0, 0, ' '}), - concat(request, padding, new byte[] {0, 0x11, 0, 0})); - for (byte[] body : bodies) { - assertParseError(Arrays.toString(body), doPost(body)); - } - verify(realRpcServer, never()).handleRequest(any(InputStream.class), any(OutputStream.class)); - verify(rpc, never()).getLatestBlockNum(); - } - // --- batch size limit --- @Test @@ -2073,14 +2038,6 @@ private static byte[] utf8(String text) { return text.getBytes(StandardCharsets.UTF_8); } - private static byte[] concat(byte[]... parts) { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - for (byte[] part : parts) { - out.write(part, 0, part.length); - } - return out.toByteArray(); - } - private void assertCleanupFailureDoesNotReplaceFatal(HttpServletResponse response) throws Exception { StackOverflowError fatal = new StackOverflowError("original-fatal-marker");