diff --git a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtility.java b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtility.java index 7c495e801537..983eda9760f8 100644 --- a/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtility.java +++ b/java-bigquery-jdbc/src/main/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtility.java @@ -20,6 +20,7 @@ import com.google.api.client.http.HttpTransport; import com.google.api.client.http.apache.v5.Apache5HttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.gax.rpc.TransportChannelProvider; import com.google.auth.http.HttpTransportFactory; import com.google.cloud.bigquery.exception.BigQueryJdbcRuntimeException; @@ -58,6 +59,7 @@ final class BigQueryJdbcProxyUtility { new BigQueryJdbcCustomLogger(BigQueryJdbcProxyUtility.class.getName()); static final String validPortRegex = "^([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])$"; + private static final HttpTransport DEFAULT_TRANSPORT = new NetHttpTransport.Builder().build(); private BigQueryJdbcProxyUtility() {} @@ -136,17 +138,14 @@ static HttpTransportOptions getHttpTransportOptions( boolean hasProxyOrSsl = proxyProperties.containsKey(BigQueryJdbcUrlUtility.PROXY_HOST_PROPERTY_NAME) || sslTrustStorePath != null; - boolean hasTimeoutConfig = connectTimeout != null || readTimeout != null; - - if (!hasProxyOrSsl && !hasTimeoutConfig) { - return null; - } HttpTransportOptions.Builder httpTransportOptionsBuilder = HttpTransportOptions.newBuilder(); if (hasProxyOrSsl) { httpTransportOptionsBuilder.setHttpTransportFactory( getHttpTransportFactory( proxyProperties, sslTrustStorePath, sslTrustStorePassword, callerClassName)); + } else { + httpTransportOptionsBuilder.setHttpTransportFactory(() -> DEFAULT_TRANSPORT); } if (connectTimeout != null) { @@ -178,9 +177,8 @@ private static HttpTransportFactory getHttpTransportFactory( HttpRoutePlanner httpRoutePlanner = new DefaultProxyRoutePlanner(proxyHostDetails); httpClientBuilder.setRoutePlanner(httpRoutePlanner); addAuthToProxyIfPresent(proxyProperties, httpClientBuilder, callerClassName); - } else { - httpClientBuilder.useSystemProperties(); } + httpClientBuilder.useSystemProperties(); if (sslTrustStorePath != null) { try (FileInputStream trustStoreStream = new FileInputStream(sslTrustStorePath)) { diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtilityTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtilityTest.java index ea62166e0112..c8e613f08941 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtilityTest.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/BigQueryJdbcProxyUtilityTest.java @@ -161,7 +161,7 @@ public void testGetHttpTransportOptionsWithNonAuthenticatedProxy() { } @Test - public void testGetHttpTransportOptionsWithNoProxySettingsReturnsNull() { + public void testGetHttpTransportOptionsWithNoProxySettingsReturnsDefaultOptions() { String connection_uri = "jdbc:bigquery://https://www.googleapis.com/bigquery/v2:443;" + "ProjectId=TestProject" @@ -172,7 +172,8 @@ public void testGetHttpTransportOptionsWithNoProxySettingsReturnsNull() { HttpTransportOptions result = BigQueryJdbcProxyUtility.getHttpTransportOptions( proxyProperties, null, null, null, null, "TestClass"); - assertNull(result); + assertNotNull(result); + assertNotNull(result.getHttpTransportFactory()); } private String getTestResourcePath(String resourceName) throws URISyntaxException { @@ -299,11 +300,12 @@ public void testGetTransportChannelProvider_noProxyNoSsl_returnsNull() { } @Test - public void testGetHttpTransportOptions_noProxyNoSsl_returnsNull() { + public void testGetHttpTransportOptions_noProxyNoSsl_returnsDefaultOptions() { HttpTransportOptions options = BigQueryJdbcProxyUtility.getHttpTransportOptions( Collections.emptyMap(), null, null, null, null, "TestClass"); - assertNull(options); + assertNotNull(options); + assertNotNull(options.getHttpTransportFactory()); } @Test diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITLocalSslValidationTest.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITLocalSslValidationTest.java new file mode 100644 index 000000000000..6ccffb703547 --- /dev/null +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/ITLocalSslValidationTest.java @@ -0,0 +1,272 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.bigquery.jdbc.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.cloud.bigquery.jdbc.utils.URIBuilder; +import com.google.common.io.CharStreams; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class ITLocalSslValidationTest { + private static final String HOST = "localhost"; + private static final String PASSWORD = "changeit"; + private static final String KEYSTORE_RESOURCE = "/localhost-keystore.jks"; + private static final String TRUSTSTORE_RESOURCE = "/localhost-truststore.jks"; + private static final String SUCCESS_MARKER = "SUBPROCESS_RESULT: SUCCESS"; + private static final String FAILURE_MARKER_PREFIX = "SUBPROCESS_RESULT: FAILURE - "; + private static final String PKIX_ERROR_MSG = "PKIX path building failed"; + + private static MockHttpsServer mockServer; + private static int port; + + public static class MockHttpsServer { + private final HttpsServer server; + + public MockHttpsServer(int port) throws Exception { + server = HttpsServer.create(new InetSocketAddress(HOST, port), 0); + SSLContext sslContext = SSLContext.getInstance("TLS"); + + KeyStore ks = KeyStore.getInstance("JKS"); + try (InputStream stream = getClass().getResourceAsStream(KEYSTORE_RESOURCE)) { + if (stream == null) { + throw new IllegalStateException( + "Keystore resource " + KEYSTORE_RESOURCE + " not found on classpath!"); + } + ks.load(stream, PASSWORD.toCharArray()); + } + + KeyManagerFactory kmf = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, PASSWORD.toCharArray()); + + sslContext.init(kmf.getKeyManagers(), null, null); + + server.setHttpsConfigurator( + new HttpsConfigurator(sslContext) { + @Override + public void configure(HttpsParameters params) { + try { + SSLContext context = getSSLContext(); + SSLParameters sslParams = context.getDefaultSSLParameters(); + params.setSSLParameters(sslParams); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + }); + + server.createContext( + "/", + exchange -> { + String path = exchange.getRequestURI().getPath(); + String response; + if (path.contains("/queries")) { + response = + "{\n" + + " \"kind\": \"bigquery#queryResponse\",\n" + + " \"jobComplete\": true,\n" + + " \"rows\": [],\n" + + " \"totalRows\": \"0\",\n" + + " \"schema\": {\n" + + " \"fields\": []\n" + + " }\n" + + "}"; + } else { + response = + "{\n" + + " \"kind\": \"bigquery#job\",\n" + + " \"status\": {\n" + + " \"state\": \"DONE\"\n" + + " },\n" + + " \"jobReference\": {\n" + + " \"projectId\": \"dummy\",\n" + + " \"jobId\": \"dummy-job\"\n" + + " },\n" + + " \"configuration\": {\n" + + " \"query\": {\n" + + " \"query\": \"SELECT 1\"\n" + + " }\n" + + " },\n" + + " \"statistics\": {\n" + + " \"query\": {\n" + + " \"statementType\": \"SELECT\"\n" + + " }\n" + + " }\n" + + "}"; + } + byte[] responseBytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, responseBytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBytes); + } + }); + } + + public void start() { + server.start(); + } + + public void stop() { + server.stop(0); + } + + public int getPort() { + return server.getAddress().getPort(); + } + } + + private static class ProcessResult { + final int exitCode; + final String stdout; + + ProcessResult(int exitCode, String stdout) { + this.exitCode = exitCode; + this.stdout = stdout; + } + } + + @BeforeAll + public static void setUp() throws Exception { + mockServer = new MockHttpsServer(0); + mockServer.start(); + port = mockServer.getPort(); + } + + @AfterAll + public static void tearDown() { + if (mockServer == null) { + return; + } + mockServer.stop(); + } + + private ProcessResult runSubprocess(String trustStore, String password) throws Exception { + String javaHome = System.getProperty("java.home"); + String javaBin = javaHome + File.separator + "bin" + File.separator + "java"; + String classpath = System.getProperty("java.class.path"); + String className = ITLocalSslValidationTest.class.getCanonicalName(); + + List command = new ArrayList<>(); + command.add(javaBin); + if (trustStore != null) { + command.add("-Djavax.net.ssl.trustStore=" + trustStore); + } + if (password != null) { + command.add("-Djavax.net.ssl.trustStorePassword=" + password); + } + command.add("-cp"); + command.add(classpath); + command.add(className); + command.add(String.valueOf(port)); + + ProcessBuilder builder = new ProcessBuilder(command); + builder.redirectErrorStream(true); + Process process = builder.start(); + + String output = ""; + boolean finished = false; + try { + try (InputStreamReader reader = + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) { + output = CharStreams.toString(reader); + } + finished = process.waitFor(10, TimeUnit.SECONDS); + if (!finished) { + throw new TimeoutException("Subprocess timed out after 10 seconds"); + } + int exitCode = process.exitValue(); + return new ProcessResult(exitCode, output); + } finally { + if (!finished && process.isAlive()) { + process.destroyForcibly(); + } + } + } + + @Test + public void testDefaultSslFailsForSelfSigned() throws Exception { + ProcessResult result = runSubprocess(null, null); + assertEquals(1, result.exitCode, "Subprocess should fail. Output:\n" + result.stdout); + assertTrue(result.stdout.contains(PKIX_ERROR_MSG)); + } + + @Test + public void testCustomTrustStoreSucceeds() throws Exception { + URL trustStoreUrl = getClass().getResource(TRUSTSTORE_RESOURCE); + if (trustStoreUrl == null) { + throw new IllegalStateException( + "Truststore resource " + TRUSTSTORE_RESOURCE + " not found on classpath!"); + } + String trustStorePath = new File(trustStoreUrl.toURI()).getAbsolutePath(); + ProcessResult result = runSubprocess(trustStorePath, PASSWORD); + + assertEquals(0, result.exitCode, "Subprocess failed. Output:\n" + result.stdout); + assertTrue(result.stdout.contains(SUCCESS_MARKER)); + assertFalse( + result.stdout.contains(PKIX_ERROR_MSG), + "Handshake failed with SSL error: " + result.stdout); + } + + public static void main(String[] args) { + int port = Integer.parseInt(args[0]); + String baseUri = "jdbc:bigquery://https://" + HOST + ":" + port + ";"; + String url = + new URIBuilder(baseUri) + .append("EndpointOverrides", "BIGQUERY=https://" + HOST + ":" + port) + .append("ProjectId", "dummy") + .append("OAuthType", 2) + .append("OAuthAccessToken", "dummy-token") + .toString(); + try (Connection connection = DriverManager.getConnection(url); + Statement statement = connection.createStatement()) { + statement.execute("SELECT 1"); + System.out.println(SUCCESS_MARKER); + System.exit(0); + } catch (Throwable e) { + System.out.println(FAILURE_MARKER_PREFIX + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } +} diff --git a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/suites/ITPresubmitTests.java b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/suites/ITPresubmitTests.java index cdcece31a279..44e37f6888e2 100644 --- a/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/suites/ITPresubmitTests.java +++ b/java-bigquery-jdbc/src/test/java/com/google/cloud/bigquery/jdbc/it/suites/ITPresubmitTests.java @@ -23,6 +23,7 @@ import com.google.cloud.bigquery.jdbc.it.ITConnectionTest; import com.google.cloud.bigquery.jdbc.it.ITDatabaseMetadataTest; import com.google.cloud.bigquery.jdbc.it.ITDriverTest; +import com.google.cloud.bigquery.jdbc.it.ITLocalSslValidationTest; import com.google.cloud.bigquery.jdbc.it.ITResultSetMetadataTest; import com.google.cloud.bigquery.jdbc.it.ITStatementTest; import org.junit.platform.suite.api.SelectClasses; @@ -37,6 +38,7 @@ ITConnectionPoolingTest.class, ITDatabaseMetadataTest.class, ITDriverTest.class, + ITLocalSslValidationTest.class, ITResultSetMetadataTest.class, ITStatementTest.class }) diff --git a/java-bigquery-jdbc/src/test/resources/localhost-keystore.jks b/java-bigquery-jdbc/src/test/resources/localhost-keystore.jks new file mode 100644 index 000000000000..a5a04b2fe56e Binary files /dev/null and b/java-bigquery-jdbc/src/test/resources/localhost-keystore.jks differ diff --git a/java-bigquery-jdbc/src/test/resources/localhost-truststore.jks b/java-bigquery-jdbc/src/test/resources/localhost-truststore.jks new file mode 100644 index 000000000000..8a96ce8df85a Binary files /dev/null and b/java-bigquery-jdbc/src/test/resources/localhost-truststore.jks differ