diff --git a/docker-compose.yaml b/docker-compose.yaml index 1e3c816..ef290ac 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,7 +7,7 @@ services: - .:/app working_dir: /app # Run only unit tests (no real credentials). Full suite: mvn test -q with env set. - command: mvn test -Dtest=RevokeTokenInputTest,AuthClientTest,AwsAuthProviderTest,ProjectsClientTest + command: mvn test -Dtest=RevokeTokenInputTest,AuthClientTest,AwsAuthProviderTest,ProjectsClientTest,ApiClientTest format: image: cimg/openjdk:21.0 diff --git a/pom.xml b/pom.xml index ac061ec..a8833c9 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,8 @@ - 2.7.5 + + 4.12.0 2.8.9 1.8.3 @@ -108,17 +109,11 @@ - com.squareup.okhttp + com.squareup.okhttp3 okhttp ${okhttp-version} - - com.squareup.okhttp - logging-interceptor - ${okhttp-version} - - com.google.code.gson @@ -139,6 +134,18 @@ true + + com.squareup.okhttp3 + mockwebserver + ${okhttp-version} + test + + + com.squareup.okhttp3 + okhttp-tls + ${okhttp-version} + test + org.junit.jupiter junit-jupiter-engine diff --git a/src/main/java/com/infisical/sdk/api/ApiClient.java b/src/main/java/com/infisical/sdk/api/ApiClient.java index ae9e5cb..0d49087 100644 --- a/src/main/java/com/infisical/sdk/api/ApiClient.java +++ b/src/main/java/com/infisical/sdk/api/ApiClient.java @@ -2,8 +2,8 @@ import com.google.gson.Gson; import com.infisical.sdk.util.InfisicalException; -import com.squareup.okhttp.*; -import com.squareup.okhttp.Request; +import okhttp3.*; +import okhttp3.Request; import java.io.IOException; import java.util.Collections; import java.util.Map; @@ -14,7 +14,7 @@ public class ApiClient { private String accessToken; private String baseUrl; - private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); public ApiClient(String baseUrl, String accessToken) { this.client = new OkHttpClient(); @@ -77,7 +77,7 @@ public R post(String url, T requestBody, Class responseType) throws In // Build request Request.Builder requestBuilder = new Request.Builder() .url(url) - .post(RequestBody.create(JSON, jsonBody)) + .post(RequestBody.create(jsonBody, JSON)) .header("Accept", "application/json"); if (this.accessToken != null && !this.accessToken.isEmpty()) { @@ -119,7 +119,8 @@ public R post(String url, Class responseType) throws InfisicalException { public R get(String baseUrl, Map queryParams, Class responseType) throws InfisicalException { try { - HttpUrl.Builder urlBuilder = HttpUrl.parse(baseUrl).newBuilder(); + // get() throws on a malformed URL rather than returning null + HttpUrl.Builder urlBuilder = HttpUrl.get(baseUrl).newBuilder(); if (queryParams != null) { queryParams.forEach(urlBuilder::addQueryParameter); @@ -163,7 +164,7 @@ public R patch(String url, T requestBody, Class responseType) // Build request Request.Builder requestBuilder = new Request.Builder() .url(url) - .patch(RequestBody.create(JSON, jsonBody)) + .patch(RequestBody.create(jsonBody, JSON)) .header("Accept", "application/json"); if (this.accessToken != null && !this.accessToken.isEmpty()) { @@ -199,7 +200,7 @@ public R delete(String url, T requestBody, Class responseType) // Build request Request.Builder requestBuilder = new Request.Builder() .url(url) - .delete(RequestBody.create(JSON, jsonBody)) + .delete(RequestBody.create(jsonBody, JSON)) .header("Accept", "application/json"); if (this.accessToken != null && !this.accessToken.isEmpty()) { diff --git a/src/test/java/com/infisical/sdk/api/ApiClientTest.java b/src/test/java/com/infisical/sdk/api/ApiClientTest.java new file mode 100644 index 0000000..55b72bb --- /dev/null +++ b/src/test/java/com/infisical/sdk/api/ApiClientTest.java @@ -0,0 +1,214 @@ +package com.infisical.sdk.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.infisical.sdk.util.InfisicalException; +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.net.ssl.SSLPeerUnverifiedException; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okhttp3.tls.HandshakeCertificates; +import okhttp3.tls.HeldCertificate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class ApiClientTest { + + private MockWebServer server; + + public static class Payload { + private final String name; + + public Payload(String name) { + this.name = name; + } + } + + public static class Reply { + private String message; + + public String getMessage() { + return message; + } + } + + @BeforeEach + public void startServer() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + public void stopServer() throws IOException { + server.shutdown(); + } + + private ApiClient apiClient(String accessToken) { + return new ApiClient(server.url("/").toString(), accessToken); + } + + @Test + public void post_sendsJsonBodyWithAuthHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"message\":\"created\"}")); + + Reply reply = apiClient("token-123") + .post(server.url("/api/v3/secrets/raw").toString(), new Payload("SECRET"), Reply.class); + + assertEquals("created", reply.getMessage()); + + RecordedRequest request = server.takeRequest(); + assertEquals("POST", request.getMethod()); + assertEquals("/api/v3/secrets/raw", request.getPath()); + assertEquals("Bearer token-123", request.getHeader("Authorization")); + assertEquals("application/json", request.getHeader("Accept")); + assertEquals("application/json; charset=utf-8", request.getHeader("Content-Type")); + assertEquals("{\"name\":\"SECRET\"}", request.getBody().readUtf8()); + } + + @Test + public void get_appendsQueryParametersAndAuthHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"message\":\"ok\"}")); + + Map queryParams = new LinkedHashMap<>(); + queryParams.put("workspaceId", "project-1"); + queryParams.put("environment", "dev"); + + apiClient("token-123").get(server.url("/api/v3/secrets/raw").toString(), queryParams, Reply.class); + + RecordedRequest request = server.takeRequest(); + assertEquals("GET", request.getMethod()); + + HttpUrl url = request.getRequestUrl(); + assertEquals("/api/v3/secrets/raw", url.encodedPath()); + assertEquals("project-1", url.queryParameter("workspaceId")); + assertEquals("dev", url.queryParameter("environment")); + assertEquals("Bearer token-123", request.getHeader("Authorization")); + } + + @Test + public void patch_usesPatchVerbAndSendsBody() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"message\":\"updated\"}")); + + Reply reply = apiClient("token-123") + .patch(server.url("/api/v3/secrets/raw/SECRET").toString(), new Payload("SECRET"), Reply.class); + + assertEquals("updated", reply.getMessage()); + + RecordedRequest request = server.takeRequest(); + assertEquals("PATCH", request.getMethod()); + assertEquals("{\"name\":\"SECRET\"}", request.getBody().readUtf8()); + } + + @Test + public void delete_usesDeleteVerbAndSendsBody() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"message\":\"deleted\"}")); + + Reply reply = apiClient("token-123") + .delete(server.url("/api/v3/secrets/raw/SECRET").toString(), new Payload("SECRET"), Reply.class); + + assertEquals("deleted", reply.getMessage()); + + RecordedRequest request = server.takeRequest(); + assertEquals("DELETE", request.getMethod()); + assertEquals("{\"name\":\"SECRET\"}", request.getBody().readUtf8()); + } + + @Test + public void post_withoutAccessToken_omitsAuthorizationHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"message\":\"ok\"}")); + + apiClient(null) + .post(server.url("/api/v1/auth/universal-auth/login").toString(), new Payload("id"), Reply.class); + + assertNull(server.takeRequest().getHeader("Authorization")); + } + + @Test + public void post_withEmptyResponseBody_returnsNull() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + Void result = apiClient("token-123") + .post(server.url("/api/v1/auth/token/revoke").toString(), new Payload("token"), Void.class); + + assertNull(result); + } + + @Test + public void errorResponse_throwsInfisicalExceptionCarryingResponseBody() { + server.enqueue( + new MockResponse().setResponseCode(403).setBody("{\"message\":\"permission denied\"}")); + + InfisicalException exception = assertThrows( + InfisicalException.class, + () -> apiClient("token-123") + .get(server.url("/api/v3/secrets/raw").toString(), Collections.emptyMap(), Reply.class)); + + assertTrue(exception.getMessage().contains("permission denied"), exception.getMessage()); + } + + @Test + public void get_withMalformedBaseUrl_throwsInsteadOfNullPointer() { + assertThrows( + IllegalArgumentException.class, + () -> apiClient("token-123").get("not-a-url", Collections.emptyMap(), Reply.class)); + } + + // CVE-2021-0341 guard. Builds its own client, since ApiClient cannot be given the test CA. + @Test + public void https_certificateIssuedForAnotherHost_isRejected() throws Exception { + HeldCertificate rootCertificate = new HeldCertificate.Builder().certificateAuthority(0).build(); + HeldCertificate serverCertificate = new HeldCertificate.Builder() + .commonName("example.com") + .addSubjectAlternativeName("example.com") + .signedBy(rootCertificate) + .build(); + + HandshakeCertificates serverCertificates = new HandshakeCertificates.Builder() + .heldCertificate(serverCertificate, rootCertificate.certificate()) + .build(); + server.useHttps(serverCertificates.sslSocketFactory(), false); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"message\":\"ok\"}")); + + HandshakeCertificates clientCertificates = new HandshakeCertificates.Builder() + .addTrustedCertificate(rootCertificate.certificate()) + .build(); + OkHttpClient client = new OkHttpClient.Builder() + .sslSocketFactory(clientCertificates.sslSocketFactory(), clientCertificates.trustManager()) + .build(); + + Request request = new Request.Builder().url(server.url("/api/v3/secrets/raw")).build(); + + assertThrows(SSLPeerUnverifiedException.class, () -> client.newCall(request).execute()); + } + + @Test + public void https_untrustedCertificate_failsBeforeTheRequestIsSent() throws Exception { + HeldCertificate serverCertificate = new HeldCertificate.Builder() + .commonName("localhost") + .addSubjectAlternativeName("localhost") + .build(); + HandshakeCertificates serverCertificates = new HandshakeCertificates.Builder() + .heldCertificate(serverCertificate) + .build(); + server.useHttps(serverCertificates.sslSocketFactory(), false); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"message\":\"ok\"}")); + + assertThrows( + InfisicalException.class, + () -> apiClient("token-123") + .get(server.url("/api/v3/secrets/raw").toString(), Collections.emptyMap(), Reply.class)); + + assertEquals(0, server.getRequestCount()); + } +}