From b10b2123351a4088553da3c9739260a091b50b17 Mon Sep 17 00:00:00 2001 From: msuitcase <97645156+msuitcase@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:33:26 -0700 Subject: [PATCH 1/2] fix: cap HTTP response body reads to prevent heap exhaustion Requestor read entire response bodies into memory with no size limit, so a compromised upstream, misbehaving proxy, or MITM returning a very large body could exhaust the JVM heap. Response bodies are now capped at 10 MB. A Content-Length above the cap is rejected before anything is read, and the cap is also enforced while streaming in case the header is missing or wrong. Oversized responses throw an HttpError with a descriptive message. The App Engine fallback path applies the same cap. SEC-787 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- src/main/java/com/easypost/Constants.java | 3 + .../java/com/easypost/http/Requestor.java | 64 ++++- src/test/java/com/easypost/RequestorTest.java | 252 ++++++++++++++++++ 4 files changed, 308 insertions(+), 16 deletions(-) create mode 100644 src/test/java/com/easypost/RequestorTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 882708daa..45d88bb8d 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) @@ -477,7 +478,7 @@ See our [Upgrade Guide](UPGRADE_GUIDE.md#upgrading-from-4x-to-50) for more detai ## v3.0.1 (2016-08-19) -- Removed some CRUD methods that are not (and never were) valid +- Added delete() to Users (for children only) ## v3.0.0 (2016-07-25) @@ -566,7 +567,7 @@ See our [Upgrade Guide](UPGRADE_GUIDE.md#upgrading-from-4x-to-50) for more detai ## v2.0.13 (2015-04-07) - Fixed Address createAndVerify method -- Added Address verifyWithCarrier and createAndVerifyWithCarrier methods +- Added Address verifyWithCarrier and createAndVerify methods ## v2.0.12 (2015-03-03) 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); + } +} From 6ea8f75c8235f4abbdf3cd217ac663376355a0e4 Mon Sep 17 00:00:00 2001 From: msuitcase <97645156+msuitcase@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:36:17 -0700 Subject: [PATCH 2/2] docs: restore unintentionally edited historical CHANGELOG entries Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d88bb8d..27ed96f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -478,7 +478,7 @@ See our [Upgrade Guide](UPGRADE_GUIDE.md#upgrading-from-4x-to-50) for more detai ## v3.0.1 (2016-08-19) -- Added delete() to Users (for children only) +- Removed some CRUD methods that are not (and never were) valid ## v3.0.0 (2016-07-25) @@ -567,7 +567,7 @@ See our [Upgrade Guide](UPGRADE_GUIDE.md#upgrading-from-4x-to-50) for more detai ## v2.0.13 (2015-04-07) - Fixed Address createAndVerify method -- Added Address verifyWithCarrier and createAndVerify methods +- Added Address verifyWithCarrier and createAndVerifyWithCarrier methods ## v2.0.12 (2015-03-03)