diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/AlpnHeaderSupport.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/AlpnHeaderSupport.java new file mode 100644 index 0000000000..8fa2868da7 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/AlpnHeaderSupport.java @@ -0,0 +1,111 @@ +/* + * ==================================================================== + * 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 java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +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.http.Header; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.ProtocolException; +import org.apache.hc.core5.http.message.MessageSupport; +import org.apache.hc.core5.net.PercentCodec; +import org.apache.hc.core5.util.Args; + +/** + * Codec for the HTTP {@code ALPN} header field (RFC 7639). + * + * @since 5.7 + */ +@Contract(threading = ThreadingBehavior.IMMUTABLE) +@Internal +public final class AlpnHeaderSupport { + + private AlpnHeaderSupport() { + } + + /** + * Formats a list of raw ALPN protocol IDs into a single {@code ALPN} header. + */ + public static Header formatValue(final List protocolIds) { + Args.notEmpty(protocolIds, "protocolIds"); + return MessageSupport.headerOfTokens(HttpHeaders.ALPN, protocolIds, AlpnHeaderSupport::encodeId); + } + + /** + * Parses an {@code ALPN} header into decoded protocol IDs. + * + * @throws ProtocolException if a token is not a well-formed percent-encoded protocol ID. + */ + public static List parseValue(final Header header) throws ProtocolException { + final List tokens = new ArrayList<>(); + MessageSupport.parseTokens(header, tokens::add); + final List 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()); + } + }