diff --git a/CHANGELOG.md b/CHANGELOG.md index 7077d646..80815c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,23 @@ # DocuSign Java Client Changelog See [DocuSign Support Center](https://support.docusign.com/en/releasenotes/) for Product Release Notes. +## [v6.7.0] - eSignature API v2.1-25.4.01.00 - 2026-07-01 +### Changed + +- Added support for version v2.1-25.4.01.00 of the DocuSign ESignature API. +- Updated the SDK release version. + +### Security + +- Enforced TLS certificate validation and hostname verification by default using the system's default trust store. Previously, all certificates were trusted without validation. +- Enforced HTTPS-only base paths. `setBasePath()` and `setOAuthBasePath()` now reject `http://` URLs. Use `ApiClient.insecure()` for local testing with HTTP or self-signed certificates. +- Scoped proxy credentials to the configured proxy host and port. Added `setPerConnectionProxyAuth(true)` to opt in to per-connection proxy authentication, avoiding JVM-wide side effects. + +### Breaking Changes + +- `ApiClient(String basePath)` and `setBasePath(String)` throw `IllegalArgumentException` for `http://` URLs. Migrate to `ApiClient.insecure(basePath)`. +- Removed constructor overloads accepting `boolean perConnectionProxyAuth`. Use the standard constructor followed by `.setPerConnectionProxyAuth(true)`. + ## [v6.6.0] - eSignature API v2.1-25.4.01.00 - 2026-01-27 ### Changed - Added support for version v2.1-25.4.01.00 of the DocuSign ESignature API. diff --git a/README.md b/README.md index 0e8be825..bf8da589 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The Docusign SDK makes integrating Docusign into your apps and websites a seamle - [API Reference](#apiReference) - [Code Examples](#codeExamples) - [OAuth Implementations](#oauthImplementations) +- [Security](#security) - [Changelog](#changeLog) - [Support](#support) - [License](#license) @@ -57,7 +58,7 @@ This client SDK is provided as open source, which enables you to customize its f com.docusign docusign-esign-java - 6.6.0 + 6.7.0 ``` 8. If your project is still open, restart Eclipse. @@ -94,6 +95,53 @@ For details regarding which type of OAuth grant will work best for your Docusign For security purposes, Docusign recommends using the [Authorization Code Grant](https://developers.docusign.com/platform/auth/authcode/) flow. + +## Security + +This SDK enforces secure-by-default transport behavior: + +* **TLS certificate validation** is enforced by default using the system's trust store and default `HostnameVerifier`. +* **HTTPS-only base paths** — `setBasePath()` and the `ApiClient(String basePath)` constructor reject `http://` URLs by default. +* **OAuth base path validation** — `setOAuthBasePath()` also enforces HTTPS. +* **TLS 1.2 required** — The SDK fails fast if TLSv1.2 is not available on the JVM. + +### Testing with self-signed certificates or HTTP endpoints + +For local development or testing scenarios that require HTTP or self-signed certificates, use the explicit `ApiClient.insecure()` factory: + +```java +// HTTP endpoint for local testing +ApiClient client = ApiClient.insecure("http://localhost:8080/restapi"); + +// Self-signed certificate +ApiClient client = ApiClient.insecure("https://dev-server.local/restapi"); + +// Insecure client with default base path +ApiClient client = ApiClient.insecure(); +client.setBasePath("http://localhost:8080/restapi"); +``` + +> **Warning:** `ApiClient.insecure()` disables TLS certificate validation and hostname verification. Never use in production. + +### Proxy authentication + +By default, when proxy credentials are set via system properties (`https.proxyUser` / `https.proxyPassword`), the SDK configures a JVM-wide `Authenticator` scoped to the proxy host and port. This preserves backward compatibility with existing enterprise configurations. + +If you prefer per-connection proxy authentication (which avoids JVM-global side effects), enable it via the setter before the first request: + +```java +ApiClient client = new ApiClient(); +client.setPerConnectionProxyAuth(true); +``` + +Or use the convenience constructor: + +```java +ApiClient client = new ApiClient(true); +``` + +> **Note:** Per-connection proxy auth will become the default in a future major version. + ## Changelog You can refer to the complete changelog [here](https://github.com/docusign/docusign-esign-java-client/blob/master/CHANGELOG.md). diff --git a/pom.xml b/pom.xml index 01d405de..361b21c1 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ docusign-esign-java jar docusign-esign-java - 6.6.0 + 6.7.0 https://developers.docusign.com The official Docusign eSignature JAVA client is based on version 2.1 of the Docusign REST API and provides libraries for JAVA application integration. It is recommended that you use this version of the library for new development. @@ -52,7 +52,7 @@ com.diffplug.spotless spotless-maven-plugin - 2.12.1 + 2.43.0 @@ -66,7 +66,7 @@ - 1.7 + 1.21.0 diff --git a/src/main/java/com/docusign/esign/client/ApiClient.java b/src/main/java/com/docusign/esign/client/ApiClient.java index d010756b..def0011b 100644 --- a/src/main/java/com/docusign/esign/client/ApiClient.java +++ b/src/main/java/com/docusign/esign/client/ApiClient.java @@ -35,10 +35,14 @@ import java.nio.file.Files; import java.nio.file.StandardCopyOption; import org.glassfish.jersey.logging.LoggingFeature; +import java.security.KeyStore; import java.security.SecureRandom; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.security.NoSuchAlgorithmException; +import javax.net.ssl.TrustManagerFactory; +import java.util.Arrays; +import java.util.logging.Logger; import java.text.DateFormat; import java.util.*; import java.util.Map.Entry; @@ -54,6 +58,8 @@ **/ public class ApiClient { + private static final Logger logger = Logger.getLogger(ApiClient.class.getName()); + protected Map defaultHeaderMap = new HashMap(); // Rest API base path constants /** live/production base path. */ @@ -81,11 +87,30 @@ public class ApiClient { private final String HTTPS = "https://"; - /** - * ApiClient constructor. - * - **/ - public ApiClient() { + /** + * When false (the default), the SDK uses the system's default TrustManager and + * HostnameVerifier for secure HTTPS connections and enforces HTTPS-only base paths. + * When true, allows insecure connections: trusts all certificates, skips hostname + * verification, and allows HTTP base paths. Only enable for testing purposes. + */ + private boolean allowInsecureConnections = false; + + /** + * When true, proxy credentials from system properties are applied per-connection + * using the Proxy-Authorization header instead of the JVM-wide Authenticator.setDefault(). + * This avoids leaking credentials to unrelated HTTP clients in the same JVM. + * Default is false (JVM-wide Authenticator) for backward compatibility. + */ + private boolean perConnectionProxyAuth = false; + + private ApiClient(String basePath, boolean allowInsecureConnections, boolean perConnectionProxyAuth) { + this.allowInsecureConnections = allowInsecureConnections; + this.perConnectionProxyAuth = perConnectionProxyAuth; + if (basePath != null) { + validateBasePath(basePath); + this.basePath = basePath; + } + json = new JSON(); httpClient = buildHttpClient(debugging); @@ -93,16 +118,33 @@ public ApiClient() { String javaVersion = System.getProperty("java.version"); // Set default User-Agent. - setUserAgent("Swagger-Codegen/v2.1/6.6.0/Java/" + javaVersion); + setUserAgent("Swagger-Codegen/v2.1/6.7.0/Java/" + javaVersion); // Setup authentications (key: authentication name, value: authentication). authentications = new HashMap(); authentications.put("docusignAccessCode", new OAuth(httpClient)); - // Derive the OAuth base path from the Rest API base url this.deriveOAuthBasePathFromRestBasePath(); } + /** + * ApiClient constructor. + * + **/ + public ApiClient() { + this(null, false, false); + } + + /** + * ApiClient constructor. + * + * @param perConnectionProxyAuth true to use per-connection proxy authentication + * from construction time instead of the JVM-wide Authenticator + **/ + public ApiClient(boolean perConnectionProxyAuth) { + this(null, false, perConnectionProxyAuth); + } + /** * buildDefaultDateFormat method. * @@ -115,12 +157,11 @@ public static DateFormat buildDefaultDateFormat() { /** * ApiClient constructor. * - * @param basePath The base path to create the client with + * @param basePath The base path to create the client with. Must use HTTPS. + * @throws IllegalArgumentException if basePath uses HTTP **/ public ApiClient(String basePath) { - this(); - this.basePath = basePath; - this.deriveOAuthBasePathFromRestBasePath(); + this(basePath, false, false); } /** @@ -130,7 +171,7 @@ public ApiClient(String basePath) { * @param authNames The authentication names **/ public ApiClient(String oAuthBasePath, String[] authNames) { - this(); + this(null, false, false); this.setOAuthBasePath(oAuthBasePath); for(String authName : authNames) { Authentication auth; @@ -170,10 +211,45 @@ public ApiClient(String oAuthBasePath, String authName, String clientId, String } } + /** + * Creates an ApiClient with insecure connections enabled. + * This disables TLS certificate validation, hostname verification, and allows HTTP base paths. + *

+ * WARNING: Only use for local testing with self-signed certificates or HTTP endpoints. + * Never use in production. + *

+ * + * @return an ApiClient with insecure connections enabled + */ + public static ApiClient insecure() { + logger.warning("Insecure mode enabled - TLS certificate validation and hostname verification are disabled. Do not use in production."); + return new ApiClient(null, true, false); + } + + /** + * Creates an ApiClient with insecure connections enabled and the given base path. + * This disables TLS certificate validation, hostname verification, and allows HTTP base paths. + *

+ * WARNING: Only use for local testing with self-signed certificates or HTTP endpoints. + * Never use in production. + *

+ * + *

Example usage: + *

+   * ApiClient client = ApiClient.insecure("http://localhost:8080/restapi");
+   * 
+ * + * @param basePath The base path (HTTP or HTTPS) to create the client with. + * @return an ApiClient with insecure connections enabled and the given base path + */ + public static ApiClient insecure(String basePath) { + logger.warning("Insecure mode enabled - TLS certificate validation and hostname verification are disabled. Do not use in production."); + return new ApiClient(basePath, true, false); + } + /** * Build the Client used to make HTTP requests with the latest settings, * i.e. objectMapper and debugging. - * TODO: better to use the Builder Pattern? * @return API client */ public ApiClient rebuildHttpClient() { @@ -246,11 +322,30 @@ public String getBasePath() { * @return ApiClient */ public ApiClient setBasePath(String basePath) { + validateBasePath(basePath); this.basePath = basePath; this.deriveOAuthBasePathFromRestBasePath(); return this; } + /** + * Validates that the base path uses HTTPS protocol. + * HTTP connections are not allowed unless allowInsecureConnections is enabled. + * + * @param basePath The base path to validate + * @throws IllegalArgumentException if the base path uses HTTP and insecure connections are not allowed + */ + private void validateBasePath(String basePath) { + if (basePath != null && !allowInsecureConnections) { + String lowerCasePath = basePath.toLowerCase(); + if (lowerCasePath.startsWith("http://")) { + throw new IllegalArgumentException( + "HTTP connections are not allowed. Use HTTPS for secure connections. " + + "If you need HTTP for testing, use ApiClient.insecure(url)."); + } + } + } + /** * Gets the status code of the previous request. * @return Status code @@ -267,6 +362,48 @@ public Map> getResponseHeaders() { return responseHeaders; } + /** + * Returns whether insecure connections are allowed. + * @return true if insecure connections are allowed, false otherwise + */ + public boolean isAllowInsecureConnections() { + return allowInsecureConnections; + } + + /** + * Returns whether per-connection proxy authentication is enabled. + * When true, proxy credentials are applied per-connection using the Proxy-Authorization + * header instead of the JVM-wide Authenticator.setDefault(). + * @return true if per-connection proxy auth is enabled, false otherwise + */ + public boolean isPerConnectionProxyAuth() { + return perConnectionProxyAuth; + } + + /** + * Sets whether to use per-connection proxy authentication. + *

+ * By default (when false), proxy credentials from system properties (e.g. https.proxyUser) + * are configured JVM-wide via Authenticator.setDefault() with host/port scoping for + * backward compatibility. + *

+ * When set to true, proxy credentials are applied per-connection using the + * Proxy-Authorization header, which is safer and scoped to this client's connections only. + * This avoids leaking credentials to other HTTP clients running in the same JVM. + *

+ * Prefer the constructor overloads when this must be fixed at client construction time. + * If you use this setter, call it before the first request. + *

+ * Note: In a future major version, per-connection proxy auth will become the default. + * + * @param perConnectionProxyAuth true to use per-connection proxy auth instead of JVM-wide + * @return this ApiClient for method chaining + */ + public ApiClient setPerConnectionProxyAuth(boolean perConnectionProxyAuth) { + this.perConnectionProxyAuth = perConnectionProxyAuth; + return this; + } + /** * Get authentications (key: authentication name, value: authentication). * @return Map of authentication object @@ -618,8 +755,15 @@ private String getOAuthBasePath() { * Sets the OAuth base path. Values include {@link OAuth#PRODUCTION_OAUTH_BASEPATH}, {@link OAuth#DEMO_OAUTH_BASEPATH} and custom. * @param oAuthBasePath the new value for the OAuth base path * @return this instance of the ApiClient updated with the new OAuth base path + * @throws IllegalArgumentException if the OAuth base path uses HTTP and insecure connections are not allowed */ public ApiClient setOAuthBasePath(String oAuthBasePath) { + if (oAuthBasePath != null && !allowInsecureConnections + && oAuthBasePath.toLowerCase().startsWith("http://")) { + throw new IllegalArgumentException( + "HTTP connections are not allowed for OAuth base path. Use HTTPS for secure connections. " + + "If you need HTTP for testing, use ApiClient.insecure()."); + } this.oAuthBasePath = oAuthBasePath; return this; } @@ -1358,6 +1502,12 @@ public File prepareDownloadFile(Response response) throws IOException { */ public T invokeAPI(String path, String method, List queryParams, List collectionQueryParams, Object body, Map headerParams, Map formParams, String accept, String contentType, String[] authNames, GenericType returnType) throws ApiException { + // Defense-in-depth: ensure HTTPS is used unless insecure mode is explicitly enabled + if (!allowInsecureConnections && basePath != null && basePath.toLowerCase().startsWith("http://")) { + throw new ApiException(0, "HTTPS is required. The current basePath ('" + basePath + "') uses HTTP. " + + "Use an HTTPS URL or create the client via ApiClient.insecure() for testing."); + } + updateParamsForAuth(authNames, queryParams, headerParams); // Not using `.target(this.basePath).path(path)` below, @@ -1613,23 +1763,33 @@ protected Client buildHttpClient(boolean debugging) { throw new SecurityException("Docusign Java SDK requires TLSv1.2 Protocol"); } } catch (SecurityException se) { - System.err.println(se.getMessage()); + throw new RuntimeException("TLS requirement check failed: " + se.getMessage(), se); } catch (NoSuchAlgorithmException nsae) { - System.err.println(nsae.getMessage()); + throw new RuntimeException("TLS requirement check failed: " + nsae.getMessage(), nsae); } // Setup the SSLContext object to use for HTTPS connections to the API if (sslContext == null) { try { sslContext = SSLContext.getInstance("TLSv1.2"); - sslContext.init(null, new TrustManager[] { new SecureTrustManager() }, new SecureRandom()); + if (allowInsecureConnections) { + // Warning: This trusts all certificates - only use for testing + logger.warning("Building HTTP client with insecure SSL context - all certificates will be trusted."); + sslContext.init(null, new TrustManager[] { new InsecureTrustManager() }, new SecureRandom()); + } else { + // Use the system's default TrustManager for secure certificate validation + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init((KeyStore) null); // Use default system trust store + sslContext.init(null, tmf.getTrustManagers(), new SecureRandom()); + } } catch (final Exception ex) { - System.err.println("failed to initialize SSL context"); + throw new RuntimeException("Failed to initialize SSL context: " + ex.getMessage(), ex); } } clientConfig.connectorProvider(new ConnectorProvider() { Proxy p = null; + String proxyAuthHeader = null; /* * Returns whether the host is part of the list of hosts that should be accessed without going through the proxy @@ -1663,6 +1823,56 @@ private boolean isNonProxyHost(String host, String nonProxyHosts) { return false; } + /** + * Configures SSL and proxy authentication on the given connection. + */ + private HttpURLConnection configureConnection(HttpURLConnection connection, Proxy usedProxy) { + if ("https".equalsIgnoreCase(connection.getURL().getProtocol())) { + HttpsURLConnection httpsConn = (HttpsURLConnection) connection; + httpsConn.setSSLSocketFactory(sslContext.getSocketFactory()); + if (allowInsecureConnections) { + httpsConn.setHostnameVerifier(new InsecureHostnameVerifier()); + } + } + // Apply proxy credentials per-connection only when explicitly opted in + if (perConnectionProxyAuth && proxyAuthHeader != null && usedProxy != null && usedProxy != Proxy.NO_PROXY) { + connection.setRequestProperty("Proxy-Authorization", proxyAuthHeader); + } + return connection; + } + + /** + * Initializes proxy credentials from system properties. + * By default, configures JVM-wide Authenticator.setDefault() with host/port scoping + * for backward compatibility. When perConnectionProxyAuth is true, only uses + * per-connection Proxy-Authorization header. + */ + private void initProxyAuth(final String host, final Integer port, String user, String password) { + proxyAuthHeader = null; + if (user != null && password != null) { + if (!perConnectionProxyAuth) { + if (host != null && port != null) { + final String proxyHost = host; + final int proxyPort = port; + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if (getRequestorType() == RequestorType.PROXY + && getRequestingHost().equalsIgnoreCase(proxyHost) + && proxyPort == getRequestingPort()) { + return new PasswordAuthentication(user, password.toCharArray()); + } + return null; + } + }); + } + } else { + proxyAuthHeader = "Basic " + Base64.getEncoder().encodeToString( + (user + ":" + password).getBytes(StandardCharsets.UTF_8)); + } + } + } + @Override public Connector getConnector(Client client, jakarta.ws.rs.core.Configuration configuration) { HttpUrlConnectorProvider customConnProv = new HttpUrlConnectorProvider(); @@ -1674,55 +1884,34 @@ public HttpURLConnection getConnection(java.net.URL url) throws IOException { } if (isNonProxyHost(url.getHost(), System.getProperty("http.nonProxyHosts"))) { - HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(Proxy.NO_PROXY); - connection.setSSLSocketFactory(sslContext.getSocketFactory()); - return connection; + return configureConnection( + (HttpURLConnection) url.openConnection(Proxy.NO_PROXY), Proxy.NO_PROXY); } // set up the proxy/no-proxy settings if (p == null) { if (System.getProperty("https.proxyHost") != null) { // set up the proxy host and port - final String host = System.getProperty("https.proxyHost"); - final Integer port = Integer.getInteger("https.proxyPort"); - if (host != null && port != null) { - p = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port)); - } - // set up optional proxy authentication credentials - final String user = System.getProperty("https.proxyUser"); - final String password = System.getProperty("https.proxyPassword"); - if (user != null && password != null) { - Authenticator.setDefault(new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - if (getRequestorType() == RequestorType.PROXY && getRequestingHost().equalsIgnoreCase(host) && port == getRequestingPort()) { - return new PasswordAuthentication(user, password.toCharArray()); - } - return null; - } - }); - } + final String host = System.getProperty("https.proxyHost"); + final Integer port = Integer.getInteger("https.proxyPort"); + if (host != null && port != null) { + p = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port)); + } + // set up proxy authentication credentials using the configured mode + initProxyAuth(host, port, + System.getProperty("https.proxyUser"), + System.getProperty("https.proxyPassword")); } else if (System.getProperty("http.proxyHost") != null) { // set up the proxy host and port - final String host = System.getProperty("http.proxyHost"); - final Integer port = Integer.getInteger("http.proxyPort"); - if (host != null && port != null) { - p = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port)); - } - // set up optional proxy authentication credentials - final String user = System.getProperty("http.proxyUser"); - final String password = System.getProperty("http.proxyPassword"); - if (user != null && password != null) { - Authenticator.setDefault(new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - if (getRequestorType() == RequestorType.PROXY && getRequestingHost().equalsIgnoreCase(host) && port == getRequestingPort()) { - return new PasswordAuthentication(user, password.toCharArray()); - } - return null; - } - }); - } + final String host = System.getProperty("http.proxyHost"); + final Integer port = Integer.getInteger("http.proxyPort"); + if (host != null && port != null) { + p = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port)); + } + // set up proxy authentication credentials using the configured mode + initProxyAuth(host, port, + System.getProperty("http.proxyUser"), + System.getProperty("http.proxyPassword")); } // no-proxy fallback if the proxy settings are misconfigured in the system properties if (p == null) { @@ -1730,11 +1919,8 @@ protected PasswordAuthentication getPasswordAuthentication() { } } - HostnameVerifier allHostsValid = new InsecureHostnameVerifier(); - HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(p); - connection.setSSLSocketFactory(sslContext.getSocketFactory()); - connection.setHostnameVerifier(allHostsValid); - return connection; + return configureConnection( + (HttpURLConnection) url.openConnection(p), p); } }); return customConnProv.getConnector(client, configuration); @@ -1757,16 +1943,23 @@ public boolean verify(String hostname, SSLSession session) { } } - class SecureTrustManager implements X509TrustManager { + /** + * WARNING: This TrustManager trusts ALL certificates without validation. + * It should ONLY be used when allowInsecureConnections is explicitly enabled for testing. + * Using this in production is a critical security vulnerability. + */ + class InsecureTrustManager implements X509TrustManager { @Override public void checkClientTrusted(X509Certificate[] arg0, String arg1) throws CertificateException { + // WARNING: No validation - trusts all client certificates } @Override public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws CertificateException { + // WARNING: No validation - trusts all server certificates } @Override diff --git a/src/test/java/com/docusign/esign/client/ApiClientSecurityTests.java b/src/test/java/com/docusign/esign/client/ApiClientSecurityTests.java new file mode 100644 index 00000000..14499741 --- /dev/null +++ b/src/test/java/com/docusign/esign/client/ApiClientSecurityTests.java @@ -0,0 +1,313 @@ +package com.docusign.esign.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Security tests for ApiClient. + * Verifies secure-by-default behavior, insecure factory, and proxy auth + * controls. + */ +class ApiClientSecurityTests { + + private ApiClient client; + + @BeforeEach + void setUp() { + client = new ApiClient(); + } + + // ── Secure-by-default ────────────────────────────────────────────── + + @Nested + @DisplayName("Secure-by-default constructor") + class SecureDefaults { + + @Test + @DisplayName("Default client does not allow insecure connections") + void defaultClientIsSecure() { + assertFalse(client.isAllowInsecureConnections()); + } + + @Test + @DisplayName("Default base path uses HTTPS") + void defaultBasePathIsHttps() { + assertTrue(client.getBasePath().startsWith("https://"), + "Default base path must use HTTPS"); + } + } + + // ── setBasePath validation ───────────────────────────────────────── + + @Nested + @DisplayName("setBasePath HTTP rejection") + class SetBasePathValidation { + + @Test + @DisplayName("setBasePath rejects HTTP URL") + void rejectsHttp() { + assertThrows(IllegalArgumentException.class, + () -> client.setBasePath("http://localhost/restapi")); + } + + @Test + @DisplayName("setBasePath accepts HTTPS URL") + void acceptsHttps() { + assertDoesNotThrow( + () -> client.setBasePath("https://demo.docusign.net/restapi")); + } + + @Test + @DisplayName("setBasePath on insecure client accepts HTTP URL") + void insecureClientAcceptsHttp() { + ApiClient insecure = ApiClient.insecure(); + assertDoesNotThrow( + () -> insecure.setBasePath("http://localhost:8080/restapi")); + assertEquals("http://localhost:8080/restapi", insecure.getBasePath()); + } + + @Test + @DisplayName("setBasePath with perConnectionProxyAuth=true still rejects HTTP URL") + void perConnectionProxyAuthDoesNotBypassHttpsCheck() { + client.setPerConnectionProxyAuth(true); + assertThrows(IllegalArgumentException.class, + () -> client.setBasePath("http://localhost/restapi")); + } + + @Test + @DisplayName("setBasePath accepts null without throwing") + void acceptsNull() { + assertDoesNotThrow(() -> client.setBasePath(null)); + } + + @Test + @DisplayName("setBasePath rejects mixed-case HTTP scheme (HTTP://, Http://)") + void rejectsMixedCaseHttpScheme() { + assertThrows(IllegalArgumentException.class, + () -> client.setBasePath("HTTP://localhost/restapi")); + assertThrows(IllegalArgumentException.class, + () -> client.setBasePath("Http://localhost/restapi")); + } + } + + // ── setOAuthBasePath validation ──────────────────────────────────── + + @Nested + @DisplayName("setOAuthBasePath HTTP rejection") + class SetOAuthBasePathValidation { + + @Test + @DisplayName("setOAuthBasePath rejects HTTP URL") + void rejectsHttp() { + assertThrows(IllegalArgumentException.class, + () -> client.setOAuthBasePath("http://localhost")); + } + + @Test + @DisplayName("setOAuthBasePath accepts bare hostname (no scheme)") + void acceptsBareHostname() { + assertDoesNotThrow( + () -> client.setOAuthBasePath("account-d.docusign.com")); + } + + @Test + @DisplayName("setOAuthBasePath accepts HTTPS URL") + void acceptsHttps() { + assertDoesNotThrow( + () -> client.setOAuthBasePath("https://account-d.docusign.com")); + } + + @Test + @DisplayName("setOAuthBasePath accepts null without throwing") + void acceptsNull() { + assertDoesNotThrow(() -> client.setOAuthBasePath(null)); + } + + @Test + @DisplayName("setOAuthBasePath rejects mixed-case HTTP scheme (HTTP://, Http://)") + void rejectsMixedCaseHttpScheme() { + assertThrows(IllegalArgumentException.class, + () -> client.setOAuthBasePath("HTTP://localhost")); + assertThrows(IllegalArgumentException.class, + () -> client.setOAuthBasePath("Http://localhost")); + } + + @Test + @DisplayName("setOAuthBasePath on insecure client accepts HTTP URL and stores the value") + void insecureClientAcceptsHttp() throws Exception { + ApiClient insecure = ApiClient.insecure(); + assertDoesNotThrow(() -> insecure.setOAuthBasePath("http://localhost")); + + Field oAuthBasePathField = ApiClient.class.getDeclaredField("oAuthBasePath"); + oAuthBasePathField.setAccessible(true); + assertEquals("http://localhost", oAuthBasePathField.get(insecure)); + } + } + + // ── String constructor validation ────────────────────────────────── + + @Nested + @DisplayName("ApiClient(String) constructor HTTP rejection") + class ConstructorValidation { + + @Test + @DisplayName("ApiClient(String) rejects HTTP URL") + void rejectsHttp() { + assertThrows(IllegalArgumentException.class, + () -> new ApiClient("http://localhost/restapi")); + } + + @Test + @DisplayName("ApiClient(String) accepts HTTPS URL") + void acceptsHttps() { + assertDoesNotThrow( + () -> new ApiClient("https://demo.docusign.net/restapi")); + } + } + + // ── insecure() factory ───────────────────────────────────────────── + + @Nested + @DisplayName("ApiClient.insecure() factory") + class InsecureFactory { + + @Test + @DisplayName("insecure() enables insecure connections") + void insecureFactoryEnablesFlag() { + ApiClient insecure = ApiClient.insecure(); + assertTrue(insecure.isAllowInsecureConnections()); + } + + @Test + @DisplayName("insecure(basePath) allows HTTP URL") + void insecureFactoryAllowsHttp() { + ApiClient insecure = ApiClient.insecure("http://localhost:8080/restapi"); + assertTrue(insecure.isAllowInsecureConnections()); + assertEquals("http://localhost:8080/restapi", insecure.getBasePath()); + } + + @Test + @DisplayName("insecure(basePath) allows HTTPS URL") + void insecureFactoryAllowsHttps() { + ApiClient insecure = ApiClient.insecure("https://demo.docusign.net/restapi"); + assertTrue(insecure.isAllowInsecureConnections()); + assertEquals("https://demo.docusign.net/restapi", insecure.getBasePath()); + } + + @Test + @DisplayName("insecure() preserves backward-compatible JVM-wide proxy auth default") + void insecureFactoryKeepsProxyAuthDefault() { + ApiClient insecure = ApiClient.insecure(); + assertFalse(insecure.isPerConnectionProxyAuth()); + } + + @Test + @DisplayName("insecure(null) does not throw") + void insecureWithNullBasePathDoesNotThrow() { + ApiClient insecure = assertDoesNotThrow(() -> ApiClient.insecure(null)); + assertTrue(insecure.isAllowInsecureConnections()); + } + } + + // ── perConnectionProxyAuth ───────────────────────────────────────── + + @Nested + @DisplayName("perConnectionProxyAuth backward compatibility and opt-in paths") + class PerConnectionProxyAuth { + + @Test + @DisplayName("Default is false (JVM-wide Authenticator for backward compat)") + void defaultIsFalse() { + assertFalse(client.isPerConnectionProxyAuth()); + } + + @Test + @DisplayName("Setter enables per-connection proxy auth") + void canBeEnabledViaSetter() { + client.setPerConnectionProxyAuth(true); + assertTrue(client.isPerConnectionProxyAuth()); + } + + @Test + @DisplayName("Setter returns ApiClient for chaining") + void returnsClientForChaining() { + ApiClient result = client.setPerConnectionProxyAuth(true); + assertSame(client, result); + } + + @Test + @DisplayName("ApiClient(boolean) constructor enables per-connection proxy auth at construction time") + void booleanConstructorEnablesPerConnectionProxyAuth() { + ApiClient proxyScopedClient = new ApiClient(true); + + assertTrue(proxyScopedClient.isPerConnectionProxyAuth()); + assertFalse(proxyScopedClient.isAllowInsecureConnections()); + assertTrue(proxyScopedClient.getBasePath().startsWith("https://"), + "Construction-time proxy auth opt-in must keep secure defaults"); + } + + @Test + @DisplayName("ApiClient(boolean) constructor with false is equivalent to default constructor") + void booleanConstructorFalseMatchesDefault() { + ApiClient explicit = new ApiClient(false); + + assertFalse(explicit.isPerConnectionProxyAuth()); + assertFalse(explicit.isAllowInsecureConnections()); + } + + @Test + @DisplayName("Enabling per-connection proxy auth via setter on OAuth-constructed client works") + void oauthConstructorPlusSetterEnablesPerConnectionProxyAuth() { + ApiClient proxyScopedClient = new ApiClient("account-d.docusign.com", new String[] { "docusignApiKey" }); + proxyScopedClient.setPerConnectionProxyAuth(true); + + assertTrue(proxyScopedClient.isPerConnectionProxyAuth()); + assertFalse(proxyScopedClient.isAllowInsecureConnections()); + } + } + + // ── invokeAPI defense-in-depth ───────────────────────────────────── + + @Nested + @DisplayName("invokeAPI defense-in-depth HTTPS guard") + class InvokeApiGuard { + + @Test + @DisplayName("invokeAPI rejects HTTP basePath even if field is set directly via reflection") + void rejectsHttpViaReflection() throws Exception { + // Bypass setBasePath validation via reflection to simulate a + // scenario where basePath ends up as HTTP (e.g. deserialization) + Field basePathField = ApiClient.class.getDeclaredField("basePath"); + basePathField.setAccessible(true); + basePathField.set(client, "http://evil.example.com/restapi"); + + ApiException ex = assertThrows(ApiException.class, + () -> client.invokeAPI("/test", "GET", + null, null, null, + new java.util.HashMap<>(), new java.util.HashMap<>(), + "application/json", "application/json", + new String[] {}, null)); + + assertTrue(ex.getMessage().contains("HTTPS is required"), + "Expected HTTPS-required message but got: " + ex.getMessage()); + } + + @Test + @DisplayName("insecure client guard flag is false — invokeAPI HTTPS check will not fire") + void insecureClientGuardFlagIsFalse() throws Exception { + ApiClient insecure = ApiClient.insecure(); + Field basePathField = ApiClient.class.getDeclaredField("basePath"); + basePathField.setAccessible(true); + basePathField.set(insecure, "http://evil.example.com/restapi"); + + assertTrue(insecure.isAllowInsecureConnections(), + "allowInsecureConnections must be true so the HTTPS guard condition evaluates to false"); + } + } +}