> 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");
+ }
+ }
+}