From 8bbcb52a9af44c1f0d1249b839a97afaaf7d252a Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Thu, 2 Oct 2025 14:10:23 +0200 Subject: [PATCH] Add RFC 7639 ALPN header codec; emit ALPN on CONNECT tunnels Encode protocol IDs with core's PercentCodec.HTTP_TOKEN (canonical RFC 7230 tchar form, uppercase hex) and decode strictly, rejecting malformed percent-encoding with ProtocolException. The advertised protocol set is derived from the target's HttpVersionPolicy. The connection manager resolves the effective TlsConfig and publishes the policy on HttpClientContext before the connection is established; ConnectExec and AsyncConnectExec read it back and, on secure CONNECT tunnels, advertise the same protocols the tunnel's TLS layer will offer, so the header cannot diverge from the protocol negotiated inside the tunnel. Interceptors fall back to NEGOTIATE when no policy is present on the context. --- .../client5/http/impl/AlpnHeaderSupport.java | 111 +++++++++++++ .../http/impl/async/AsyncConnectExec.java | 13 ++ .../http/impl/classic/ConnectExec.java | 47 ++---- .../PoolingHttpClientConnectionManager.java | 7 +- .../PoolingAsyncClientConnectionManager.java | 7 +- .../http/protocol/HttpClientContext.java | 23 +++ .../http/impl/AlpnHeaderSupportTest.java | 135 +++++++++++++++ .../http/impl/async/TestAsyncConnectExec.java | 155 ++++++++++++++++++ .../http/impl/classic/TestConnectExec.java | 66 +++++++- ...estPoolingHttpClientConnectionManager.java | 4 + 10 files changed, 532 insertions(+), 36 deletions(-) create mode 100644 httpclient5/src/main/java/org/apache/hc/client5/http/impl/AlpnHeaderSupport.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/AlpnHeaderSupportTest.java create mode 100644 httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncConnectExec.java 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..2ce6a97d19 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,7 @@ 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.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; @@ -76,6 +78,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; @@ -426,6 +430,15 @@ 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 published on the context by the connection manager, + // so the header cannot diverge from the protocol actually negotiated inside the tunnel. + if (scope.route.isSecure()) { + final HttpVersionPolicy configured = clientContext.getHttpVersionPolicy(); + final HttpVersionPolicy versionPolicy = configured != null ? configured : 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/classic/ConnectExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java index fc0f8d5106..422e3bb943 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,7 @@ 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.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; @@ -65,6 +67,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; @@ -139,7 +143,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 +165,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 +197,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 +220,16 @@ 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 published on the context by the connection manager, + // so the header cannot diverge from the protocol actually negotiated inside the tunnel. + if (route.isSecure()) { + final HttpVersionPolicy configured = context.getHttpVersionPolicy(); + final HttpVersionPolicy versionPolicy = configured != null ? configured : HttpVersionPolicy.NEGOTIATE; + connect.setHeader(AlpnHeaderSupport.formatValue( + Arrays.asList(H2TlsSupport.selectApplicationProtocols(versionPolicy)))); + } + this.proxyHttpProcessor.process(connect, null, context); while (response == null) { @@ -262,12 +264,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 +295,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/io/PoolingHttpClientConnectionManager.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java index 291b8e4baf..2945e8ed7d 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 @@ -51,6 +51,7 @@ import org.apache.hc.client5.http.io.HttpClientConnectionOperator; import org.apache.hc.client5.http.io.LeaseRequest; import org.apache.hc.client5.http.io.ManagedHttpClientConnection; +import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; import org.apache.hc.client5.http.ssl.TlsSocketStrategy; import org.apache.hc.core5.annotation.Contract; @@ -552,6 +553,10 @@ public void connect(final ConnectionEndpoint endpoint, final TimeValue timeout, final HttpHost firstHop = route.getProxyHost() != null ? route.getProxyHost() : route.getTargetHost(); final SocketConfig socketConfig = resolveSocketConfig(route); final ConnectionConfig connectionConfig = resolveConnectionConfig(route); + final TlsConfig tlsConfig = resolveTlsConfig(route.getTargetHost()); + if (context != null) { + HttpClientContext.cast(context).setHttpVersionPolicy(tlsConfig.getHttpVersionPolicy()); + } final Timeout connectTimeout = timeout != null ? Timeout.of(timeout.getDuration(), timeout.getTimeUnit()) : connectionConfig.getConnectTimeout(); if (LOG.isDebugEnabled()) { LOG.debug("{} connecting endpoint to {} ({})", ConnPoolSupport.getId(endpoint), firstHop, connectTimeout); @@ -565,7 +570,7 @@ public void connect(final ConnectionEndpoint endpoint, final TimeValue timeout, route.getLocalSocketAddress(), connectTimeout, socketConfig, - route.isTunnelled() ? null : resolveTlsConfig(route.getTargetHost()), + route.isTunnelled() ? null : tlsConfig, context); if (LOG.isDebugEnabled()) { LOG.debug("{} connected {}", ConnPoolSupport.getId(endpoint), ConnPoolSupport.getId(conn)); 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..6cb2b6b39f 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 @@ -50,6 +50,7 @@ import org.apache.hc.client5.http.nio.AsyncClientConnectionOperator; import org.apache.hc.client5.http.nio.AsyncConnectionEndpoint; import org.apache.hc.client5.http.nio.ManagedAsyncClientConnection; +import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; import org.apache.hc.core5.annotation.Contract; import org.apache.hc.core5.annotation.Internal; @@ -504,13 +505,17 @@ public Future connect( if (LOG.isDebugEnabled()) { LOG.debug("{} connecting endpoint to {} ({})", ConnPoolSupport.getId(endpoint), firstHop, connectTimeout); } + final TlsConfig targetTlsConfig = resolveTlsConfig(route.getTargetHost()); + if (context != null) { + HttpClientContext.cast(context).setHttpVersionPolicy(targetTlsConfig.getHttpVersionPolicy()); + } final Object connectAttachment; if (route.isTunnelled()) { connectAttachment = null; } else if (attachment instanceof TlsConfig) { connectAttachment = attachment; } else { - connectAttachment = resolveTlsConfig(route.getTargetHost()); + connectAttachment = targetTlsConfig; } final Future connectFuture = connectionOperator.connect( diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java index 575f67cd64..7ba111d4a7 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java @@ -53,6 +53,7 @@ import org.apache.hc.core5.http.config.Lookup; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http2.HttpVersionPolicy; /** * Client execution {@link HttpContext}. This class can be re-used for @@ -201,6 +202,7 @@ public static HttpClientContext create() { private AuthCache authCache; private Object userToken; private RequestConfig requestConfig; + private HttpVersionPolicy versionPolicy; /** * Stores the {@code nextnonce} value provided by the server in an HTTP response. @@ -488,6 +490,27 @@ public void setNextNonce(final String nextNonce) { this.nextNonce = nextNonce; } + /** + * Represents the {@link HttpVersionPolicy} resolved for the target of the current route. The + * connection manager populates this attribute before the connection is established so that + * protocol interceptors can act on the effective TLS policy, for instance to advertise the + * matching ALPN protocol identifiers on a {@code CONNECT} request. + * + * @since 5.7 + */ + @Internal + public HttpVersionPolicy getHttpVersionPolicy() { + return versionPolicy; + } + + /** + * @since 5.7 + */ + @Internal + public void setHttpVersionPolicy(final HttpVersionPolicy versionPolicy) { + this.versionPolicy = versionPolicy; + } + /** * Internal adaptor class that delegates all its method calls to a plain {@link HttpContext}. * To be removed in the future. 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..0551a69f5f --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncConnectExec.java @@ -0,0 +1,155 @@ +/* + * ==================================================================== + * 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.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.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, + final HttpVersionPolicy versionPolicy) throws Exception { + final HttpClientContext context = HttpClientContext.create(); + if (versionPolicy != null) { + context.setHttpVersionPolicy(versionPolicy); + } + 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 HttpVersionPolicy on the context: the interceptor falls back to 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, null); + + 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 FORCE_HTTP_1 policy published on the context must be reflected verbatim: only http/1.1 + // is advertised, so the header can never contradict the protocol negotiated inside the tunnel. + final AsyncConnectExec exec = new AsyncConnectExec(proxyHttpProcessor, proxyAuthStrategy, null, true); + final HttpRoute route = new HttpRoute(target, null, proxy, true); + + final HttpRequest connect = tunnelConnectRequest(exec, route, HttpVersionPolicy.FORCE_HTTP_1); + + 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..57a8f1be08 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 @@ -47,6 +47,7 @@ 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.http.HttpHost; @@ -55,6 +56,7 @@ import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpResponse; import org.apache.hc.core5.http.protocol.HttpProcessor; +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; @@ -324,7 +326,6 @@ static class ConnectionState { private boolean connected; public Answer connectAnswer() { - return invocationOnMock -> { connected = true; return null; @@ -332,10 +333,69 @@ public Answer connectAnswer() { } public Answer isConnectedAnswer() { - return invocationOnMock -> connected; - } } + @Test + void testEstablishRouteViaProxyTunnelAddsAlpnHeader() throws Exception { + // No HttpVersionPolicy on the context: the interceptor falls back to 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 FORCE_HTTP_1 policy published on the context must be reflected verbatim: only http/1.1 + // is advertised, so the header can never contradict the protocol negotiated inside the tunnel. + exec = new ConnectExec(reuseStrategy, proxyHttpProcessor, proxyAuthStrategy, null, true); + + final HttpRoute route = new HttpRoute(target, null, proxy, true); + final HttpClientContext context = HttpClientContext.create(); + context.setHttpVersionPolicy(HttpVersionPolicy.FORCE_HTTP_1); + 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()); + } + } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/TestPoolingHttpClientConnectionManager.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/TestPoolingHttpClientConnectionManager.java index c21c9fb82c..62f9f7c24f 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/TestPoolingHttpClientConnectionManager.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/io/TestPoolingHttpClientConnectionManager.java @@ -53,6 +53,7 @@ import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.config.Lookup; import org.apache.hc.core5.http.io.SocketConfig; +import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.hc.core5.pool.PoolEntry; import org.apache.hc.core5.pool.StrictConnPool; import org.apache.hc.core5.util.TimeValue; @@ -262,6 +263,7 @@ void testTargetConnect() throws Exception { mgr.setDefaultConnectionConfig(connectionConfig); final TlsConfig tlsConfig = TlsConfig.custom() .setHandshakeTimeout(345, TimeUnit.MILLISECONDS) + .setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_2) .build(); mgr.setDefaultTlsConfig(tlsConfig); @@ -279,6 +281,8 @@ void testTargetConnect() throws Exception { mgr.connect(endpoint1, null, context); + // connect() publishes the target's HttpVersionPolicy on the context for the interceptors + Assertions.assertEquals(HttpVersionPolicy.FORCE_HTTP_2, context.getHttpVersionPolicy()); Mockito.verify(dnsResolver, Mockito.times(1)).resolve("somehost", 8443); Mockito.verify(schemePortResolver, Mockito.times(1)).resolve(target.getSchemeName(), target); Mockito.verify(detachedSocketFactory, Mockito.times(1)).create("https", null);