From cecb1b6a4b5145c716dd2307e1c01d012a40e135 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Thu, 8 Jan 2026 10:44:12 +0100 Subject: [PATCH] HTTPCLIENT-2397 - TLS-Required mode: add setTlsOnly(boolean) to classic and async builders to reject cleartext routes. Fail fast with UnsupportedSchemeException when the computed route is not secure. --- .../hc/client5/http/impl/ChainElement.java | 2 +- .../impl/async/HttpAsyncClientBuilder.java | 21 ++++ .../http/impl/async/TlsRequiredAsyncExec.java | 62 ++++++++++++ .../http/impl/classic/HttpClientBuilder.java | 20 ++++ .../http/impl/classic/TlsRequiredExec.java | 59 +++++++++++ .../examples/TlsRequiredAsyncExample.java | 97 +++++++++++++++++++ .../examples/TlsRequiredClassicExample.java | 92 ++++++++++++++++++ 7 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/TlsRequiredAsyncExec.java create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/TlsRequiredExec.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/examples/TlsRequiredAsyncExample.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/examples/TlsRequiredClassicExample.java diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/ChainElement.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/ChainElement.java index 5ebcf4b607..e988cf6d3d 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/ChainElement.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/ChainElement.java @@ -34,6 +34,6 @@ */ public enum ChainElement { - REDIRECT, COMPRESS, BACK_OFF, RETRY, CACHING, PROTOCOL, CONNECT, MAIN_TRANSPORT + REDIRECT, COMPRESS, BACK_OFF, RETRY, CACHING, PROTOCOL, CONNECT, MAIN_TRANSPORT, TLS_REQUIRED } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java index 6ec4a90dfc..2a5ebebc18 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java @@ -273,6 +273,8 @@ private ExecInterceptorEntry( private boolean priorityHeaderDisabled; + private boolean tlsRequired; + /** * Maps {@code Content-Encoding} tokens to decoder factories in insertion order. @@ -901,6 +903,20 @@ public HttpAsyncClientBuilder disableContentCompression() { return this; } + /** + * When enabled, the client refuses to establish cleartext connections. + * This disables plain {@code http://}, {@code h2c}, and RFC 2817 TLS upgrade paths. + * + * @param tlsRequired whether to enforce TLS-required routes. + * @return this instance. + * + * @since 5.7 + */ + public final HttpAsyncClientBuilder setTlsRequired(final boolean tlsRequired) { + this.tlsRequired = tlsRequired; + return this; + } + /** * Sets a hard cap on the number of requests allowed to be queued/in-flight * within the internal async execution pipeline. When the limit is reached, @@ -1103,6 +1119,7 @@ public CloseableHttpAsyncClient build() { authCachingDisabled), ChainElement.PROTOCOL.name()); + // Add request retry executor, if not disabled if (!automaticRetriesDisabled) { HttpRequestRetryStrategy retryStrategyCopy = this.retryStrategy; @@ -1126,6 +1143,10 @@ public CloseableHttpAsyncClient build() { } } + if (this.tlsRequired) { + execChainDefinition.addFirst(new TlsRequiredAsyncExec(), ChainElement.TLS_REQUIRED.name()); + } + HttpRoutePlanner routePlannerCopy = this.routePlanner; if (routePlannerCopy == null) { diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/TlsRequiredAsyncExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/TlsRequiredAsyncExec.java new file mode 100644 index 0000000000..360fbd65ec --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/TlsRequiredAsyncExec.java @@ -0,0 +1,62 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.async; + +import java.io.IOException; + +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.UnsupportedSchemeException; +import org.apache.hc.client5.http.async.AsyncExecCallback; +import org.apache.hc.client5.http.async.AsyncExecChain; +import org.apache.hc.client5.http.async.AsyncExecChainHandler; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.nio.AsyncEntityProducer; + +/** + * Internal async exec interceptor that enforces the "TLS required" client policy. + * + */ +@Internal +final class TlsRequiredAsyncExec implements AsyncExecChainHandler { + + @Override + public void execute( + final HttpRequest request, + final AsyncEntityProducer entityProducer, + final AsyncExecChain.Scope scope, + final AsyncExecChain chain, + final AsyncExecCallback asyncExecCallback) throws HttpException, IOException { + + final HttpRoute route = scope.route; + if (route != null && !route.isSecure()) { + asyncExecCallback.failed(new UnsupportedSchemeException("Cleartext HTTP is disabled (TLS required)")); + } + chain.proceed(request, entityProducer, scope, asyncExecCallback); + } +} \ No newline at end of file diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java index 4508f40f00..07a44d9b0c 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java @@ -237,6 +237,8 @@ private ExecInterceptorEntry( private boolean defaultUserAgentDisabled; private ProxySelector proxySelector; + private boolean tlsRequired; + private List closeables; public static HttpClientBuilder create() { @@ -807,6 +809,20 @@ public final HttpClientBuilder setProxySelector(final ProxySelector proxySelecto return this; } + /** + * When enabled, the client refuses to establish cleartext connections. + * This disables plain {@code http://}, {@code h2c}, and RFC 2817 TLS upgrade paths. + * + * @param tlsRequired whether to enforce TLS-required routes. + * @return this instance. + * + * @since 5.7 + */ + public final HttpClientBuilder setTlsRequired(final boolean tlsRequired) { + this.tlsRequired = tlsRequired; + return this; + } + /** * Request exec chain customization and extension. *

@@ -999,6 +1015,10 @@ public CloseableHttpClient build() { } } + if (this.tlsRequired) { + execChainDefinition.addFirst(new TlsRequiredExec(), ChainElement.TLS_REQUIRED.name()); + } + // Add request retry executor, if not disabled if (!automaticRetriesDisabled) { HttpRequestRetryStrategy retryStrategyCopy = this.retryStrategy; diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/TlsRequiredExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/TlsRequiredExec.java new file mode 100644 index 0000000000..0c155b210c --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/TlsRequiredExec.java @@ -0,0 +1,59 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.classic; + +import java.io.IOException; + +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.UnsupportedSchemeException; +import org.apache.hc.client5.http.classic.ExecChain; +import org.apache.hc.client5.http.classic.ExecChainHandler; +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpException; + +/** + * Internal exec interceptor that enforces the "TLS required" client policy. + * + */ +@Internal +final class TlsRequiredExec implements ExecChainHandler { + + @Override + public ClassicHttpResponse execute( + final ClassicHttpRequest request, + final ExecChain.Scope scope, + final ExecChain chain) throws IOException, HttpException { + + final HttpRoute route = scope.route; + if (route != null && !route.isSecure()) { + throw new UnsupportedSchemeException("Cleartext HTTP is disabled (TLS required)"); + } + return chain.proceed(request, scope); + } +} \ No newline at end of file diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/TlsRequiredAsyncExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/TlsRequiredAsyncExample.java new file mode 100644 index 0000000000..c4253977f7 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/TlsRequiredAsyncExample.java @@ -0,0 +1,97 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.examples; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +import org.apache.hc.client5.http.UnsupportedSchemeException; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.client5.http.protocol.HttpClientContext; + +/** + * Demonstrates the "TLS-required connections" mode for the async client. + * + *

+ * When {@code TlsRequired(true)} is enabled, the async client rejects execution when the + * computed {@code HttpRoute} is not secure. This prevents accidental cleartext connections + * such as {@code http://...} and disables cleartext upgrade mechanisms that start without TLS. + *

+ * + *

+ * The example triggers a rejection using {@code http://example.com/} and validates the failure + * by unwrapping {@link ExecutionException#getCause()} and checking for + * {@link UnsupportedSchemeException}. + *

+ * + * @since 5.7 + */ +public final class TlsRequiredAsyncExample { + + public static void main(final String[] args) throws Exception { + try (final CloseableHttpAsyncClient client = HttpAsyncClients.custom() + .setTlsRequired(true) + .build()) { + + client.start(); + + // 1) Must fail fast with UnsupportedSchemeException + final SimpleHttpRequest http = SimpleRequestBuilder.get("http://example.com/").build(); + final Future httpFuture = + client.execute(http, HttpClientContext.create(), null); + + try { + final SimpleHttpResponse response = httpFuture.get(); + System.out.println("UNEXPECTED: http:// executed with status " + response.getCode()); + } catch (final ExecutionException ex) { + final Throwable cause = ex.getCause(); + if (cause instanceof UnsupportedSchemeException) { + System.out.println("OK (expected): " + cause.getMessage()); + } else { + throw ex; + } + } + + // 2) Allowed (may still fail if network/DNS blocked) + final SimpleHttpRequest https = SimpleRequestBuilder.get("https://example.com/").build(); + final Future httpsFuture = + client.execute(https, HttpClientContext.create(), null); + + try { + final SimpleHttpResponse response = httpsFuture.get(); + System.out.println("HTTPS OK: status=" + response.getCode()); + } catch (final ExecutionException ex) { + System.err.println("HTTPS failed (network/env): " + ex.getCause()); + } + } + } + +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/TlsRequiredClassicExample.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/TlsRequiredClassicExample.java new file mode 100644 index 0000000000..edc9b64096 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/TlsRequiredClassicExample.java @@ -0,0 +1,92 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.examples; + +import java.io.IOException; + +import org.apache.hc.client5.http.UnsupportedSchemeException; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.io.entity.EntityUtils; + + +/** + * Demonstrates the "TLS-required connections" mode. + * + *

+ * When {@code TlsRequired(true)} is enabled, the client refuses to execute requests whose + * computed {@code HttpRoute} is not marked secure. In practice this prevents accidental + * cleartext HTTP usage (for example {@code http://...}), and also disables cleartext upgrade + * mechanisms such as RFC 2817 "Upgrade to TLS" (which necessarily starts in cleartext). + *

+ * + *

+ * Notes: + *

+ *
    + *
  • This is an opt-in client policy. Default behavior is unchanged.
  • + *
  • This does not add any extra security guarantees beyond normal TLS behavior; it simply + * fails fast when a cleartext route is about to be used.
  • + *
  • If a server speaks plaintext on an {@code https://} endpoint, the TLS handshake will fail + * as usual; TLS-required mode does not change that.
  • + *
+ * + * @since 5.7 + */ +public final class TlsRequiredClassicExample { + + public static void main(final String[] args) throws Exception { + try (final CloseableHttpClient client = HttpClients.custom() + .setTlsRequired(true) + .build()) { + + // 1) This must fail fast (no connect attempt) + final HttpGet http = new HttpGet("http://example.com/"); + try { + client.execute(http, response -> { + EntityUtils.consume(response.getEntity()); + System.out.println("UNEXPECTED: http:// executed with status " + response.getCode()); + return null; + }); + } catch (final UnsupportedSchemeException ex) { + System.out.println("OK (expected): " + ex.getMessage()); + } + + // 2) This should be allowed (may still fail if network/DNS blocked) + final HttpGet https = new HttpGet("https://example.com/"); + client.execute(https, response -> { + EntityUtils.consume(response.getEntity()); + System.out.println("HTTPS OK: status=" + response.getCode()); + return null; + }); + } catch (final IOException ex) { + System.err.println("I/O error: " + ex.getClass().getName() + ": " + ex.getMessage()); + } + } + +}