Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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;

/**
Expand All @@ -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();
}
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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");
}
}
}