diff --git a/CHANGELOG.md b/CHANGELOG.md index 882708daa..27ed96f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Removes the deprecated, unusable `addCreditCardToUser` function - Stripe has disabled the ability to pass plain credit card details over the wire and now requires using [Stripe.js/Elements/Checkout](https://support.stripe.com/questions/card-tokenization-restrictions-using-publishable-keys). Follow the [Decentralized (EasyPost-Manage Billing) Guide](https://docs.easypost.com/guides/get-started-with-forge/easypost-managed-billing-guide#referralcustomer-billing-management) for more details on the new flow to use. - Makes `referralCustomer.retrieveEasypostStripeApiKey` public to help facilitate adding credit cards using Stripe.js +- Caps HTTP response bodies at 10 MB to prevent heap exhaustion from oversized responses. Responses that declare or stream more than this now throw an `HttpError` instead of being read into memory without limit ## v8.8.0 (2026-06-25) diff --git a/src/main/java/com/easypost/Constants.java b/src/main/java/com/easypost/Constants.java index cbd00f208..4653b7c44 100644 --- a/src/main/java/com/easypost/Constants.java +++ b/src/main/java/com/easypost/Constants.java @@ -39,6 +39,8 @@ public abstract static class ErrorMessages { public static final String API_DID_NOT_RETURN_ERROR_DETAILS = "API did not return error details."; public static final String WEBHOOK_DOES_NOT_MATCH = "Webhook received did not originate from EasyPost or had a webhook secret mismatch."; + public static final String RESPONSE_BODY_TOO_LARGE = + "The API response body exceeded the maximum allowed size of %d bytes and was not read."; public static final String NO_MORE_PAGES_TO_RETRIEVE = "There are no more pages to retrieve."; } @@ -74,6 +76,7 @@ public abstract static class Http { public static final String CHARSET = "UTF-8"; public static final int DEFAULT_CONNECT_TIMEOUT_MILLISECONDS = 30000; public static final int DEFAULT_READ_TIMEOUT_MILLISECONDS = 60000; + public static final int MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024; // 10 MB public static final Gson GSON = new GsonBuilder() // Standard model deserializer diff --git a/src/main/java/com/easypost/http/Requestor.java b/src/main/java/com/easypost/http/Requestor.java index 5c3592e7f..1c6265841 100644 --- a/src/main/java/com/easypost/http/Requestor.java +++ b/src/main/java/com/easypost/http/Requestor.java @@ -30,6 +30,7 @@ import lombok.Generated; import javax.net.ssl.HttpsURLConnection; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -47,7 +48,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Scanner; import java.util.UUID; public abstract class Requestor { @@ -60,6 +60,7 @@ public enum RequestMethod { private static final String DNS_CACHE_TTL_PROPERTY_NAME = "networkaddress.cache.ttl"; private static final String CUSTOM_URL_STREAM_HANDLER_PROPERTY_NAME = "com.easypost.net.customURLStreamHandler"; + private static final int RESPONSE_READ_BUFFER_SIZE = 8192; private static String urlEncodePair(final String key, final String value) throws UnsupportedEncodingException { return String.format("%s=%s", URLEncoder.encode(key, Constants.Http.CHARSET), @@ -318,22 +319,55 @@ private static Map flattenParams(final Map param } /** - * Get response body from the InputStream. + * Get response body from the InputStream, reading at most + * {@link Constants.Http#MAX_RESPONSE_BODY_BYTES} bytes. * * @param responseStream The InputStream from the response body. + * @param contentLength The declared Content-Length of the response, or -1 if unknown. * @return InputStream in string value. * @throws IOException When the request fails. + * @throws HttpError When the response body exceeds the maximum allowed size. */ - private static String getResponseBody(final InputStream responseStream) throws IOException { - if (responseStream.available() == 0) { - // Return empty string if the InputSteam is empty to avoid exceptions. + protected static String getResponseBody(final InputStream responseStream, final long contentLength) + throws IOException, HttpError { + if (responseStream == null) { return ""; } - @SuppressWarnings("resource") - String rBody = new Scanner(responseStream, Constants.Http.CHARSET).useDelimiter("\\A").next(); - responseStream.close(); - return rBody; + try { + // Reject a declared oversized body before reading anything. The limit is still enforced while + // reading below, since Content-Length may be missing or wrong. + if (contentLength > Constants.Http.MAX_RESPONSE_BODY_BYTES) { + throw responseBodyTooLargeError(); + } + + ByteArrayOutputStream body = new ByteArrayOutputStream(); + byte[] buffer = new byte[RESPONSE_READ_BUFFER_SIZE]; + long totalBytesRead = 0; + int bytesRead = responseStream.read(buffer); + while (bytesRead != -1) { + totalBytesRead += bytesRead; + if (totalBytesRead > Constants.Http.MAX_RESPONSE_BODY_BYTES) { + throw responseBodyTooLargeError(); + } + body.write(buffer, 0, bytesRead); + bytesRead = responseStream.read(buffer); + } + + return body.toString(Constants.Http.CHARSET); + } finally { + responseStream.close(); + } + } + + /** + * Build the error thrown when a response body exceeds the maximum allowed size. + * + * @return HttpError object. + */ + private static HttpError responseBodyTooLargeError() { + return new HttpError(String.format(Constants.ErrorMessages.RESPONSE_BODY_TOO_LARGE, + Constants.Http.MAX_RESPONSE_BODY_BYTES)); } /** @@ -377,9 +411,9 @@ private static EasyPostResponse makeURLConnectionRequest(final RequestMethod met if (rCode == HttpURLConnection.HTTP_NO_CONTENT) { rBody = ""; } else if (rCode >= HttpURLConnection.HTTP_OK && rCode < HttpURLConnection.HTTP_MULT_CHOICE) { - rBody = getResponseBody(conn.getInputStream()); + rBody = getResponseBody(conn.getInputStream(), conn.getContentLengthLong()); } else { - rBody = getResponseBody(conn.getErrorStream()); + rBody = getResponseBody(conn.getErrorStream(), conn.getContentLengthLong()); } return new EasyPostResponse(rCode, rBody); } catch (MissingParameterError e) { @@ -755,9 +789,11 @@ private static EasyPostResponse makeAppEngineRequest(final RequestMethod method, Object response = fetchMethod.invoke(urlFetchService, request); int responseCode = (Integer) response.getClass().getDeclaredMethod("getResponseCode").invoke(response); - String responseBody = new String( - (byte[]) response.getClass().getDeclaredMethod("getContent").invoke(response), - Constants.Http.CHARSET); + byte[] responseContent = (byte[]) response.getClass().getDeclaredMethod("getContent").invoke(response); + if (responseContent != null && responseContent.length > Constants.Http.MAX_RESPONSE_BODY_BYTES) { + throw responseBodyTooLargeError(); + } + String responseBody = new String(responseContent, Constants.Http.CHARSET); return new EasyPostResponse(responseCode, responseBody); diff --git a/src/test/java/com/easypost/RequestorTest.java b/src/test/java/com/easypost/RequestorTest.java new file mode 100644 index 000000000..9f0394042 --- /dev/null +++ b/src/test/java/com/easypost/RequestorTest.java @@ -0,0 +1,252 @@ +package com.easypost; + +import com.easypost.exception.API.HttpError; +import com.easypost.exception.EasyPostException; +import com.easypost.http.Requestor; +import com.easypost.model.Address; +import com.easypost.service.EasyPostClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import javax.net.ssl.HttpsURLConnection; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class RequestorTest extends Requestor { + private static final long MAX_BYTES = Constants.Http.MAX_RESPONSE_BODY_BYTES; + private static final String TOO_LARGE_MESSAGE = String.format(Constants.ErrorMessages.RESPONSE_BODY_TOO_LARGE, + Constants.Http.MAX_RESPONSE_BODY_BYTES); + + /** + * An InputStream that generates bytes on demand, so tests can simulate very large responses + * without allocating them up front. + */ + private static final class GeneratedInputStream extends InputStream { + private final long length; + private long bytesRead = 0; + private boolean closed = false; + + /** + * GeneratedInputStream constructor. + * + * @param length The number of bytes the stream will produce. + */ + GeneratedInputStream(final long length) { + this.length = length; + } + + @Override + public int read() { + if (bytesRead >= length) { + return -1; + } + bytesRead++; + return 'a'; + } + + @Override + public int read(final byte[] b, final int off, final int len) { + if (bytesRead >= length) { + return -1; + } + int count = (int) Math.min(len, length - bytesRead); + Arrays.fill(b, off, off + count, (byte) 'a'); + bytesRead += count; + return count; + } + + @Override + public void close() { + closed = true; + } + } + + /** + * Build a mocked connection that returns a 200 response with the given body and Content-Length. + * + * @param body The response body stream. + * @param contentLength The declared Content-Length, or -1 if unknown. + * @return HttpsURLConnection object. + * @throws IOException if the mock cannot be set up. + */ + private static HttpsURLConnection mockConnection(final InputStream body, final long contentLength) + throws IOException { + HttpsURLConnection connection = Mockito.mock(HttpsURLConnection.class); + Mockito.when(connection.getResponseCode()).thenReturn(200); + Mockito.when(connection.getContentLengthLong()).thenReturn(contentLength); + Mockito.when(connection.getInputStream()).thenReturn(body); + return connection; + } + + /** + * Clear the connection override after each test. + */ + @AfterEach + public void tearDown() { + EasyPost._vcrUrlFunction = null; + } + + /** + * Test reading a normal response body. + * + * @throws EasyPostException when the request fails. + * @throws IOException when the stream cannot be read. + */ + @Test + public void testGetResponseBody() throws EasyPostException, IOException { + String json = "{\"name\": \"Jürgen 中文\"}"; + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + + assertEquals(json, getResponseBody(new ByteArrayInputStream(bytes), bytes.length)); + } + + /** + * Test that a multi-byte UTF-8 character split across read chunks is decoded correctly. + * + * @throws EasyPostException when the request fails. + * @throws IOException when the stream cannot be read. + */ + @Test + public void testGetResponseBodyMultiByteCharacterAcrossReads() throws EasyPostException, IOException { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < 8191; i++) { + builder.append('a'); + } + builder.append('ü'); + String body = builder.toString(); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + + assertEquals(body, getResponseBody(new ByteArrayInputStream(bytes), -1)); + } + + /** + * Test that empty and missing response streams return an empty body. + * + * @throws EasyPostException when the request fails. + * @throws IOException when the stream cannot be read. + */ + @Test + public void testGetResponseBodyEmpty() throws EasyPostException, IOException { + assertEquals("", getResponseBody(new ByteArrayInputStream(new byte[0]), 0)); + assertEquals("", getResponseBody(null, -1)); + } + + /** + * Test that a response body exactly at the size limit is read. + * + * @throws EasyPostException when the request fails. + * @throws IOException when the stream cannot be read. + */ + @Test + public void testGetResponseBodyAtLimit() throws EasyPostException, IOException { + GeneratedInputStream stream = new GeneratedInputStream(MAX_BYTES); + + assertEquals(MAX_BYTES, getResponseBody(stream, MAX_BYTES).length()); + assertTrue(stream.closed); + } + + /** + * Test that an oversized Content-Length is rejected before any of the body is read. + */ + @Test + public void testGetResponseBodyRejectsOversizedContentLength() { + GeneratedInputStream stream = new GeneratedInputStream(MAX_BYTES + 1); + + HttpError error = assertThrows(HttpError.class, () -> getResponseBody(stream, MAX_BYTES + 1)); + + assertEquals(TOO_LARGE_MESSAGE, error.getMessage()); + assertEquals(0, stream.bytesRead); + assertTrue(stream.closed); + } + + /** + * Test that the size limit is enforced while streaming when Content-Length is missing. + */ + @Test + public void testGetResponseBodyRejectsOversizedBodyWithoutContentLength() { + GeneratedInputStream stream = new GeneratedInputStream(Long.MAX_VALUE); + + HttpError error = assertThrows(HttpError.class, () -> getResponseBody(stream, -1)); + + assertEquals(TOO_LARGE_MESSAGE, error.getMessage()); + assertTrue(stream.bytesRead < 2 * MAX_BYTES); + assertTrue(stream.closed); + } + + /** + * Test that the size limit is enforced while streaming when Content-Length understates the body size. + */ + @Test + public void testGetResponseBodyRejectsOversizedBodyWithWrongContentLength() { + GeneratedInputStream stream = new GeneratedInputStream(MAX_BYTES + 1); + + HttpError error = assertThrows(HttpError.class, () -> getResponseBody(stream, 100)); + + assertEquals(TOO_LARGE_MESSAGE, error.getMessage()); + assertTrue(stream.closed); + } + + /** + * Test that a normal API response is read and deserialized. + * + * @throws EasyPostException when the request fails. + * @throws IOException when the mock cannot be set up. + */ + @Test + public void testRequestReadsNormalResponse() throws EasyPostException, IOException { + byte[] body = "{\"id\": \"adr_123\", \"object\": \"Address\"}".getBytes(StandardCharsets.UTF_8); + HttpsURLConnection connection = mockConnection(new ByteArrayInputStream(body), -1); + EasyPost._vcrUrlFunction = url -> connection; + EasyPostClient client = new EasyPostClient("fake_api_key"); + + Address address = client.address.retrieve("adr_123"); + + assertEquals("adr_123", address.getId()); + } + + /** + * Test that an API request fails without reading the body when Content-Length exceeds the limit. + * + * @throws EasyPostException when the request fails. + * @throws IOException when the mock cannot be set up. + */ + @Test + public void testRequestRejectsOversizedContentLength() throws EasyPostException, IOException { + GeneratedInputStream stream = new GeneratedInputStream(MAX_BYTES + 1); + HttpsURLConnection connection = mockConnection(stream, MAX_BYTES + 1); + EasyPost._vcrUrlFunction = url -> connection; + EasyPostClient client = new EasyPostClient("fake_api_key"); + + HttpError error = assertThrows(HttpError.class, () -> client.address.retrieve("adr_123")); + + assertEquals(TOO_LARGE_MESSAGE, error.getMessage()); + assertEquals(0, stream.bytesRead); + } + + /** + * Test that an API request fails when an oversized body is streamed without a Content-Length. + * + * @throws EasyPostException when the request fails. + * @throws IOException when the mock cannot be set up. + */ + @Test + public void testRequestRejectsOversizedStreamedBody() throws EasyPostException, IOException { + GeneratedInputStream stream = new GeneratedInputStream(Long.MAX_VALUE); + HttpsURLConnection connection = mockConnection(stream, -1); + EasyPost._vcrUrlFunction = url -> connection; + EasyPostClient client = new EasyPostClient("fake_api_key"); + + HttpError error = assertThrows(HttpError.class, () -> client.address.retrieve("adr_123")); + + assertEquals(TOO_LARGE_MESSAGE, error.getMessage()); + assertTrue(stream.closed); + } +}