Skip to content

Commit 7af3224

Browse files
feat(bigquery-jdbc): respect standard JVM trustStore properties by default (#13435)
b/515129164 ### Problem In enterprise corporate networks (e.g. Zscaler), outbound HTTPS traffic is intercepted by transparent proxies doing SSL MITM (man-in-the-middle) decryption. The proxy signs its re-encrypted connections with dynamically generated root CA certificates. The BigQuery JDBC driver failed to establish TLS connections under these environments because the underlying client library ignored JVM trust stores (the standard Java `cacerts` file or custom system properties set via `-Djavax.net.ssl.trustStore`). This happened because when direct connections had empty SSL/proxy settings, the driver returned `null` for `HttpTransportOptions`. This fallback triggered classpath SPI overrides or legacy defaults which invoked `GoogleNetHttpTransport.newTrustedTransport()`. That convenience constructor hardcodes trust exclusively to a bundled `google.p12` keystore, completely overriding JVM system properties. ### Solution Simplified the driver's transport instantiation to align with the core Google Cloud Java SDK's network defaults: 1. **Direct Connections:** Modified `getHttpTransportOptions(...)` to unconditionally return a transport factory configured with a single `NetHttpTransport` instance (`new NetHttpTransport.Builder().build()`). This allows JSSE to handle TLS certificate validation using standard JVM system properties and `cacerts` natively. Bypassing the SPI loader prevents classpath hijacking. 2. **Explicit Proxy Connections:** Configured the Apache HTTP client builder inside `getHttpTransportFactory(...)` to unconditionally call `httpClientBuilder.useSystemProperties()`. This ensures that even when a proxy is set in the JDBC URL, Apache HttpClient still honors system-level properties like `-Djavax.net.ssl.trustStore`. ### Integration Testing: SSL/TLS Validation (`ITLocalSslValidationTest`) Added `ITLocalSslValidationTest.java` to validate the loading and enforcement of custom SSL truststore configurations end-to-end. * **Local Mock HTTPS Server:** Starts a lightweight local HTTPS server on a random port presenting a self-signed certificate. It mocks necessary BigQuery backend endpoints (`/queries` and `/jobs`) to satisfy basic driver query execution. * **Process Isolation:** Runs each connection check in a separate, isolated JVM subprocess via `ProcessBuilder`. This is required to bypass JSSE's JVM-wide caching of the `-Djavax.net.ssl.trustStore` property. * **Test Coverage:** * **Negative Case:** Verifies that connection attempts without a truststore fail with the expected `PKIX path building failed` handshake error (exit code `1`). * **Positive Case:** Verifies that connection attempts using our custom truststore (`localhost-truststore.jks`) succeed and complete query executions successfully (exit code `0`). * **CI Integration:** Added to `ITPresubmitTests` to run automatically on every pull request. Since it uses local mocks, it requires **no GCP credentials** and executes in **under 2 seconds**.
1 parent 5ff7a0f commit 7af3224

6 files changed

Lines changed: 285 additions & 11 deletions

File tree

java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtility.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import com.google.api.client.http.HttpTransport;
2222
import com.google.api.client.http.apache.v5.Apache5HttpTransport;
23+
import com.google.api.client.http.javanet.NetHttpTransport;
2324
import com.google.api.gax.rpc.TransportChannelProvider;
2425
import com.google.auth.http.HttpTransportFactory;
2526
import com.google.cloud.bigquery.exception.BigQueryJdbcRuntimeException;
@@ -58,6 +59,7 @@ final class BigQueryJdbcProxyUtility {
5859
new BigQueryJdbcCustomLogger(BigQueryJdbcProxyUtility.class.getName());
5960
static final String validPortRegex =
6061
"^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$";
62+
private static final HttpTransport DEFAULT_TRANSPORT = new NetHttpTransport.Builder().build();
6163

6264
private BigQueryJdbcProxyUtility() {}
6365

@@ -136,17 +138,14 @@ static HttpTransportOptions getHttpTransportOptions(
136138
boolean hasProxyOrSsl =
137139
proxyProperties.containsKey(BigQueryJdbcUrlUtility.PROXY_HOST_PROPERTY_NAME)
138140
|| sslTrustStorePath != null;
139-
boolean hasTimeoutConfig = connectTimeout != null || readTimeout != null;
140-
141-
if (!hasProxyOrSsl && !hasTimeoutConfig) {
142-
return null;
143-
}
144141

145142
HttpTransportOptions.Builder httpTransportOptionsBuilder = HttpTransportOptions.newBuilder();
146143
if (hasProxyOrSsl) {
147144
httpTransportOptionsBuilder.setHttpTransportFactory(
148145
getHttpTransportFactory(
149146
proxyProperties, sslTrustStorePath, sslTrustStorePassword, callerClassName));
147+
} else {
148+
httpTransportOptionsBuilder.setHttpTransportFactory(() -> DEFAULT_TRANSPORT);
150149
}
151150

152151
if (connectTimeout != null) {
@@ -178,9 +177,8 @@ private static HttpTransportFactory getHttpTransportFactory(
178177
HttpRoutePlanner httpRoutePlanner = new DefaultProxyRoutePlanner(proxyHostDetails);
179178
httpClientBuilder.setRoutePlanner(httpRoutePlanner);
180179
addAuthToProxyIfPresent(proxyProperties, httpClientBuilder, callerClassName);
181-
} else {
182-
httpClientBuilder.useSystemProperties();
183180
}
181+
httpClientBuilder.useSystemProperties();
184182

185183
if (sslTrustStorePath != null) {
186184
try (FileInputStream trustStoreStream = new FileInputStream(sslTrustStorePath)) {

java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtilityTest.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ public void testGetHttpTransportOptionsWithNonAuthenticatedProxy() {
161161
}
162162

163163
@Test
164-
public void testGetHttpTransportOptionsWithNoProxySettingsReturnsNull() {
164+
public void testGetHttpTransportOptionsWithNoProxySettingsReturnsDefaultOptions() {
165165
String connection_uri =
166166
"jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;"
167167
+ "ProjectId=TestProject"
@@ -172,7 +172,8 @@ public void testGetHttpTransportOptionsWithNoProxySettingsReturnsNull() {
172172
HttpTransportOptions result =
173173
BigQueryJdbcProxyUtility.getHttpTransportOptions(
174174
proxyProperties, null, null, null, null, "TestClass");
175-
assertNull(result);
175+
assertNotNull(result);
176+
assertNotNull(result.getHttpTransportFactory());
176177
}
177178

178179
private String getTestResourcePath(String resourceName) throws URISyntaxException {
@@ -299,11 +300,12 @@ public void testGetTransportChannelProvider_noProxyNoSsl_returnsNull() {
299300
}
300301

301302
@Test
302-
public void testGetHttpTransportOptions_noProxyNoSsl_returnsNull() {
303+
public void testGetHttpTransportOptions_noProxyNoSsl_returnsDefaultOptions() {
303304
HttpTransportOptions options =
304305
BigQueryJdbcProxyUtility.getHttpTransportOptions(
305306
Collections.<String, String>emptyMap(), null, null, null, null, "TestClass");
306-
assertNull(options);
307+
assertNotNull(options);
308+
assertNotNull(options.getHttpTransportFactory());
307309
}
308310

309311
@Test
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.bigquery.jdbc.it;
18+
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertFalse;
21+
import static org.junit.jupiter.api.Assertions.assertTrue;
22+
23+
import com.google.cloud.bigquery.jdbc.utils.URIBuilder;
24+
import com.google.common.io.CharStreams;
25+
import com.sun.net.httpserver.HttpsConfigurator;
26+
import com.sun.net.httpserver.HttpsParameters;
27+
import com.sun.net.httpserver.HttpsServer;
28+
import java.io.File;
29+
import java.io.InputStream;
30+
import java.io.InputStreamReader;
31+
import java.io.OutputStream;
32+
import java.net.InetSocketAddress;
33+
import java.net.URL;
34+
import java.nio.charset.StandardCharsets;
35+
import java.security.KeyStore;
36+
import java.sql.Connection;
37+
import java.sql.DriverManager;
38+
import java.sql.Statement;
39+
import java.util.ArrayList;
40+
import java.util.List;
41+
import java.util.concurrent.TimeUnit;
42+
import java.util.concurrent.TimeoutException;
43+
import javax.net.ssl.KeyManagerFactory;
44+
import javax.net.ssl.SSLContext;
45+
import javax.net.ssl.SSLParameters;
46+
import org.junit.jupiter.api.AfterAll;
47+
import org.junit.jupiter.api.BeforeAll;
48+
import org.junit.jupiter.api.Test;
49+
50+
public class ITLocalSslValidationTest {
51+
private static final String HOST = "localhost";
52+
private static final String PASSWORD = "changeit";
53+
private static final String KEYSTORE_RESOURCE = "/localhost-keystore.jks";
54+
private static final String TRUSTSTORE_RESOURCE = "/localhost-truststore.jks";
55+
private static final String SUCCESS_MARKER = "SUBPROCESS_RESULT: SUCCESS";
56+
private static final String FAILURE_MARKER_PREFIX = "SUBPROCESS_RESULT: FAILURE - ";
57+
private static final String PKIX_ERROR_MSG = "PKIX path building failed";
58+
59+
private static MockHttpsServer mockServer;
60+
private static int port;
61+
62+
public static class MockHttpsServer {
63+
private final HttpsServer server;
64+
65+
public MockHttpsServer(int port) throws Exception {
66+
server = HttpsServer.create(new InetSocketAddress(HOST, port), 0);
67+
SSLContext sslContext = SSLContext.getInstance("TLS");
68+
69+
KeyStore ks = KeyStore.getInstance("JKS");
70+
try (InputStream stream = getClass().getResourceAsStream(KEYSTORE_RESOURCE)) {
71+
if (stream == null) {
72+
throw new IllegalStateException(
73+
"Keystore resource " + KEYSTORE_RESOURCE + " not found on classpath!");
74+
}
75+
ks.load(stream, PASSWORD.toCharArray());
76+
}
77+
78+
KeyManagerFactory kmf =
79+
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
80+
kmf.init(ks, PASSWORD.toCharArray());
81+
82+
sslContext.init(kmf.getKeyManagers(), null, null);
83+
84+
server.setHttpsConfigurator(
85+
new HttpsConfigurator(sslContext) {
86+
@Override
87+
public void configure(HttpsParameters params) {
88+
try {
89+
SSLContext context = getSSLContext();
90+
SSLParameters sslParams = context.getDefaultSSLParameters();
91+
params.setSSLParameters(sslParams);
92+
} catch (Exception ex) {
93+
ex.printStackTrace();
94+
}
95+
}
96+
});
97+
98+
server.createContext(
99+
"/",
100+
exchange -> {
101+
String path = exchange.getRequestURI().getPath();
102+
String response;
103+
if (path.contains("/queries")) {
104+
response =
105+
"{\n"
106+
+ " \"kind\": \"bigquery#queryResponse\",\n"
107+
+ " \"jobComplete\": true,\n"
108+
+ " \"rows\": [],\n"
109+
+ " \"totalRows\": \"0\",\n"
110+
+ " \"schema\": {\n"
111+
+ " \"fields\": []\n"
112+
+ " }\n"
113+
+ "}";
114+
} else {
115+
response =
116+
"{\n"
117+
+ " \"kind\": \"bigquery#job\",\n"
118+
+ " \"status\": {\n"
119+
+ " \"state\": \"DONE\"\n"
120+
+ " },\n"
121+
+ " \"jobReference\": {\n"
122+
+ " \"projectId\": \"dummy\",\n"
123+
+ " \"jobId\": \"dummy-job\"\n"
124+
+ " },\n"
125+
+ " \"configuration\": {\n"
126+
+ " \"query\": {\n"
127+
+ " \"query\": \"SELECT 1\"\n"
128+
+ " }\n"
129+
+ " },\n"
130+
+ " \"statistics\": {\n"
131+
+ " \"query\": {\n"
132+
+ " \"statementType\": \"SELECT\"\n"
133+
+ " }\n"
134+
+ " }\n"
135+
+ "}";
136+
}
137+
byte[] responseBytes = response.getBytes(StandardCharsets.UTF_8);
138+
exchange.getResponseHeaders().set("Content-Type", "application/json");
139+
exchange.sendResponseHeaders(200, responseBytes.length);
140+
try (OutputStream os = exchange.getResponseBody()) {
141+
os.write(responseBytes);
142+
}
143+
});
144+
}
145+
146+
public void start() {
147+
server.start();
148+
}
149+
150+
public void stop() {
151+
server.stop(0);
152+
}
153+
154+
public int getPort() {
155+
return server.getAddress().getPort();
156+
}
157+
}
158+
159+
private static class ProcessResult {
160+
final int exitCode;
161+
final String stdout;
162+
163+
ProcessResult(int exitCode, String stdout) {
164+
this.exitCode = exitCode;
165+
this.stdout = stdout;
166+
}
167+
}
168+
169+
@BeforeAll
170+
public static void setUp() throws Exception {
171+
mockServer = new MockHttpsServer(0);
172+
mockServer.start();
173+
port = mockServer.getPort();
174+
}
175+
176+
@AfterAll
177+
public static void tearDown() {
178+
if (mockServer == null) {
179+
return;
180+
}
181+
mockServer.stop();
182+
}
183+
184+
private ProcessResult runSubprocess(String trustStore, String password) throws Exception {
185+
String javaHome = System.getProperty("java.home");
186+
String javaBin = javaHome + File.separator + "bin" + File.separator + "java";
187+
String classpath = System.getProperty("java.class.path");
188+
String className = ITLocalSslValidationTest.class.getCanonicalName();
189+
190+
List<String> command = new ArrayList<>();
191+
command.add(javaBin);
192+
if (trustStore != null) {
193+
command.add("-Djavax.net.ssl.trustStore=" + trustStore);
194+
}
195+
if (password != null) {
196+
command.add("-Djavax.net.ssl.trustStorePassword=" + password);
197+
}
198+
command.add("-cp");
199+
command.add(classpath);
200+
command.add(className);
201+
command.add(String.valueOf(port));
202+
203+
ProcessBuilder builder = new ProcessBuilder(command);
204+
builder.redirectErrorStream(true);
205+
Process process = builder.start();
206+
207+
String output = "";
208+
boolean finished = false;
209+
try {
210+
try (InputStreamReader reader =
211+
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
212+
output = CharStreams.toString(reader);
213+
}
214+
finished = process.waitFor(10, TimeUnit.SECONDS);
215+
if (!finished) {
216+
throw new TimeoutException("Subprocess timed out after 10 seconds");
217+
}
218+
int exitCode = process.exitValue();
219+
return new ProcessResult(exitCode, output);
220+
} finally {
221+
if (!finished && process.isAlive()) {
222+
process.destroyForcibly();
223+
}
224+
}
225+
}
226+
227+
@Test
228+
public void testDefaultSslFailsForSelfSigned() throws Exception {
229+
ProcessResult result = runSubprocess(null, null);
230+
assertEquals(1, result.exitCode, "Subprocess should fail. Output:\n" + result.stdout);
231+
assertTrue(result.stdout.contains(PKIX_ERROR_MSG));
232+
}
233+
234+
@Test
235+
public void testCustomTrustStoreSucceeds() throws Exception {
236+
URL trustStoreUrl = getClass().getResource(TRUSTSTORE_RESOURCE);
237+
if (trustStoreUrl == null) {
238+
throw new IllegalStateException(
239+
"Truststore resource " + TRUSTSTORE_RESOURCE + " not found on classpath!");
240+
}
241+
String trustStorePath = new File(trustStoreUrl.toURI()).getAbsolutePath();
242+
ProcessResult result = runSubprocess(trustStorePath, PASSWORD);
243+
244+
assertEquals(0, result.exitCode, "Subprocess failed. Output:\n" + result.stdout);
245+
assertTrue(result.stdout.contains(SUCCESS_MARKER));
246+
assertFalse(
247+
result.stdout.contains(PKIX_ERROR_MSG),
248+
"Handshake failed with SSL error: " + result.stdout);
249+
}
250+
251+
public static void main(String[] args) {
252+
int port = Integer.parseInt(args[0]);
253+
String baseUri = "jdbc:bigquery://https://" + HOST + ":" + port + ";";
254+
String url =
255+
new URIBuilder(baseUri)
256+
.append("EndpointOverrides", "BIGQUERY=https://" + HOST + ":" + port)
257+
.append("ProjectId", "dummy")
258+
.append("OAuthType", 2)
259+
.append("OAuthAccessToken", "dummy-token")
260+
.toString();
261+
try (Connection connection = DriverManager.getConnection(url);
262+
Statement statement = connection.createStatement()) {
263+
statement.execute("SELECT 1");
264+
System.out.println(SUCCESS_MARKER);
265+
System.exit(0);
266+
} catch (Throwable e) {
267+
System.out.println(FAILURE_MARKER_PREFIX + e.getMessage());
268+
e.printStackTrace();
269+
System.exit(1);
270+
}
271+
}
272+
}

java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/suites/ITPresubmitTests.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.google.cloud.bigquery.jdbc.it.ITConnectionTest;
2424
import com.google.cloud.bigquery.jdbc.it.ITDatabaseMetadataTest;
2525
import com.google.cloud.bigquery.jdbc.it.ITDriverTest;
26+
import com.google.cloud.bigquery.jdbc.it.ITLocalSslValidationTest;
2627
import com.google.cloud.bigquery.jdbc.it.ITResultSetMetadataTest;
2728
import com.google.cloud.bigquery.jdbc.it.ITStatementTest;
2829
import org.junit.platform.suite.api.SelectClasses;
@@ -37,6 +38,7 @@
3738
ITConnectionPoolingTest.class,
3839
ITDatabaseMetadataTest.class,
3940
ITDriverTest.class,
41+
ITLocalSslValidationTest.class,
4042
ITResultSetMetadataTest.class,
4143
ITStatementTest.class
4244
})
2.16 KB
Binary file not shown.
927 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)