diff --git a/framework/src/main/java/org/tron/common/application/HttpService.java b/framework/src/main/java/org/tron/common/application/HttpService.java index 7060f1f135a..a06e09c6a25 100644 --- a/framework/src/main/java/org/tron/common/application/HttpService.java +++ b/framework/src/main/java/org/tron/common/application/HttpService.java @@ -30,7 +30,6 @@ import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.handler.ErrorHandler; -import org.eclipse.jetty.server.handler.SizeLimitHandler; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.util.BufferUtil; import org.tron.core.config.args.Args; @@ -110,7 +109,8 @@ protected void initServer() { protected ServletContextHandler initContextHandler() { ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); context.setContextPath(this.contextPath); - SizeLimitHandler sizeLimitHandler = new SizeLimitHandler(this.maxRequestSize, -1); + RequestSizeLimitHandler sizeLimitHandler = + new RequestSizeLimitHandler(this.maxRequestSize); sizeLimitHandler.setHandler(context); this.apiServer.setHandler(sizeLimitHandler); return context; @@ -123,7 +123,7 @@ protected void addFilter(ServletContextHandler context) { } /** - * For oversized requests (the 413 thrown by SizeLimitHandler during dispatch) logs the + * For oversized requests (the 413 thrown by RequestSizeLimitHandler during dispatch) logs the * detail server-side and returns the short, uniform bad-message page, instead of the * default error page that leaks the exception stack and internal request sizes. All * other errors keep Jetty's default handling. diff --git a/framework/src/main/java/org/tron/common/application/RequestSizeLimitHandler.java b/framework/src/main/java/org/tron/common/application/RequestSizeLimitHandler.java new file mode 100644 index 00000000000..a92aa33c3b5 --- /dev/null +++ b/framework/src/main/java/org/tron/common/application/RequestSizeLimitHandler.java @@ -0,0 +1,55 @@ +package org.tron.common.application; + +import java.io.IOException; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.BadMessageException; +import org.eclipse.jetty.server.HttpInput; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.handler.HandlerWrapper; + +/** + * Enforces request-body limits using Jetty's received-byte counter. + * + *

This request-only handler installs its own input interceptor to avoid counting the same + * unread content repeatedly. It does not install a response-limiting output interceptor. + */ +public class RequestSizeLimitHandler extends HandlerWrapper { + + private final long requestLimit; + + public RequestSizeLimitHandler(long requestLimit) { + this.requestLimit = requestLimit; + } + + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, + HttpServletResponse response) throws IOException, ServletException { + if (requestLimit >= 0) { + long contentLength = baseRequest.getContentLengthLong(); + checkRequestLimit(contentLength); + + if (contentLength < 0) { + HttpInput input = baseRequest.getHttpInput(); + input.addInterceptor(content -> { + if (content == null) { + return null; + } + // addContent updates this counter before interception, under HttpInput's input lock. + checkRequestLimit(input.getContentReceived()); + return content; + }); + } + } + + super.handle(target, baseRequest, request, response); + } + + private void checkRequestLimit(long size) { + if (requestLimit >= 0 && size > requestLimit) { + throw new BadMessageException(413, + "Request body is too large: " + size + ">" + requestLimit); + } + } +} diff --git a/framework/src/test/java/org/tron/common/jetty/RequestSizeLimitHandlerTest.java b/framework/src/test/java/org/tron/common/jetty/RequestSizeLimitHandlerTest.java new file mode 100644 index 00000000000..8e4b098ede0 --- /dev/null +++ b/framework/src/test/java/org/tron/common/jetty/RequestSizeLimitHandlerTest.java @@ -0,0 +1,283 @@ +package org.tron.common.jetty; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.BadMessageException; +import org.eclipse.jetty.server.HttpInput; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.eclipse.jetty.server.handler.HandlerWrapper; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.application.RequestSizeLimitHandler; +import org.tron.json.JSONObject; + +public class RequestSizeLimitHandlerTest { + + private static final int LIMIT = 64 * 1024; + private final Map inputs = new ConcurrentHashMap<>(); + private final Map> arrivals = new ConcurrentHashMap<>(); + private Server server; + private int port; + private volatile CountDownLatch firstReads; + + @Before + public void setup() throws Exception { + server = new Server(); + ServerConnector connector = new ServerConnector(server); + connector.setHost("127.0.0.1"); + connector.setPort(0); + server.addConnector(connector); + RequestSizeLimitHandler limitHandler = new RequestSizeLimitHandler(LIMIT); + limitHandler.setHandler(new AbstractHandler() { + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, + HttpServletResponse response) throws IOException { + HttpInput input = baseRequest.getHttpInput(); + inputs.put(target, input); + int count = 0; + int error = 0; + long firstReceived = 0; + try { + while (input.read() != -1) { + count++; + int checkpoint = firstReads == null ? 1 + : 13 + Integer.parseInt(target.substring("/concurrent".length())); + if (count == checkpoint) { + firstReceived = input.getContentReceived(); + if (firstReads != null) { + firstReads.countDown(); + } + } + } + } catch (BadMessageException e) { + error = e.getCode(); + } + byte[] body = ("{\"bytes\":" + count + ",\"received\":" + input.getContentReceived() + + ",\"firstReceived\":" + firstReceived + ",\"error\":" + error + "}") + .getBytes(StandardCharsets.UTF_8); + // Diagnostic endpoint: assertions inspect the caught exception code and read count. + response.setStatus(200); + response.setContentType("application/json"); + response.setContentLength(body.length); + response.getOutputStream().write(body); + baseRequest.setHandled(true); + } + }); + HandlerWrapper observer = new HandlerWrapper() { + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, + HttpServletResponse response) throws IOException, ServletException { + HttpInput input = baseRequest.getHttpInput(); + List counts = Collections.synchronizedList(new ArrayList<>()); + arrivals.put(target, counts); + input.addInterceptor(content -> { + if (content != null && content.hasContent()) { + long received = input.getContentReceived(); + if (counts.isEmpty() || counts.get(counts.size() - 1) != received) { + counts.add(received); + } + } + return content; + }); + super.handle(target, baseRequest, request, response); + } + }; + observer.setHandler(limitHandler); + server.setHandler(observer); + server.start(); + port = connector.getLocalPort(); + } + + @After + public void teardown() throws Exception { + server.stop(); + } + + @Test + public void testOneByteReadsAcceptExactLimit() throws Exception { + try (Socket socket = connect()) { + writeHeaders(socket, "/exact", true, LIMIT); + writeBody(socket, true, LIMIT); + JSONObject result = readResponse(socket); + Assert.assertEquals(0, result.getIntValue("error")); + Assert.assertEquals(LIMIT, result.getIntValue("bytes")); + Assert.assertEquals(LIMIT, result.getLongValue("received")); + } + } + + @Test + public void testOneByteReadsRejectBeforeReturningBytesBeyondLimit() throws Exception { + try (Socket socket = connect()) { + writeHeaders(socket, "/over", true, LIMIT + 4096); + writeBody(socket, true, LIMIT + 4096); + JSONObject result = readResponse(socket); + Assert.assertEquals(413, result.getIntValue("error")); + Assert.assertTrue(result.toJSONString(), result.getIntValue("bytes") <= LIMIT); + } + } + + @Test + public void testConcurrentRequestsHaveIndependentCumulativeCounters() throws Exception { + int requests = 4; + firstReads = new CountDownLatch(requests); + ExecutorService executor = Executors.newFixedThreadPool(requests, + new ThreadFactoryBuilder().setNameFormat("request-size-counter-client-%d").build()); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < requests; i++) { + final int index = i; + results.add(executor.submit(() -> { + boolean chunked = index % 2 == 0; + int total = 1024 + index * 257; + int first = 13 + index; + try (Socket socket = connect()) { + writeHeaders(socket, "/concurrent" + index, chunked, total); + writePart(socket, chunked, first); + // All four bodies are partially read before any client sends its remainder. + Assert.assertTrue(firstReads.await(10, TimeUnit.SECONDS)); + writePart(socket, chunked, 31); + writePart(socket, chunked, total - first - 31); + finishBody(socket, chunked); + return readResponse(socket); + } + })); + } + for (int i = 0; i < requests; i++) { + JSONObject result = results.get(i).get(15, TimeUnit.SECONDS); + int total = 1024 + i * 257; + Assert.assertEquals(0, result.getIntValue("error")); + Assert.assertEquals(total, result.getIntValue("bytes")); + Assert.assertEquals(total, result.getLongValue("received")); + Assert.assertEquals(13 + i, result.getLongValue("firstReceived")); + List counts = arrivals.get("/concurrent" + i); + Assert.assertTrue(counts.toString(), counts.contains((long) (13 + i))); + Assert.assertEquals(total, counts.get(counts.size() - 1).longValue()); + for (int j = 1; j < counts.size(); j++) { + Assert.assertTrue(counts.toString(), counts.get(j) > counts.get(j - 1)); + } + for (int j = 0; j < i; j++) { + Assert.assertNotSame(inputs.get("/concurrent" + i), inputs.get("/concurrent" + j)); + } + } + } finally { + executor.shutdown(); + if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { + executor.shutdownNow(); + Assert.assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + } + } + } + + @Test + public void testKeepAliveRecyclesCounterBetweenFramings() throws Exception { + try (Socket socket = connect()) { + for (int i = 0; i < 4; i++) { + boolean chunked = i % 2 == 0; + int total = 1024 - i * 257; + writeHeaders(socket, "/reuse" + i, chunked, total); + writeBody(socket, chunked, total); + JSONObject result = readResponse(socket); + Assert.assertEquals(0, result.getIntValue("error")); + Assert.assertEquals(total, result.getIntValue("bytes")); + Assert.assertEquals(total, result.getLongValue("received")); + Assert.assertSame(inputs.get("/reuse0"), inputs.get("/reuse" + i)); + } + } + } + + private Socket connect() throws Exception { + Socket socket = new Socket("127.0.0.1", port); + socket.setSoTimeout(15000); + return socket; + } + + private void writeHeaders(Socket socket, String path, boolean chunked, int total) + throws Exception { + String framing = chunked ? "Transfer-Encoding: chunked" : "Content-Length: " + total; + socket.getOutputStream().write(("POST " + path + " HTTP/1.1\r\nHost: localhost\r\n" + + framing + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().flush(); + } + + private void writeBody(Socket socket, boolean chunked, int total) throws Exception { + writePart(socket, chunked, total); + finishBody(socket, chunked); + } + + private void writePart(Socket socket, boolean chunked, int size) throws Exception { + OutputStream output = socket.getOutputStream(); + if (chunked) { + output.write((Integer.toHexString(size) + "\r\n").getBytes(StandardCharsets.US_ASCII)); + } + byte[] body = new byte[size]; + Arrays.fill(body, (byte) 'a'); + output.write(body); + if (chunked) { + output.write("\r\n".getBytes(StandardCharsets.US_ASCII)); + } + output.flush(); + } + + private void finishBody(Socket socket, boolean chunked) throws Exception { + if (chunked) { + socket.getOutputStream().write("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().flush(); + } + } + + private JSONObject readResponse(Socket socket) throws Exception { + InputStream input = socket.getInputStream(); + Assert.assertTrue(readLine(input).startsWith("HTTP/1.1 200")); + int length = -1; + String line; + while (!(line = readLine(input)).isEmpty()) { + if (line.startsWith("Content-Length:")) { + length = Integer.parseInt(line.substring("Content-Length:".length()).trim()); + } + } + Assert.assertTrue("response must have a content length", length >= 0); + byte[] body = new byte[length]; + for (int i = 0; i < length; i++) { + int value = input.read(); + Assert.assertTrue("early response EOF", value >= 0); + body[i] = (byte) value; + } + return JSONObject.parseObject(new String(body, StandardCharsets.UTF_8)); + } + + private String readLine(InputStream input) throws Exception { + ByteArrayOutputStream line = new ByteArrayOutputStream(); + int value; + while ((value = input.read()) != '\n') { + Assert.assertTrue("early header EOF", value >= 0); + if (value != '\r') { + line.write(value); + } + } + return new String(line.toByteArray(), StandardCharsets.US_ASCII); + } +} diff --git a/framework/src/test/java/org/tron/common/jetty/SizeLimitHandlerTest.java b/framework/src/test/java/org/tron/common/jetty/SizeLimitHandlerTest.java index 145eda6d398..295eee2cf6b 100644 --- a/framework/src/test/java/org/tron/common/jetty/SizeLimitHandlerTest.java +++ b/framework/src/test/java/org/tron/common/jetty/SizeLimitHandlerTest.java @@ -1,26 +1,40 @@ package org.tron.common.jetty; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; import java.net.Socket; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; +import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; import org.apache.http.entity.InputStreamEntity; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.handler.HandlerWrapper; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; import org.junit.AfterClass; @@ -29,15 +43,21 @@ import org.junit.ClassRule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.springframework.test.util.ReflectionTestUtils; import org.tron.common.TestConstants; import org.tron.common.application.HttpService; +import org.tron.common.application.RequestSizeLimitHandler; import org.tron.common.utils.PublicMethod; +import org.tron.core.Wallet; import org.tron.core.config.args.Args; +import org.tron.core.services.http.servlets.GetBlockByNumServlet; +import org.tron.core.services.ratelimiter.RateLimiterContainer; import org.tron.json.JSONObject; +import org.tron.protos.Protocol.Block; /** - * Tests {@link org.eclipse.jetty.server.handler.SizeLimitHandler} body-size - * enforcement configured in {@link HttpService}. + * Tests {@link RequestSizeLimitHandler} body-size enforcement configured in + * {@link HttpService}. * * Covers: accept/reject by size, UTF-8 byte counting, independent limits * across HttpService instances, chunked transfer, and zero-limit behavior. @@ -50,6 +70,7 @@ public class SizeLimitHandlerTest { private static final int HTTP_MAX_BODY_SIZE = 1024; private static final int SECOND_SERVICE_MAX_BODY_SIZE = 512; + private static final int CLIENT_TIMEOUT_MILLIS = 15000; @ClassRule public static final TemporaryFolder temporaryFolder = new TemporaryFolder(); @@ -78,7 +99,8 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I // Mimics RateLimiterServlet line 119-120: silently logs, does not rethrow resp.setStatus(HttpServletResponse.SC_OK); resp.setContentType("application/json"); - resp.getWriter().println("{\"Error\":\"" + e.getClass().getSimpleName() + "\"}"); + resp.getWriter().println("{\"Error\":\"" + e.getClass().getSimpleName() + + "\",\"detail\":\"" + e.getMessage() + "\"}"); } } } @@ -97,6 +119,37 @@ protected void addServlet(ServletContextHandler context) { } } + static class ObserverHttpService extends TestHttpService { + private final AtomicInteger interceptions; + + ObserverHttpService(int port, long maxRequestSize, AtomicInteger interceptions) { + super(port, maxRequestSize); + this.interceptions = interceptions; + } + + @Override + protected ServletContextHandler initContextHandler() { + ServletContextHandler context = super.initContextHandler(); + HandlerWrapper limitHandler = (HandlerWrapper) apiServer.getHandler(); + HandlerWrapper observer = new HandlerWrapper() { + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, + HttpServletResponse response) throws IOException, ServletException { + baseRequest.getHttpInput().addInterceptor(content -> { + if (content != null) { + interceptions.incrementAndGet(); + } + return content; + }); + super.handle(target, baseRequest, request, response); + } + }; + observer.setHandler(limitHandler); + apiServer.setHandler(observer); + return context; + } + } + /** Second HttpService instance with a different size limit, for independence tests. */ static class SecondHttpService extends HttpService { SecondHttpService(int port, long maxRequestSize) { @@ -128,7 +181,13 @@ public static void setup() throws Exception { secondService.start().get(10, TimeUnit.SECONDS); secondServerUri = new URI(String.format("http://localhost:%d/", secondPort)); - client = HttpClients.createDefault(); + client = HttpClients.custom() + .setDefaultRequestConfig(RequestConfig.custom() + .setConnectTimeout(CLIENT_TIMEOUT_MILLIS) + .setConnectionRequestTimeout(CLIENT_TIMEOUT_MILLIS) + .setSocketTimeout(CLIENT_TIMEOUT_MILLIS) + .build()) + .build(); } @AfterClass @@ -257,14 +316,137 @@ public void testChunkedBodyWithinLimit() throws Exception { Assert.assertEquals(200, post(httpServerUri, chunked)); } + @Test + public void testLargeBodyWithinLimitMatchesContentLengthAndChunked() throws Exception { + int limit = 4 * 1024 * 1024; + int port = PublicMethod.chooseRandomPort(); + TestHttpService service = new TestHttpService(port, limit); + try { + service.start().get(10, TimeUnit.SECONDS); + URI uri = new URI(String.format("http://localhost:%d/", port)); + String body = repeat('a', 3 * 1024 * 1024); + byte[] data = body.getBytes("UTF-8"); + + String knownLengthResponse = postForBody(uri, new StringEntity(body)); + String chunkedResponse = postForBody(uri, + new InputStreamEntity(new ByteArrayInputStream(data), -1)); + + Assert.assertEquals(knownLengthResponse, chunkedResponse); + Assert.assertEquals(data.length, + JSONObject.parseObject(chunkedResponse).getIntValue("bytes")); + } finally { + service.stop(); + } + } + + @Test + public void testGetBlockByNumAcceptsSameLargeBodyWithBothFramings() throws Exception { + int limit = 4 * 1024 * 1024; + long originalLimit = Args.getInstance().getHttpMaxMessageSize(); + boolean originalNonBlocking = Args.getInstance().isRateLimiterApiNonBlocking(); + Args.getInstance().setHttpMaxMessageSize(limit); + Args.getInstance().setRateLimiterApiNonBlocking(false); + + Wallet wallet = mock(Wallet.class); + when(wallet.getBlockByNum(0)).thenReturn(Block.getDefaultInstance()); + GetBlockByNumServlet servlet = new GetBlockByNumServlet(); + ReflectionTestUtils.setField(servlet, "wallet", wallet); + ReflectionTestUtils.setField(servlet, "container", new RateLimiterContainer()); + + int port = PublicMethod.chooseRandomPort(); + TestHttpService service = new TestHttpService(port, limit) { + @Override + protected void addServlet(ServletContextHandler context) { + context.addServlet(new ServletHolder(servlet), "/wallet/getblockbynum"); + } + }; + try { + service.start().get(10, TimeUnit.SECONDS); + URI uri = new URI(String.format("http://localhost:%d/wallet/getblockbynum", port)); + String prefix = "{\"num\":0,\"_pad\":\""; + String suffix = "\"}"; + String body = prefix + repeat('A', 3 * 1024 * 1024 - prefix.length() - suffix.length()) + + suffix; + byte[] data = body.getBytes(StandardCharsets.UTF_8); + InputStreamEntity chunked = new InputStreamEntity(new ByteArrayInputStream(data), -1); + chunked.setContentType(ContentType.APPLICATION_JSON.getMimeType()); + + String knownLengthResponse = postForBody(uri, + new StringEntity(body, ContentType.APPLICATION_JSON)); + String chunkedResponse = postForBody(uri, chunked); + + Assert.assertEquals(knownLengthResponse, chunkedResponse); + Assert.assertFalse(chunkedResponse.contains("Error")); + verify(wallet, times(2)).getBlockByNum(0); + } finally { + try { + service.stop(); + } finally { + Args.getInstance().setHttpMaxMessageSize(originalLimit); + Args.getInstance().setRateLimiterApiNonBlocking(originalNonBlocking); + } + } + } + + @Test + public void testChunkedBodyAtLimitWithDifferentChunkSizes() throws Exception { + byte[] data = repeat('a', HTTP_MAX_BODY_SIZE).getBytes("UTF-8"); + for (int chunkSize : new int[] {1, 7, 64, 1024, 4096}) { + String response = postChunked(httpServerUri, data, chunkSize); + Assert.assertEquals("chunk size " + chunkSize, data.length, + JSONObject.parseObject(response).getIntValue("bytes")); + } + } + + @Test + public void testChunkedBodyOneByteOverLimitUsesActualSize() throws Exception { + byte[] data = repeat('a', HTTP_MAX_BODY_SIZE + 1).getBytes("UTF-8"); + String response = postForBody(httpServerUri, + new InputStreamEntity(new ByteArrayInputStream(data), -1)); + + Assert.assertTrue(response, response.contains("BadMessageException")); + String detail = (HTTP_MAX_BODY_SIZE + 1) + ">" + HTTP_MAX_BODY_SIZE; + Assert.assertTrue(response, response.contains(detail)); + } + + @Test + public void testChunkedBodyCountResetBetweenRequests() throws Exception { + byte[] data = repeat('a', HTTP_MAX_BODY_SIZE).getBytes("UTF-8"); + for (int i = 0; i < 2; i++) { + String response = postForBody(httpServerUri, + new InputStreamEntity(new ByteArrayInputStream(data), -1)); + Assert.assertEquals(data.length, JSONObject.parseObject(response).getIntValue("bytes")); + } + } + + @Test + public void testExistingInterceptorIsPreserved() throws Exception { + AtomicInteger interceptions = new AtomicInteger(); + int port = PublicMethod.chooseRandomPort(); + ObserverHttpService service = new ObserverHttpService(port, HTTP_MAX_BODY_SIZE, + interceptions); + try { + service.start().get(10, TimeUnit.SECONDS); + URI uri = new URI(String.format("http://localhost:%d/", port)); + byte[] data = repeat('a', HTTP_MAX_BODY_SIZE).getBytes("UTF-8"); + String response = postForBody(uri, + new InputStreamEntity(new ByteArrayInputStream(data), -1)); + + Assert.assertEquals(data.length, JSONObject.parseObject(response).getIntValue("bytes")); + Assert.assertTrue(interceptions.get() > 0); + } finally { + service.stop(); + } + } + /** * Chunked oversized body hitting a servlet with broad catch(Exception). * - * SizeLimitHandler's LimitInterceptor throws BadMessageException during - * streaming read, but the servlet's catch(Exception) absorbs it and returns - * 200 + error JSON instead of 413. This matches real TRON servlet behavior. + * RequestSizeLimitHandler throws BadMessageException during streaming read, + * but the servlet's catch(Exception) absorbs it and returns 200 + error JSON + * instead of 413. This matches real TRON servlet behavior. * - * OOM protection still works: the body read is truncated at the limit. + * The size check rejects further reading; this does not establish an OOM guarantee. */ @Test public void testChunkedBodyExceedsLimit() throws Exception { @@ -278,7 +460,7 @@ public void testChunkedBodyExceedsLimit() throws Exception { logger.info("Chunked oversized: status={}, body={}", status, body); // catch(Exception) absorbs BadMessageException -> 200 + error JSON, not 413. - // Body read IS truncated - OOM protection still effective. + // Verify the reading failure separately from the public HTTP status. Assert.assertEquals(200, status); Assert.assertTrue("Error should be surfaced in response body", body.contains("Error")); @@ -308,6 +490,61 @@ public void testZeroLimitRejectsAllBodies() throws Exception { } } + @Test + public void testNegativeLimitDisablesRequestSizeCheck() throws Exception { + int port = PublicMethod.chooseRandomPort(); + TestHttpService service = new TestHttpService(port, -1); + try { + service.start().get(10, TimeUnit.SECONDS); + URI uri = new URI(String.format("http://localhost:%d/", port)); + String body = repeat('a', HTTP_MAX_BODY_SIZE * 2); + byte[] data = body.getBytes("UTF-8"); + + String knownLengthResponse = postForBody(uri, new StringEntity(body)); + String chunkedResponse = postForBody(uri, + new InputStreamEntity(new ByteArrayInputStream(data), -1)); + + Assert.assertEquals(knownLengthResponse, chunkedResponse); + Assert.assertEquals(data.length, + JSONObject.parseObject(chunkedResponse).getIntValue("bytes")); + } finally { + service.stop(); + } + } + + @Test + public void testHttp10ContentLengthBoundaries() throws Exception { + String withinLimit = "POST / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 3\r\n\r\nabc"; + String response = sendRaw(httpServerUri, withinLimit); + Assert.assertTrue(response, response.startsWith("HTTP/1.1 200")); + Assert.assertTrue(response, response.contains("\"bytes\":3")); + + String body = repeat('a', HTTP_MAX_BODY_SIZE + 1); + String overLimit = "POST / HTTP/1.0\r\nHost: localhost\r\nContent-Length: " + + body.length() + "\r\n\r\n" + body; + response = sendRaw(httpServerUri, overLimit); + Assert.assertTrue(response, response.startsWith("HTTP/1.1 413")); + } + + @Test + public void testHttp10ChunkedRequestCompatibility() throws Exception { + String request = "POST / HTTP/1.0\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" + + "1\r\na\r\n0\r\n\r\n"; + String response = sendRaw(httpServerUri, request); + Assert.assertTrue(response, response.startsWith("HTTP/1.1 200")); + Assert.assertTrue(response, response.contains("\"bytes\":1")); + } + + @Test + public void testMalformedChunkFailsDuringBodyRead() throws Exception { + String request = "POST / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n" + + "Transfer-Encoding: chunked\r\n\r\nxyz\r\n"; + String response = sendRaw(httpServerUri, request); + Assert.assertTrue(response, response.startsWith("HTTP/1.1 200")); + Assert.assertTrue(response, response.contains("UncheckedIOException")); + Assert.assertTrue(response, response.contains("Early EOF")); + } + /** * For pure ASCII JSON (the normal TRON API case), wire bytes and * {@code body.getBytes().length} (what {@code Util.checkBodySize()} measures) @@ -366,6 +603,33 @@ public void testCheckBodySizeSafeDirectionWithNewlines() throws Exception { wireBytes, servletBytes, wireBytes - servletBytes); } + /** POSTs with a specific client chunk size and returns the response body. */ + private String postChunked(URI uri, byte[] data, int chunkSize) throws Exception { + HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); + try { + connection.setConnectTimeout(CLIENT_TIMEOUT_MILLIS); + connection.setReadTimeout(CLIENT_TIMEOUT_MILLIS); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setChunkedStreamingMode(chunkSize); + try (OutputStream output = connection.getOutputStream()) { + output.write(data); + } + Assert.assertEquals(200, connection.getResponseCode()); + ByteArrayOutputStream response = new ByteArrayOutputStream(); + try (InputStream input = connection.getInputStream()) { + byte[] buffer = new byte[4096]; + int read; + while ((read = input.read(buffer)) != -1) { + response.write(buffer, 0, read); + } + } + return new String(response.toByteArray(), StandardCharsets.UTF_8); + } finally { + connection.disconnect(); + } + } + /** POSTs with the given entity and returns the response body as a string. */ private String postForBody(URI uri, HttpEntity entity) throws Exception { HttpPost req = new HttpPost(uri); @@ -390,7 +654,9 @@ private static String repeat(char c, int n) { /** Sends a raw HTTP request over a socket and returns the full response (until EOF). */ private static String sendRaw(URI uri, String rawRequest) throws Exception { - try (Socket socket = new Socket(uri.getHost(), uri.getPort())) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(uri.getHost(), uri.getPort()), CLIENT_TIMEOUT_MILLIS); + socket.setSoTimeout(CLIENT_TIMEOUT_MILLIS); socket.getOutputStream().write(rawRequest.getBytes(StandardCharsets.US_ASCII)); socket.getOutputStream().flush(); ByteArrayOutputStream out = new ByteArrayOutputStream(); diff --git a/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java b/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java index 8b3463895f8..3a4aa89ac29 100644 --- a/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java +++ b/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java @@ -2023,7 +2023,7 @@ public void testWeb3ClientVersion() { } /** - * Verifies SizeLimitHandler integration with the real JsonRpcServlet + jsonrpc4j stack. + * Verifies request size limit integration with the real JsonRpcServlet + jsonrpc4j stack. * * Covers: normal request no regression, Content-Length oversized 413, * and chunked oversized 413 during streaming body reads.