out = new ArrayList<>(tokens.size());
+ for (final String token : tokens) {
+ out.add(decodeId(token));
+ }
+ return out;
+ }
+
+ /**
+ * Encodes a single raw protocol ID to canonical token form using the HTTP token codec
+ * from core, which keeps RFC 7230 {@code tchar} octets literal and percent-encodes the
+ * rest (including {@code '%'}) with uppercase hexadecimal.
+ */
+ public static String encodeId(final String id) {
+ Args.notBlank(id, "id");
+ return PercentCodec.HTTP_TOKEN.encode(id);
+ }
+
+ /**
+ * Decodes a percent-encoded token to a raw protocol ID using UTF-8.
+ *
+ * A {@code '%'} that is not followed by two hexadecimal digits is a malformed
+ * token and is rejected as a protocol error.
+ *
+ * @throws ProtocolException if the token contains malformed percent-encoding.
+ */
+ public static String decodeId(final String token) throws ProtocolException {
+ Args.notBlank(token, "token");
+ for (int i = 0; i < token.length(); i++) {
+ if (token.charAt(i) == '%') {
+ if (i + 2 >= token.length()
+ || Character.digit(token.charAt(i + 1), 16) < 0
+ || Character.digit(token.charAt(i + 2), 16) < 0) {
+ throw new ProtocolException("Malformed percent-encoding in ALPN protocol id: " + token);
+ }
+ i += 2;
+ }
+ }
+ return PercentCodec.decode(token, StandardCharsets.UTF_8);
+ }
+
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java
index d8dd016339..d752ff7755 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java
@@ -30,6 +30,7 @@
import java.io.IOException;
import java.io.InterruptedIOException;
import java.nio.ByteBuffer;
+import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -47,6 +48,8 @@
import org.apache.hc.client5.http.auth.ChallengeType;
import org.apache.hc.client5.http.auth.MalformedChallengeException;
import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.config.TlsConfig;
+import org.apache.hc.client5.http.impl.AlpnHeaderSupport;
import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper;
import org.apache.hc.client5.http.impl.auth.AuthenticationHandler;
import org.apache.hc.client5.http.impl.routing.BasicRouteDirector;
@@ -55,6 +58,7 @@
import org.apache.hc.core5.annotation.Contract;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.function.Resolver;
import org.apache.hc.core5.concurrent.CancellableDependency;
import org.apache.hc.core5.concurrent.FutureCallback;
import org.apache.hc.core5.http.EntityDetails;
@@ -76,6 +80,8 @@
import org.apache.hc.core5.http.nio.RequestChannel;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.http2.ssl.H2TlsSupport;
import org.apache.hc.core5.util.Args;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -99,11 +105,23 @@ public final class AsyncConnectExec implements AsyncExecChainHandler {
private final AuthCacheKeeper authCacheKeeper;
private final HttpRouteDirector routeDirector;
+ private final Resolver tlsConfigResolver;
+
+
public AsyncConnectExec(
final HttpProcessor proxyHttpProcessor,
final AuthenticationStrategy proxyAuthStrategy,
final SchemePortResolver schemePortResolver,
final boolean authCachingDisabled) {
+ this(proxyHttpProcessor, proxyAuthStrategy, schemePortResolver, authCachingDisabled, null);
+ }
+
+ public AsyncConnectExec(
+ final HttpProcessor proxyHttpProcessor,
+ final AuthenticationStrategy proxyAuthStrategy,
+ final SchemePortResolver schemePortResolver,
+ final boolean authCachingDisabled,
+ final Resolver tlsConfigResolver) {
Args.notNull(proxyHttpProcessor, "Proxy HTTP processor");
Args.notNull(proxyAuthStrategy, "Proxy authentication strategy");
this.proxyHttpProcessor = proxyHttpProcessor;
@@ -111,6 +129,7 @@ public AsyncConnectExec(
this.authenticator = new AuthenticationHandler();
this.authCacheKeeper = authCachingDisabled ? null : new AuthCacheKeeper(schemePortResolver);
this.routeDirector = BasicRouteDirector.INSTANCE;
+ this.tlsConfigResolver = tlsConfigResolver;
}
static class State {
@@ -275,7 +294,7 @@ public void cancelled() {
if (LOG.isDebugEnabled()) {
LOG.debug("{} create tunnel", exchangeId);
}
- createTunnel(state, proxy, target, scope, new AsyncExecCallback() {
+ createTunnel(state, proxy, target, route, scope, new AsyncExecCallback() {
@Override
public AsyncDataConsumer handleResponse(final HttpResponse response, final EntityDetails entityDetails) throws HttpException, IOException {
@@ -380,6 +399,7 @@ private void createTunnel(
final State state,
final HttpHost proxy,
final HttpHost nextHop,
+ final HttpRoute route,
final AsyncExecChain.Scope scope,
final AsyncExecCallback asyncExecCallback) {
@@ -426,6 +446,19 @@ public void produceRequest(final RequestChannel requestChannel,
final HttpRequest connect = new BasicHttpRequest(Method.CONNECT, nextHop, nextHop.toHostString());
connect.setVersion(HttpVersion.HTTP_1_1);
+ // RFC 7639: advertise the same ALPN protocols the tunnel's TLS layer will offer,
+ // derived from the target's HttpVersionPolicy so the header cannot diverge from the
+ // protocol actually negotiated inside the tunnel.
+ if (route.isSecure()) {
+ final TlsConfig tlsConfig = tlsConfigResolver != null
+ ? tlsConfigResolver.resolve(route.getTargetHost())
+ : null;
+ final HttpVersionPolicy versionPolicy = tlsConfig != null
+ ? tlsConfig.getHttpVersionPolicy()
+ : HttpVersionPolicy.NEGOTIATE;
+ connect.setHeader(AlpnHeaderSupport.formatValue(
+ Arrays.asList(H2TlsSupport.selectApplicationProtocols(versionPolicy))));
+ }
proxyHttpProcessor.process(connect, null, clientContext);
authenticator.addAuthResponse(proxy, ChallengeType.PROXY, connect, proxyAuthExchange, clientContext);
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 d010ac8617..90912d8af8 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
@@ -70,6 +70,7 @@
import org.apache.hc.client5.http.impl.auth.DigestSchemeFactory;
import org.apache.hc.client5.http.impl.auth.ScramSchemeFactory;
import org.apache.hc.client5.http.impl.auth.SystemDefaultCredentialsProvider;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner;
import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner;
@@ -936,6 +937,7 @@ public final HttpAsyncClientBuilder disableRequestPriority() {
return this;
}
+
/**
* Registers a global {@link org.apache.hc.client5.http.EarlyHintsListener}
* that will be notified when the client receives {@code 103 Early Hints}
@@ -1086,7 +1088,10 @@ public CloseableHttpAsyncClient build() {
new DefaultHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)),
proxyAuthStrategyCopy,
schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE,
- authCachingDisabled),
+ authCachingDisabled,
+ connManagerCopy instanceof PoolingAsyncClientConnectionManager
+ ? ((PoolingAsyncClientConnectionManager) connManagerCopy).getTlsConfigResolver()
+ : null),
ChainElement.CONNECT.name());
if (earlyHintsListener != null) {
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java
index fc0f8d5106..bcb112c7fa 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java
@@ -28,6 +28,7 @@
package org.apache.hc.client5.http.impl.classic;
import java.io.IOException;
+import java.util.Arrays;
import org.apache.hc.client5.http.AuthenticationStrategy;
import org.apache.hc.client5.http.EndpointInfo;
@@ -40,6 +41,8 @@
import org.apache.hc.client5.http.classic.ExecChainHandler;
import org.apache.hc.client5.http.classic.ExecRuntime;
import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.config.TlsConfig;
+import org.apache.hc.client5.http.impl.AlpnHeaderSupport;
import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper;
import org.apache.hc.client5.http.impl.auth.AuthenticationHandler;
import org.apache.hc.client5.http.impl.routing.BasicRouteDirector;
@@ -48,6 +51,7 @@
import org.apache.hc.core5.annotation.Contract;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.function.Resolver;
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ConnectionReuseStrategy;
@@ -65,6 +69,8 @@
import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
import org.apache.hc.core5.http.message.StatusLine;
import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.http2.ssl.H2TlsSupport;
import org.apache.hc.core5.util.Args;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -89,12 +95,24 @@ public final class ConnectExec implements ExecChainHandler {
private final AuthCacheKeeper authCacheKeeper;
private final HttpRouteDirector routeDirector;
+ private final Resolver tlsConfigResolver;
+
public ConnectExec(
final ConnectionReuseStrategy reuseStrategy,
final HttpProcessor proxyHttpProcessor,
final AuthenticationStrategy proxyAuthStrategy,
final SchemePortResolver schemePortResolver,
final boolean authCachingDisabled) {
+ this(reuseStrategy, proxyHttpProcessor, proxyAuthStrategy, schemePortResolver, authCachingDisabled, null);
+ }
+
+ public ConnectExec(
+ final ConnectionReuseStrategy reuseStrategy,
+ final HttpProcessor proxyHttpProcessor,
+ final AuthenticationStrategy proxyAuthStrategy,
+ final SchemePortResolver schemePortResolver,
+ final boolean authCachingDisabled,
+ final Resolver tlsConfigResolver) {
Args.notNull(reuseStrategy, "Connection reuse strategy");
Args.notNull(proxyHttpProcessor, "Proxy HTTP processor");
Args.notNull(proxyAuthStrategy, "Proxy authentication strategy");
@@ -104,6 +122,7 @@ public ConnectExec(
this.authenticator = new AuthenticationHandler();
this.authCacheKeeper = authCachingDisabled ? null : new AuthCacheKeeper(schemePortResolver);
this.routeDirector = BasicRouteDirector.INSTANCE;
+ this.tlsConfigResolver = tlsConfigResolver;
}
@Override
@@ -139,7 +158,6 @@ public ClassicHttpResponse execute(
step = this.routeDirector.nextStep(route, fact);
switch (step) {
-
case HttpRouteDirector.CONNECT_TARGET:
execRuntime.connectEndpoint(context);
tracker.connectTarget(route.isSecure());
@@ -162,11 +180,8 @@ public ClassicHttpResponse execute(
}
break;
- case HttpRouteDirector.TUNNEL_PROXY: {
- // Proxy chains are not supported by HttpClient.
- // Fail fast instead of attempting an untested tunnel to an intermediate proxy.
+ case HttpRouteDirector.TUNNEL_PROXY:
throw new HttpException("Proxy chains are not supported.");
- }
case HttpRouteDirector.LAYER_PROTOCOL:
execRuntime.upgradeTls(context);
@@ -197,14 +212,6 @@ public ClassicHttpResponse execute(
}
}
- /**
- * Creates a tunnel to the target server.
- * The connection must be established to the (last) proxy.
- * A CONNECT request for tunnelling through the proxy will
- * be created and sent, the response received and checked.
- * This method does not processChallenge the connection with
- * information about the tunnel, that is left to the caller.
- */
private ClassicHttpResponse createTunnelToTarget(
final String exchangeId,
final HttpRoute route,
@@ -228,6 +235,18 @@ private ClassicHttpResponse createTunnelToTarget(
final ClassicHttpRequest connect = new BasicClassicHttpRequest(Method.CONNECT, target, authority);
connect.setVersion(HttpVersion.HTTP_1_1);
+ // RFC 7639: advertise the same ALPN protocols the tunnel's TLS layer will offer,
+ // derived from the target's HttpVersionPolicy so the header cannot diverge from the
+ // protocol actually negotiated inside the tunnel.
+ if (route.isSecure()) {
+ final TlsConfig tlsConfig = tlsConfigResolver != null ? tlsConfigResolver.resolve(target) : null;
+ final HttpVersionPolicy versionPolicy = tlsConfig != null
+ ? tlsConfig.getHttpVersionPolicy()
+ : HttpVersionPolicy.NEGOTIATE;
+ connect.setHeader(AlpnHeaderSupport.formatValue(
+ Arrays.asList(H2TlsSupport.selectApplicationProtocols(versionPolicy))));
+ }
+
this.proxyHttpProcessor.process(connect, null, context);
while (response == null) {
@@ -262,12 +281,10 @@ private ClassicHttpResponse createTunnelToTarget(
authCacheKeeper.updateOnResponse(proxy, null, proxyAuthExchange, context);
}
if (updated) {
- // Retry request
if (this.reuseStrategy.keepAlive(connect, response, context)) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} connection kept alive", exchangeId);
}
- // Consume response content
final HttpEntity entity = response.getEntity();
EntityUtils.consume(entity);
} else {
@@ -295,26 +312,11 @@ private ClassicHttpResponse createTunnelToTarget(
return null;
}
- /**
- * Creates a tunnel to an intermediate proxy.
- * This method is not implemented in this class.
- * It just throws an exception here.
- */
private boolean createTunnelToProxy(
final HttpRoute route,
final int hop,
final HttpClientContext context) throws HttpException {
-
- // Have a look at createTunnelToTarget and replicate the parts
- // you need in a custom derived class. If your proxies don't require
- // authentication, it is not too hard. But for the stock version of
- // HttpClient, we cannot make such simplifying assumptions and would
- // have to include proxy authentication code. The HttpComponents team
- // is currently not in a position to support rarely used code of this
- // complexity. Feel free to submit patches that refactor the code in
- // createTunnelToTarget to facilitate re-use for proxy tunnelling.
-
throw new HttpException("Proxy chains are not supported.");
}
-}
+}
\ 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 6639349323..25207c63cc 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
@@ -72,6 +72,7 @@
import org.apache.hc.client5.http.impl.auth.DigestSchemeFactory;
import org.apache.hc.client5.http.impl.auth.ScramSchemeFactory;
import org.apache.hc.client5.http.impl.auth.SystemDefaultCredentialsProvider;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner;
import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner;
@@ -951,7 +952,10 @@ public CloseableHttpClient build() {
new DefaultHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)),
proxyAuthStrategyCopy,
schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE,
- authCachingDisabled),
+ authCachingDisabled,
+ connManagerCopy instanceof PoolingHttpClientConnectionManager
+ ? ((PoolingHttpClientConnectionManager) connManagerCopy).getTlsConfigResolver()
+ : null),
ChainElement.CONNECT.name());
execChainDefinition.addFirst(
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java
index 291b8e4baf..9d54e08646 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java
@@ -712,6 +712,16 @@ public void setTlsConfigResolver(final Resolver tlsConfigRe
this.tlsConfigResolver = tlsConfigResolver;
}
+ /**
+ * Returns the {@link Resolver} of {@link TlsConfig} on a per host basis, or {@code null} if not set.
+ *
+ * @since 5.7
+ */
+ @Internal
+ public Resolver getTlsConfigResolver() {
+ return tlsConfigResolver;
+ }
+
void closeIfExpired(final PoolEntry entry) {
final long now = System.currentTimeMillis();
if (entry.getExpiryDeadline().isBefore(now)) {
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
index 1f74d5d60f..c91a02ad4d 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
@@ -702,6 +702,16 @@ public void setTlsConfigResolver(final Resolver tlsConfigRe
this.tlsConfigResolver = tlsConfigResolver;
}
+ /**
+ * Returns the {@link Resolver} of {@link TlsConfig} on a per host basis, or {@code null} if not set.
+ *
+ * @since 5.7
+ */
+ @Internal
+ public Resolver getTlsConfigResolver() {
+ return tlsConfigResolver;
+ }
+
void closeIfExpired(final PoolEntry entry) {
final long now = System.currentTimeMillis();
if (entry.getExpiryDeadline().isBefore(now)) {
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/AlpnHeaderSupportTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/AlpnHeaderSupportTest.java
new file mode 100644
index 0000000000..1530e75c93
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/AlpnHeaderSupportTest.java
@@ -0,0 +1,135 @@
+/*
+ * ====================================================================
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.ProtocolException;
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.junit.jupiter.api.Test;
+
+class AlpnHeaderSupportTest {
+
+ @Test
+ void encodes_slash_and_percent_and_space() {
+ assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1"));
+ assertEquals("h2%25", AlpnHeaderSupport.encodeId("h2%"));
+ assertEquals("foo%20bar", AlpnHeaderSupport.encodeId("foo bar"));
+ }
+
+ @Test
+ void encodes_unicode_utf8() throws Exception {
+ final String raw = "ws/é"; // é -> C3 A9
+ final String enc = AlpnHeaderSupport.encodeId(raw);
+ assertEquals("ws%2F%C3%A9", enc);
+ assertEquals(raw, AlpnHeaderSupport.decodeId(enc));
+ }
+
+ @Test
+ void keeps_tchar_plain_and_upper_hex() {
+ assertEquals("h2", AlpnHeaderSupport.encodeId("h2"));
+ assertEquals("A1+B", AlpnHeaderSupport.encodeId("A1+B")); // '+' is a tchar → stays literal
+ assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1")); // slash encoded, hex uppercase
+ }
+
+ @Test
+ void decode_accepts_lowercase_hex() throws Exception {
+ assertEquals("http/1.1", AlpnHeaderSupport.decodeId("http%2f1.1"));
+ }
+
+ @Test
+ void decode_rejects_malformed_percent_encoding() {
+ // a trailing '%' with no hex digits is a protocol error
+ assertThrows(ProtocolException.class, () -> AlpnHeaderSupport.decodeId("h2%"));
+ // a '%' followed by a non-hex digit is a protocol error
+ assertThrows(ProtocolException.class, () -> AlpnHeaderSupport.decodeId("h2%G1"));
+ }
+
+ @Test
+ void format_and_parse_roundtrip_with_ows() throws Exception {
+ final String v = "h2, http%2F1.1 ,ws";
+ final Header header = new BasicHeader(HttpHeaders.ALPN, v);
+
+ final List ids = AlpnHeaderSupport.parseValue(header);
+ assertEquals(Arrays.asList("h2", "http/1.1", "ws"), ids);
+
+ assertEquals("h2, http%2F1.1, ws", AlpnHeaderSupport.formatValue(ids).getValue());
+ }
+
+ @Test
+ void parse_rejects_malformed_token() {
+ final Header header = new BasicHeader(HttpHeaders.ALPN, "h2, http%2");
+ assertThrows(ProtocolException.class, () -> AlpnHeaderSupport.parseValue(header));
+ }
+
+ @Test
+ void parse_empty() throws Exception {
+ assertTrue(AlpnHeaderSupport.parseValue(new BasicHeader(HttpHeaders.ALPN, "")).isEmpty());
+ }
+
+ @Test
+ void all_tchar_pass_through() {
+ // digits
+ for (char c = '0'; c <= '9'; c++) {
+ assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c)));
+ }
+ // uppercase letters
+ for (char c = 'A'; c <= 'Z'; c++) {
+ assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c)));
+ }
+ // lowercase letters
+ for (char c = 'a'; c <= 'z'; c++) {
+ assertEquals(String.valueOf(c), AlpnHeaderSupport.encodeId(String.valueOf(c)));
+ }
+ // the symbol set (minus '%' which must be encoded)
+ final String symbols = "!#$&'*+-.^_`|~";
+ for (int i = 0; i < symbols.length(); i++) {
+ final String s = String.valueOf(symbols.charAt(i));
+ assertEquals(s, AlpnHeaderSupport.encodeId(s));
+ }
+ }
+
+ @Test
+ void percent_is_always_encoded_and_uppercase_hex() {
+ assertEquals("%25", AlpnHeaderSupport.encodeId("%")); // '%' must be encoded
+ assertEquals("h2%25", AlpnHeaderSupport.encodeId("h2%")); // stays uppercase hex
+ }
+
+ @Test
+ void non_tchar_bytes_are_percent_encoded_uppercase() {
+ assertEquals("http%2F1.1", AlpnHeaderSupport.encodeId("http/1.1")); // 'F' uppercase
+ assertEquals("foo%20bar", AlpnHeaderSupport.encodeId("foo bar")); // space → %20
+ }
+
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncConnectExec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncConnectExec.java
new file mode 100644
index 0000000000..9e1c596bf0
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncConnectExec.java
@@ -0,0 +1,156 @@
+/*
+ * ====================================================================
+ * 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.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hc.client5.http.AuthenticationStrategy;
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.async.AsyncExecCallback;
+import org.apache.hc.client5.http.async.AsyncExecChain;
+import org.apache.hc.client5.http.async.AsyncExecRuntime;
+import org.apache.hc.client5.http.config.TlsConfig;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.concurrent.Cancellable;
+import org.apache.hc.core5.concurrent.CancellableDependency;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.function.Resolver;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler;
+import org.apache.hc.core5.http.nio.RequestChannel;
+import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http.support.BasicRequestBuilder;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+class TestAsyncConnectExec {
+
+ @Mock
+ private HttpProcessor proxyHttpProcessor;
+ @Mock
+ private AuthenticationStrategy proxyAuthStrategy;
+ @Mock
+ private AsyncExecChain chain;
+ @Mock
+ private AsyncExecRuntime execRuntime;
+
+ private HttpHost target;
+ private HttpHost proxy;
+
+ @BeforeEach
+ void setup() {
+ MockitoAnnotations.openMocks(this);
+ target = new HttpHost("https", "foo", 443);
+ proxy = new HttpHost("bar", 8888);
+ }
+
+ /**
+ * Drives the connect exec through a secure proxy tunnel and returns the {@code CONNECT} request
+ * that the tunnelling exchange handler produces.
+ */
+ private HttpRequest tunnelConnectRequest(final AsyncConnectExec exec, final HttpRoute route) throws Exception {
+ final HttpClientContext context = HttpClientContext.create();
+ final HttpRequest request = BasicRequestBuilder.get("https://foo/test").build();
+ final CancellableDependency dependency = Mockito.mock(CancellableDependency.class);
+ final AsyncExecChain.Scope scope = new AsyncExecChain.Scope(
+ "test", route, request, dependency, context, execRuntime, null, new AtomicInteger(1));
+
+ Mockito.when(execRuntime.isEndpointAcquired()).thenReturn(false);
+ Mockito.when(execRuntime.isEndpointConnected()).thenReturn(false);
+ Mockito.doAnswer(invocation -> {
+ invocation.getArgument(4, FutureCallback.class).completed(execRuntime);
+ return Mockito.mock(Cancellable.class);
+ }).when(execRuntime).acquireEndpoint(
+ Mockito.anyString(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
+ Mockito.doAnswer(invocation -> {
+ invocation.getArgument(1, FutureCallback.class).completed(execRuntime);
+ return Mockito.mock(Cancellable.class);
+ }).when(execRuntime).connectEndpoint(Mockito.any(), Mockito.any());
+
+ final AtomicReference handlerRef = new AtomicReference<>();
+ Mockito.doAnswer(invocation -> {
+ handlerRef.set(invocation.getArgument(1, AsyncClientExchangeHandler.class));
+ return Mockito.mock(Cancellable.class);
+ }).when(execRuntime).execute(Mockito.anyString(), Mockito.any(), Mockito.any());
+
+ exec.execute(request, null, scope, chain, Mockito.mock(AsyncExecCallback.class));
+
+ final AsyncClientExchangeHandler handler = handlerRef.get();
+ Assertions.assertNotNull(handler, "CONNECT exchange handler must have been submitted for execution");
+
+ final AtomicReference connectRef = new AtomicReference<>();
+ final RequestChannel requestChannel = Mockito.mock(RequestChannel.class);
+ Mockito.doAnswer(invocation -> {
+ connectRef.set(invocation.getArgument(0, HttpRequest.class));
+ return null;
+ }).when(requestChannel).sendRequest(Mockito.any(), Mockito.any(), Mockito.any());
+ handler.produceRequest(requestChannel, context);
+ return connectRef.get();
+ }
+
+ @Test
+ void testEstablishRouteViaProxyTunnelAddsAlpnHeader() throws Exception {
+ // No per-host TlsConfig resolver: the effective policy is NEGOTIATE, so the tunnel's TLS
+ // layer offers both protocols and the ALPN header advertises the same set.
+ final AsyncConnectExec exec = new AsyncConnectExec(proxyHttpProcessor, proxyAuthStrategy, null, true);
+ final HttpRoute route = new HttpRoute(target, null, proxy, true);
+
+ final HttpRequest connect = tunnelConnectRequest(exec, route);
+
+ Assertions.assertEquals("CONNECT", connect.getMethod());
+ final Header h = connect.getFirstHeader(HttpHeaders.ALPN);
+ Assertions.assertNotNull(h, "ALPN header must be present");
+ Assertions.assertEquals("h2, http%2F1.1", h.getValue());
+ }
+
+ @Test
+ void testEstablishRouteViaProxyTunnelAlpnHeaderReflectsVersionPolicy() throws Exception {
+ // A per-host FORCE_HTTP_1 policy must be reflected verbatim: only http/1.1 is advertised,
+ // so the header can never contradict the protocol negotiated inside the tunnel.
+ final Resolver tlsConfigResolver =
+ host -> TlsConfig.custom().setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_1).build();
+ final AsyncConnectExec exec = new AsyncConnectExec(
+ proxyHttpProcessor, proxyAuthStrategy, null, true, tlsConfigResolver);
+ final HttpRoute route = new HttpRoute(target, null, proxy, true);
+
+ final HttpRequest connect = tunnelConnectRequest(exec, route);
+
+ final Header h = connect.getFirstHeader(HttpHeaders.ALPN);
+ Assertions.assertNotNull(h, "ALPN header must be present");
+ Assertions.assertEquals("http%2F1.1", h.getValue());
+ }
+
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestConnectExec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestConnectExec.java
index 1cc9a5504b..21bfd1e4ef 100644
--- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestConnectExec.java
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestConnectExec.java
@@ -33,6 +33,7 @@
import org.apache.hc.client5.http.AuthenticationStrategy;
import org.apache.hc.client5.http.HttpRoute;
import org.apache.hc.client5.http.RouteInfo;
+import org.apache.hc.client5.http.config.TlsConfig;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.ChallengeType;
import org.apache.hc.client5.http.auth.CredentialsProvider;
@@ -47,9 +48,12 @@
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ConnectionReuseStrategy;
+import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.function.Resolver;
import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpVersion;
import org.apache.hc.core5.http.io.entity.StringEntity;
@@ -324,7 +328,6 @@ static class ConnectionState {
private boolean connected;
public Answer> connectAnswer() {
-
return invocationOnMock -> {
connected = true;
return null;
@@ -332,10 +335,70 @@ public Answer> connectAnswer() {
}
public Answer isConnectedAnswer() {
-
return invocationOnMock -> connected;
-
}
}
+ @Test
+ void testEstablishRouteViaProxyTunnelAddsAlpnHeader() throws Exception {
+ // No per-host TlsConfig resolver: the effective policy is NEGOTIATE, so the tunnel's TLS
+ // layer offers both protocols and the ALPN header advertises the same set.
+ exec = new ConnectExec(reuseStrategy, proxyHttpProcessor, proxyAuthStrategy, null, true);
+
+ final HttpRoute route = new HttpRoute(target, null, proxy, true);
+ final HttpClientContext context = HttpClientContext.create();
+ final ClassicHttpRequest request = new HttpGet("http://bar/test");
+ final ClassicHttpResponse response = new BasicClassicHttpResponse(200, "OK");
+
+ final ConnectionState connectionState = new ConnectionState();
+ Mockito.doAnswer(connectionState.connectAnswer()).when(execRuntime).connectEndpoint(Mockito.any());
+ Mockito.when(execRuntime.isEndpointConnected()).thenAnswer(connectionState.isConnectedAnswer());
+ Mockito.when(execRuntime.execute(Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(response);
+
+ final ExecChain.Scope scope = new ExecChain.Scope("test", route, request, execRuntime, context);
+ exec.execute(request, scope, execChain);
+
+ final ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(ClassicHttpRequest.class);
+ Mockito.verify(execRuntime).execute(Mockito.anyString(), reqCaptor.capture(), Mockito.same(context));
+
+ final ClassicHttpRequest connect = reqCaptor.getValue();
+ Assertions.assertEquals("CONNECT", connect.getMethod());
+ Assertions.assertEquals("foo:80", connect.getRequestUri());
+
+ final Header h = connect.getFirstHeader(HttpHeaders.ALPN);
+ Assertions.assertNotNull(h, "ALPN header must be present");
+ Assertions.assertEquals(HttpHeaders.ALPN, h.getName());
+ Assertions.assertEquals("h2, http%2F1.1", h.getValue());
+ }
+
+ @Test
+ void testEstablishRouteViaProxyTunnelAlpnHeaderReflectsVersionPolicy() throws Exception {
+ // A per-host FORCE_HTTP_1 policy must be reflected verbatim: only http/1.1 is advertised,
+ // so the header can never contradict the protocol negotiated inside the tunnel.
+ final Resolver tlsConfigResolver =
+ host -> TlsConfig.custom().setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_1).build();
+ exec = new ConnectExec(reuseStrategy, proxyHttpProcessor, proxyAuthStrategy, null, true, tlsConfigResolver);
+
+ final HttpRoute route = new HttpRoute(target, null, proxy, true);
+ final HttpClientContext context = HttpClientContext.create();
+ final ClassicHttpRequest request = new HttpGet("http://bar/test");
+ final ClassicHttpResponse response = new BasicClassicHttpResponse(200, "OK");
+
+ final ConnectionState connectionState = new ConnectionState();
+ Mockito.doAnswer(connectionState.connectAnswer()).when(execRuntime).connectEndpoint(Mockito.any());
+ Mockito.when(execRuntime.isEndpointConnected()).thenAnswer(connectionState.isConnectedAnswer());
+ Mockito.when(execRuntime.execute(Mockito.anyString(), Mockito.any(), Mockito.any())).thenReturn(response);
+
+ final ExecChain.Scope scope = new ExecChain.Scope("test", route, request, execRuntime, context);
+ exec.execute(request, scope, execChain);
+
+ final ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(ClassicHttpRequest.class);
+ Mockito.verify(execRuntime).execute(Mockito.anyString(), reqCaptor.capture(), Mockito.same(context));
+
+ final ClassicHttpRequest connect = reqCaptor.getValue();
+ final Header h = connect.getFirstHeader(HttpHeaders.ALPN);
+ Assertions.assertNotNull(h, "ALPN header must be present");
+ Assertions.assertEquals("http%2F1.1", h.getValue());
+ }
+
}