From 5c2890eae6f331782bbf162ff0021d45cc1c8ee4 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Mon, 9 Nov 2026 21:39:16 -0400 Subject: [PATCH] fix(httpclient): honor system proxy settings in HttpClientFactoryImpl Fixes #124. Use SystemDefaultRoutePlanner with the default ProxySelector so https.proxyHost/https.proxyPort are respected. Reaching a proxy needs a plain http connection to it, so the http scheme is registered, while a route planner still rejects http destinations unless setSupportInsecureHttp(true). --- CHANGELOG.md | 1 + .../HttpClientFactoryImpl.java | 63 ++++++++- .../HttpClientFactoryImplProxyTest.java | 133 ++++++++++++++++++ 3 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImplProxyTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c43cf915..366df13ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Signature comparison is now constant-time. ### Added +* `HttpClientFactoryImpl` now honors the JVM's proxy settings (`https.proxyHost`, `https.proxyPort`, `http.nonProxyHosts`); call `setUseSystemProxy(false)` on the builder to ignore them. Destinations using `http` are still rejected unless `setSupportInsecureHttp(true)` is used. * Added `B2EventNotification.parseUnverified`, which parses without checking a signature. The one-argument `parse` overloads are deprecated in its favor. ## [6.5.0] - 2025-12-15 diff --git a/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImpl.java b/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImpl.java index adae523c0..eae006005 100644 --- a/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImpl.java +++ b/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImpl.java @@ -6,21 +6,30 @@ import com.backblaze.b2.client.exceptions.B2Exception; import com.backblaze.b2.util.B2Preconditions; +import org.apache.http.HttpException; +import org.apache.http.HttpHost; +import org.apache.http.HttpRequest; import org.apache.http.client.config.RequestConfig; import org.apache.http.config.Registry; import org.apache.http.config.RegistryBuilder; import org.apache.http.conn.HttpClientConnectionManager; +import org.apache.http.conn.routing.HttpRoute; +import org.apache.http.conn.routing.HttpRoutePlanner; import org.apache.http.conn.socket.ConnectionSocketFactory; import org.apache.http.conn.socket.PlainConnectionSocketFactory; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.DefaultRoutePlanner; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.impl.conn.SystemDefaultRoutePlanner; +import org.apache.http.protocol.HttpContext; import org.apache.http.ssl.SSLContexts; import org.apache.http.util.VersionInfo; import javax.net.ssl.SSLContext; +import java.net.ProxySelector; import java.util.concurrent.TimeUnit; /** @@ -33,10 +42,20 @@ * If you have a non-https implementation of B2 that you test against, * you *may* choose to enable 'http' support when creating the factory. * We really do *not* recommend that in production. + * + * By default, created HttpClients use the JVM's default ProxySelector, so + * system properties such as https.proxyHost, https.proxyPort and + * http.nonProxyHosts are honored. When no proxy is configured, connections + * are made directly, as before. Talking to a proxy requires a plain 'http' + * connection to the proxy itself (before it tunnels to the https server with + * CONNECT), so that scheme is registered internally, but requests whose + * *destination* uses 'http' are still rejected unless insecure http was + * enabled on the builder. Use setUseSystemProxy(false) to ignore proxies. */ public class HttpClientFactoryImpl implements HttpClientFactory { private final HttpClientConnectionManager connectionManager; private final RequestConfig requestConfig; + private final HttpRoutePlanner routePlanner; private final IdleConnectionMonitorThread connectionJanitor; /** @@ -54,9 +73,11 @@ public class HttpClientFactoryImpl implements HttpClientFactory { private HttpClientFactoryImpl(HttpClientConnectionManager connectionManager, - RequestConfig requestConfig) { + RequestConfig requestConfig, + HttpRoutePlanner routePlanner) { this.connectionManager = connectionManager; this.requestConfig = requestConfig; + this.routePlanner = routePlanner; connectionJanitor = new IdleConnectionMonitorThread(connectionManager); connectionJanitor.start(); } @@ -76,6 +97,7 @@ public CloseableHttpClient create() throws B2Exception { .setUserAgent(APACHE_HTTP_CLIENT_USER_AGENT) .setConnectionManager(connectionManager) .setDefaultRequestConfig(requestConfig) + .setRoutePlanner(routePlanner) // Allow connections to be idle for up to 4 seconds before not reusing. This strategy is needed // because we are seeing connections being closed by the server after 4 seconds, then when we attempt // to use, the call fails. @@ -116,6 +138,10 @@ public static class Builder { // http is only supported for use with some test environments. private boolean supportInsecureHttp; + // should the clients use the JVM's default ProxySelector (https.proxyHost, etc)? + // when no proxy is configured, this has no effect. + private boolean useSystemProxy = true; + // for RequestConfig private int connectionRequestTimeoutSeconds = DEFAULT_CONNECTION_REQUEST_TIMEOUT_SECONDS; private int connectTimeoutSeconds = DEFAULT_CONNECT_TIMEOUT_SECONDS; @@ -130,6 +156,11 @@ public Builder setSupportInsecureHttp(boolean supportInsecureHttp) { return this; } + public Builder setUseSystemProxy(boolean useSystemProxy) { + this.useSystemProxy = useSystemProxy; + return this; + } + public Builder setConnectionRequestTimeoutSeconds(int connectionRequestTimeoutSeconds) { this.connectionRequestTimeoutSeconds = connectionRequestTimeoutSeconds; return this; @@ -162,7 +193,32 @@ public HttpClientFactoryImpl build() { return new HttpClientFactoryImpl( createConnectionManager(), - createRequestConfig()); + createRequestConfig(), + createRoutePlanner()); + } + + private HttpRoutePlanner createRoutePlanner() { + // DefaultRoutePlanner always connects directly. + final HttpRoutePlanner planner = useSystemProxy + ? new SystemDefaultRoutePlanner(ProxySelector.getDefault()) + : new DefaultRoutePlanner(null); + if (supportInsecureHttp) { + return planner; + } + + // The connection manager has to know about 'http' to reach a proxy, so + // it can't be what keeps us from sending data to an http destination. + return new HttpRoutePlanner() { + @Override + public HttpRoute determineRoute(HttpHost target, HttpRequest request, HttpContext context) + throws HttpException { + final String scheme = target.getSchemeName(); + if (!"https".equalsIgnoreCase(scheme)) { + throw new HttpException("unsupported scheme '" + scheme + "'; only https is enabled"); + } + return planner.determineRoute(target, request, context); + } + }; } public RequestConfig createRequestConfig() { @@ -201,7 +257,8 @@ private HttpClientConnectionManager createConnectionManager() { registryBuilder.register("https", sslFactory); } - if (supportInsecureHttp) { + // 'http' is also needed to connect to a proxy. See createRoutePlanner(). + if (supportInsecureHttp || useSystemProxy) { ConnectionSocketFactory plainFactory = new PlainConnectionSocketFactory(); registryBuilder.register("http", plainFactory); } diff --git a/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImplProxyTest.java b/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImplProxyTest.java new file mode 100644 index 000000000..f3da24ae5 --- /dev/null +++ b/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImplProxyTest.java @@ -0,0 +1,133 @@ +/* + * Copyright 2026, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.webApiHttpClient; + +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests that the default HttpClientFactory honors the JVM's proxy settings, + * using a local "proxy" that answers every CONNECT with a 502. + */ +public class HttpClientFactoryImplProxyTest { + private static final String[] PROXY_PROPERTIES = { + "https.proxyHost", "https.proxyPort", "http.nonProxyHosts" + }; + + private final String[] savedProperties = new String[PROXY_PROPERTIES.length]; + private final BlockingQueue requestLines = new LinkedBlockingQueue<>(); + private ServerSocket proxy; + private Thread proxyThread; + + @Before + public void setUp() throws IOException { + for (int i = 0; i < PROXY_PROPERTIES.length; i++) { + savedProperties[i] = System.getProperty(PROXY_PROPERTIES[i]); + } + // the JVM's default for this skips proxies for localhost. + System.setProperty("http.nonProxyHosts", ""); + + proxy = new ServerSocket(0, 10, InetAddress.getLoopbackAddress()); + proxyThread = new Thread(this::serve); + proxyThread.setDaemon(true); + proxyThread.start(); + + System.setProperty("https.proxyHost", "localhost"); + System.setProperty("https.proxyPort", String.valueOf(proxy.getLocalPort())); + } + + @After + public void tearDown() throws IOException { + for (int i = 0; i < PROXY_PROPERTIES.length; i++) { + if (savedProperties[i] == null) { + System.clearProperty(PROXY_PROPERTIES[i]); + } else { + System.setProperty(PROXY_PROPERTIES[i], savedProperties[i]); + } + } + proxy.close(); + } + + private void serve() { + while (!proxy.isClosed()) { + try (Socket socket = proxy.accept()) { + final BufferedReader in = new BufferedReader( + new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII)); + requestLines.add(in.readLine()); + final OutputStream out = socket.getOutputStream(); + out.write("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } catch (IOException e) { + // closed in tearDown() + } + } + } + + private static void tryGet(HttpClientFactoryImpl.Builder builder, String url) throws Exception { + try (HttpClientFactoryImpl factory = builder.build(); + CloseableHttpClient client = factory.create()) { + client.execute(new HttpGet(url)).close(); + } catch (IOException e) { + // expected for https: the fake proxy refuses the tunnel. + } + } + + @Test + public void testHttpsRequestGoesThroughSystemProxy() throws Exception { + tryGet(HttpClientFactoryImpl.builder(), "https://b2.invalid/"); + + final String line = requestLines.poll(5, TimeUnit.SECONDS); + assertTrue("proxy should have received a CONNECT, got: " + line, + line != null && line.startsWith("CONNECT b2.invalid:443 ")); + } + + @Test + public void testSystemProxyCanBeDisabled() throws Exception { + tryGet(HttpClientFactoryImpl.builder().setUseSystemProxy(false), "https://b2.invalid/"); + + assertNull(requestLines.poll(500, TimeUnit.MILLISECONDS)); + } + + @Test + public void testHttpDestinationStillRejectedByDefault() throws Exception { + tryGet(HttpClientFactoryImpl.builder(), "http://b2.invalid/"); + + assertNull(requestLines.poll(500, TimeUnit.MILLISECONDS)); + } + + @Test + public void testHttpDestinationAllowedWhenInsecureHttpEnabled() throws Exception { + // http destinations use the (default) http proxy settings, not https.proxyHost, + // so this only checks that the request is not rejected up front. + System.setProperty("http.proxyHost", "localhost"); + try { + System.setProperty("http.proxyPort", String.valueOf(proxy.getLocalPort())); + tryGet(HttpClientFactoryImpl.builder().setSupportInsecureHttp(true), "http://b2.invalid/"); + assertTrue(requestLines.poll(5, TimeUnit.SECONDS).startsWith("GET http://b2.invalid/ ")); + } finally { + System.clearProperty("http.proxyHost"); + System.clearProperty("http.proxyPort"); + } + } +}