Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/easypost/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
}
Expand Down Expand Up @@ -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
Expand Down
64 changes: 50 additions & 14 deletions src/main/java/com/easypost/http/Requestor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -60,6 +60,7 @@

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),
Expand Down Expand Up @@ -163,7 +164,7 @@
try {
output = conn.getOutputStream();
String jsonString = body.toString();
output.write(jsonString.getBytes(Constants.Http.CHARSET));

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (10)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (14)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (18)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (9)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (8)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (16)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (12)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (24)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (22)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (17)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (19)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (11)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (15)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (23)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (25)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (21)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (13)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (20)

[StringCharset] StringCharset

Check warning on line 167 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / coverage

[StringCharset] StringCharset
} finally {
if (output != null) {
output.close();
Expand Down Expand Up @@ -318,22 +319,55 @@
}

/**
* 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));
}

/**
Expand Down Expand Up @@ -377,9 +411,9 @@
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) {
Expand Down Expand Up @@ -755,9 +789,11 @@
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);

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (10)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (14)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (18)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (9)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (8)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (16)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (12)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (24)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (22)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (17)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (19)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (11)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (15)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (23)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (25)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (21)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (13)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (20)

[StringCharset] StringCharset

Check warning on line 796 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / coverage

[StringCharset] StringCharset

return new EasyPostResponse(responseCode, responseBody);

Expand Down
252 changes: 252 additions & 0 deletions src/test/java/com/easypost/RequestorTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading