diff --git a/CHANGELOG.md b/CHANGELOG.md index 882708daa..bc1da1c0b 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 +- 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) diff --git a/src/main/java/com/easypost/hooks/RequestHookResponses.java b/src/main/java/com/easypost/hooks/RequestHookResponses.java index 966408ad2..b49103298 100644 --- a/src/main/java/com/easypost/hooks/RequestHookResponses.java +++ b/src/main/java/com/easypost/hooks/RequestHookResponses.java @@ -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. diff --git a/src/main/java/com/easypost/hooks/ResponseHookResponses.java b/src/main/java/com/easypost/hooks/ResponseHookResponses.java index 8c4e65a65..4c4e43648 100644 --- a/src/main/java/com/easypost/hooks/ResponseHookResponses.java +++ b/src/main/java/com/easypost/hooks/ResponseHookResponses.java @@ -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. diff --git a/src/main/java/com/easypost/http/Requestor.java b/src/main/java/com/easypost/http/Requestor.java index 5c3592e7f..e6a08261d 100644 --- a/src/main/java/com/easypost/http/Requestor.java +++ b/src/main/java/com/easypost/http/Requestor.java @@ -60,6 +60,8 @@ 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 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), @@ -88,6 +90,36 @@ private static Map generateHeaders(String apiKey) throws Missing 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 generateHookHeaders(String apiKey) throws MissingParameterError { + Map 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. * @@ -576,7 +608,7 @@ private static T httpRequest(final RequestMethod method, final String url, f } Instant requestTimestamp = Instant.now(); UUID requestUuid = UUID.randomUUID(); - Map headers = generateHeaders(client.getApiKey()); + Map headers = generateHookHeaders(client.getApiKey()); RequestHookResponses requestResponse = new RequestHookResponses(headers, method.toString(), url, body, requestTimestamp.toString(), requestUuid.toString()); diff --git a/src/test/java/com/easypost/HookTest.java b/src/test/java/com/easypost/HookTest.java index 3f275750f..9ad9cd9dd 100644 --- a/src/test/java/com/easypost/HookTest.java +++ b/src/test/java/com/easypost/HookTest.java @@ -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 { @@ -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. * @@ -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> captureHookHeaders(String apiKey, HttpsURLConnection connection) + throws EasyPostException { + EasyPost._vcrUrlFunction = url -> connection; + EasyPostClient client = new EasyPostClient(apiKey); + List> 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. * @@ -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 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 headers : captureHookHeaders(apiKey, connection)) { + assertEquals("Bearer ****", headers.get("Authorization")); + } + + Mockito.verify(connection).setRequestProperty("Authorization", "Bearer " + apiKey); + } }