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
- Redacts the API key in the `Authorization` header passed to request and response hooks. Hooks now receive only the last four characters of the key (eg: `Bearer ****WXYZ`) instead of the full key; the outgoing request is unchanged

## v8.8.0 (2026-06-25)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class RequestHookResponses {
/**
* RequestHookResponses constructor.
*
* @param headers The headers of the request.
* @param headers The headers of the request, with the API key redacted to its last four characters.
* @param method The HTTP method of the request.
* @param path The path of the request.
* @param requestBody The JSON object representing the request body.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class ResponseHookResponses {
* ResponseHookResponses constructor.
*
* @param httpStatus The HTTP status code of the response.
* @param headers The headers of the response.
* @param headers The headers of the request, with the API key redacted to its last four characters.
* @param method The HTTP method of the request.
* @param path The path of the request.
* @param responseBody The response body as a string.
Expand Down
34 changes: 33 additions & 1 deletion src/main/java/com/easypost/http/Requestor.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@

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 String API_KEY_REDACTION_MASK = "****";
private static final int API_KEY_VISIBLE_CHARACTERS = 4;

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 @@ -88,6 +90,36 @@
return headers;
}

/**
* Set the header passed to request and response hooks. This is a copy of the HTTP request header with the
* API key redacted to its last four characters, so hooks never receive the full API key.
*
* @param apiKey API of this HTTP request.
* @return HTTP header with the API key redacted.
* @throws MissingParameterError When the request fails.
*/
private static Map<String, String> generateHookHeaders(String apiKey) throws MissingParameterError {
Map<String, String> headers = generateHeaders(apiKey);
headers.put("Authorization", String.format("Bearer %s", redactApiKey(apiKey)));

return headers;
}

/**
* Redact an API key so only its last four characters are visible.
*
* @param apiKey API key to redact.
* @return Redacted API key.
*/
private static String redactApiKey(String apiKey) {
// Fully mask keys too short to hide most of their characters.
if (apiKey == null || apiKey.length() <= API_KEY_VISIBLE_CHARACTERS * 2) {
return API_KEY_REDACTION_MASK;
}

return API_KEY_REDACTION_MASK + apiKey.substring(apiKey.length() - API_KEY_VISIBLE_CHARACTERS);
}

/**
* Convert space to hyphen.
*
Expand Down Expand Up @@ -163,7 +195,7 @@
try {
output = conn.getOutputStream();
String jsonString = body.toString();
output.write(jsonString.getBytes(Constants.Http.CHARSET));

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

View workflow job for this annotation

GitHub Actions / coverage

[StringCharset] StringCharset

Check warning on line 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 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 198 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (16)

[StringCharset] StringCharset
} finally {
if (output != null) {
output.close();
Expand Down Expand Up @@ -576,7 +608,7 @@
}
Instant requestTimestamp = Instant.now();
UUID requestUuid = UUID.randomUUID();
Map<String, String> headers = generateHeaders(client.getApiKey());
Map<String, String> headers = generateHookHeaders(client.getApiKey());

RequestHookResponses requestResponse = new RequestHookResponses(headers, method.toString(), url, body,
requestTimestamp.toString(), requestUuid.toString());
Expand Down Expand Up @@ -757,7 +789,7 @@
int responseCode = (Integer) response.getClass().getDeclaredMethod("getResponseCode").invoke(response);
String responseBody = new String(
(byte[]) response.getClass().getDeclaredMethod("getContent").invoke(response),
Constants.Http.CHARSET);

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

View workflow job for this annotation

GitHub Actions / coverage

[StringCharset] StringCharset

Check warning on line 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 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 792 in src/main/java/com/easypost/http/Requestor.java

View workflow job for this annotation

GitHub Actions / build (16)

[StringCharset] StringCharset

return new EasyPostResponse(responseCode, responseBody);

Expand Down
93 changes: 93 additions & 0 deletions src/test/java/com/easypost/HookTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,26 @@
import com.easypost.exception.EasyPostException;
import com.easypost.hooks.RequestHookResponses;
import com.easypost.hooks.ResponseHookResponses;
import com.easypost.service.EasyPostClient;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import javax.net.ssl.HttpsURLConnection;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

public class HookTest {
Expand All @@ -30,6 +41,14 @@ public static void setup() throws EasyPostException {
vcr = new TestUtils.VCR("hook", TestUtils.ApiKey.TEST);
}

/**
* Clear the connection override after each test.
*/
@AfterEach
public void tearDown() {
EasyPost._vcrUrlFunction = null;
}

/**
* Test failing a hook if we subscribed to a request hook.
*
Expand Down Expand Up @@ -111,6 +130,42 @@ public static Object testResponseHooks(ResponseHookResponses data) {
return true;
}

/**
* Build a mocked connection that returns a successful Address response.
*
* @return HttpsURLConnection object.
* @throws IOException if the mock cannot be set up.
*/
private static HttpsURLConnection mockConnection() throws IOException {
byte[] body = "{\"id\": \"adr_123\", \"object\": \"Address\"}".getBytes(StandardCharsets.UTF_8);
HttpsURLConnection connection = Mockito.mock(HttpsURLConnection.class);
Mockito.when(connection.getResponseCode()).thenReturn(200);
Mockito.when(connection.getInputStream()).thenReturn(new ByteArrayInputStream(body));
return connection;
}

/**
* Make a request over a mocked connection and capture the headers passed to the request and response hooks.
*
* @param apiKey The API key to make the request with.
* @param connection The mocked connection to send the request over.
* @return The headers passed to the request hook, followed by the headers passed to the response hook.
* @throws EasyPostException when the request fails.
*/
private static List<Map<String, String>> captureHookHeaders(String apiKey, HttpsURLConnection connection)
throws EasyPostException {
EasyPost._vcrUrlFunction = url -> connection;
EasyPostClient client = new EasyPostClient(apiKey);
List<Map<String, String>> hookHeaders = new ArrayList<>();
client.subscribeToRequestHook(data -> hookHeaders.add(data.getHeaders()));
client.subscribeToResponseHook(data -> hookHeaders.add(data.getHeaders()));

client.address.retrieve("adr_123");

assertEquals(2, hookHeaders.size());
return hookHeaders;
}

/**
* Test creating a Parcel with request hook subscribed.
*
Expand Down Expand Up @@ -182,4 +237,42 @@ public void testResponseHookFiredOnHTTPError() throws EasyPostException {

assertTrue(hookHit);
}

/**
* Test that request and response hooks receive the API key redacted to its last four characters,
* while the real request still sends the full API key.
*
* @throws EasyPostException when the request fails.
* @throws IOException when the mock cannot be set up.
*/
@Test
public void testHooksReceiveRedactedApiKey() throws EasyPostException, IOException {
String apiKey = "EZTKfakeapikey12345WXYZ";
HttpsURLConnection connection = mockConnection();

for (Map<String, String> headers : captureHookHeaders(apiKey, connection)) {
assertEquals("Bearer ****WXYZ", headers.get("Authorization"));
assertFalse(headers.values().stream().anyMatch(value -> value.contains(apiKey)));
}

Mockito.verify(connection).setRequestProperty("Authorization", "Bearer " + apiKey);
}

/**
* Test that hooks receive a fully masked API key when the key is too short to partially reveal.
*
* @throws EasyPostException when the request fails.
* @throws IOException when the mock cannot be set up.
*/
@Test
public void testHooksFullyRedactShortApiKey() throws EasyPostException, IOException {
String apiKey = "short123";
HttpsURLConnection connection = mockConnection();

for (Map<String, String> headers : captureHookHeaders(apiKey, connection)) {
assertEquals("Bearer ****", headers.get("Authorization"));
}

Mockito.verify(connection).setRequestProperty("Authorization", "Bearer " + apiKey);
}
}
Loading