Skip to content

Commit d16d456

Browse files
committed
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.
1 parent a890ec7 commit d16d456

10 files changed

Lines changed: 528 additions & 36 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.http.impl;
28+
29+
import java.nio.charset.StandardCharsets;
30+
import java.util.ArrayList;
31+
import java.util.List;
32+
33+
import org.apache.hc.core5.annotation.Contract;
34+
import org.apache.hc.core5.annotation.Internal;
35+
import org.apache.hc.core5.annotation.ThreadingBehavior;
36+
import org.apache.hc.core5.http.Header;
37+
import org.apache.hc.core5.http.HttpHeaders;
38+
import org.apache.hc.core5.http.ProtocolException;
39+
import org.apache.hc.core5.http.message.MessageSupport;
40+
import org.apache.hc.core5.net.PercentCodec;
41+
import org.apache.hc.core5.util.Args;
42+
43+
/**
44+
* Codec for the HTTP {@code ALPN} header field (RFC 7639).
45+
*
46+
* @since 5.7
47+
*/
48+
@Contract(threading = ThreadingBehavior.IMMUTABLE)
49+
@Internal
50+
public final class AlpnHeaderSupport {
51+
52+
private AlpnHeaderSupport() {
53+
}
54+
55+
/**
56+
* Formats a list of raw ALPN protocol IDs into a single {@code ALPN} header.
57+
*/
58+
public static Header formatValue(final List<String> protocolIds) {
59+
Args.notEmpty(protocolIds, "protocolIds");
60+
return MessageSupport.headerOfTokens(HttpHeaders.ALPN, protocolIds, AlpnHeaderSupport::encodeId);
61+
}
62+
63+
/**
64+
* Parses an {@code ALPN} header into decoded protocol IDs.
65+
*
66+
* @throws ProtocolException if a token is not a well-formed percent-encoded protocol ID.
67+
*/
68+
public static List<String> parseValue(final Header header) throws ProtocolException {
69+
final List<String> tokens = new ArrayList<>();
70+
MessageSupport.parseTokens(header, tokens::add);
71+
final List<String> out = new ArrayList<>(tokens.size());
72+
for (final String token : tokens) {
73+
out.add(decodeId(token));
74+
}
75+
return out;
76+
}
77+
78+
/**
79+
* Encodes a single raw protocol ID to canonical token form using the HTTP token codec
80+
* from core, which keeps RFC 7230 {@code tchar} octets literal and percent-encodes the
81+
* rest (including {@code '%'}) with uppercase hexadecimal.
82+
*/
83+
public static String encodeId(final String id) {
84+
Args.notBlank(id, "id");
85+
return PercentCodec.HTTP_TOKEN.encode(id);
86+
}
87+
88+
/**
89+
* Decodes a percent-encoded token to a raw protocol ID using UTF-8.
90+
* <p>
91+
* A {@code '%'} that is not followed by two hexadecimal digits is a malformed
92+
* token and is rejected as a protocol error.
93+
*
94+
* @throws ProtocolException if the token contains malformed percent-encoding.
95+
*/
96+
public static String decodeId(final String token) throws ProtocolException {
97+
Args.notBlank(token, "token");
98+
for (int i = 0; i < token.length(); i++) {
99+
if (token.charAt(i) == '%') {
100+
if (i + 2 >= token.length()
101+
|| Character.digit(token.charAt(i + 1), 16) < 0
102+
|| Character.digit(token.charAt(i + 2), 16) < 0) {
103+
throw new ProtocolException("Malformed percent-encoding in ALPN protocol id: " + token);
104+
}
105+
i += 2;
106+
}
107+
}
108+
return PercentCodec.decode(token, StandardCharsets.UTF_8);
109+
}
110+
111+
}

httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.io.IOException;
3131
import java.io.InterruptedIOException;
3232
import java.nio.ByteBuffer;
33+
import java.util.Arrays;
3334
import java.util.List;
3435
import java.util.concurrent.atomic.AtomicReference;
3536

@@ -47,6 +48,7 @@
4748
import org.apache.hc.client5.http.auth.ChallengeType;
4849
import org.apache.hc.client5.http.auth.MalformedChallengeException;
4950
import org.apache.hc.client5.http.config.RequestConfig;
51+
import org.apache.hc.client5.http.impl.AlpnHeaderSupport;
5052
import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper;
5153
import org.apache.hc.client5.http.impl.auth.AuthenticationHandler;
5254
import org.apache.hc.client5.http.impl.routing.BasicRouteDirector;
@@ -76,6 +78,8 @@
7678
import org.apache.hc.core5.http.nio.RequestChannel;
7779
import org.apache.hc.core5.http.protocol.HttpContext;
7880
import org.apache.hc.core5.http.protocol.HttpProcessor;
81+
import org.apache.hc.core5.http2.HttpVersionPolicy;
82+
import org.apache.hc.core5.http2.ssl.H2TlsSupport;
7983
import org.apache.hc.core5.util.Args;
8084
import org.slf4j.Logger;
8185
import org.slf4j.LoggerFactory;
@@ -426,6 +430,15 @@ public void produceRequest(final RequestChannel requestChannel,
426430
final HttpRequest connect = new BasicHttpRequest(Method.CONNECT, nextHop, nextHop.toHostString());
427431
connect.setVersion(HttpVersion.HTTP_1_1);
428432

433+
// RFC 7639: advertise the same ALPN protocols the tunnel's TLS layer will offer, derived
434+
// from the target's HttpVersionPolicy published on the context by the connection manager,
435+
// so the header cannot diverge from the protocol actually negotiated inside the tunnel.
436+
if (scope.route.isSecure()) {
437+
final HttpVersionPolicy configured = clientContext.getHttpVersionPolicy();
438+
final HttpVersionPolicy versionPolicy = configured != null ? configured : HttpVersionPolicy.NEGOTIATE;
439+
connect.setHeader(AlpnHeaderSupport.formatValue(
440+
Arrays.asList(H2TlsSupport.selectApplicationProtocols(versionPolicy))));
441+
}
429442
proxyHttpProcessor.process(connect, null, clientContext);
430443
authenticator.addAuthResponse(proxy, ChallengeType.PROXY, connect, proxyAuthExchange, clientContext);
431444

httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
package org.apache.hc.client5.http.impl.classic;
2929

3030
import java.io.IOException;
31+
import java.util.Arrays;
3132

3233
import org.apache.hc.client5.http.AuthenticationStrategy;
3334
import org.apache.hc.client5.http.EndpointInfo;
@@ -40,6 +41,7 @@
4041
import org.apache.hc.client5.http.classic.ExecChainHandler;
4142
import org.apache.hc.client5.http.classic.ExecRuntime;
4243
import org.apache.hc.client5.http.config.RequestConfig;
44+
import org.apache.hc.client5.http.impl.AlpnHeaderSupport;
4345
import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper;
4446
import org.apache.hc.client5.http.impl.auth.AuthenticationHandler;
4547
import org.apache.hc.client5.http.impl.routing.BasicRouteDirector;
@@ -65,6 +67,8 @@
6567
import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
6668
import org.apache.hc.core5.http.message.StatusLine;
6769
import org.apache.hc.core5.http.protocol.HttpProcessor;
70+
import org.apache.hc.core5.http2.HttpVersionPolicy;
71+
import org.apache.hc.core5.http2.ssl.H2TlsSupport;
6872
import org.apache.hc.core5.util.Args;
6973
import org.slf4j.Logger;
7074
import org.slf4j.LoggerFactory;
@@ -139,7 +143,6 @@ public ClassicHttpResponse execute(
139143
step = this.routeDirector.nextStep(route, fact);
140144

141145
switch (step) {
142-
143146
case HttpRouteDirector.CONNECT_TARGET:
144147
execRuntime.connectEndpoint(context);
145148
tracker.connectTarget(route.isSecure());
@@ -162,11 +165,8 @@ public ClassicHttpResponse execute(
162165
}
163166
break;
164167

165-
case HttpRouteDirector.TUNNEL_PROXY: {
166-
// Proxy chains are not supported by HttpClient.
167-
// Fail fast instead of attempting an untested tunnel to an intermediate proxy.
168+
case HttpRouteDirector.TUNNEL_PROXY:
168169
throw new HttpException("Proxy chains are not supported.");
169-
}
170170

171171
case HttpRouteDirector.LAYER_PROTOCOL:
172172
execRuntime.upgradeTls(context);
@@ -197,14 +197,6 @@ public ClassicHttpResponse execute(
197197
}
198198
}
199199

200-
/**
201-
* Creates a tunnel to the target server.
202-
* The connection must be established to the (last) proxy.
203-
* A CONNECT request for tunnelling through the proxy will
204-
* be created and sent, the response received and checked.
205-
* This method does <i>not</i> processChallenge the connection with
206-
* information about the tunnel, that is left to the caller.
207-
*/
208200
private ClassicHttpResponse createTunnelToTarget(
209201
final String exchangeId,
210202
final HttpRoute route,
@@ -228,6 +220,16 @@ private ClassicHttpResponse createTunnelToTarget(
228220
final ClassicHttpRequest connect = new BasicClassicHttpRequest(Method.CONNECT, target, authority);
229221
connect.setVersion(HttpVersion.HTTP_1_1);
230222

223+
// RFC 7639: advertise the same ALPN protocols the tunnel's TLS layer will offer, derived
224+
// from the target's HttpVersionPolicy published on the context by the connection manager,
225+
// so the header cannot diverge from the protocol actually negotiated inside the tunnel.
226+
if (route.isSecure()) {
227+
final HttpVersionPolicy configured = context.getHttpVersionPolicy();
228+
final HttpVersionPolicy versionPolicy = configured != null ? configured : HttpVersionPolicy.NEGOTIATE;
229+
connect.setHeader(AlpnHeaderSupport.formatValue(
230+
Arrays.asList(H2TlsSupport.selectApplicationProtocols(versionPolicy))));
231+
}
232+
231233
this.proxyHttpProcessor.process(connect, null, context);
232234

233235
while (response == null) {
@@ -262,12 +264,10 @@ private ClassicHttpResponse createTunnelToTarget(
262264
authCacheKeeper.updateOnResponse(proxy, null, proxyAuthExchange, context);
263265
}
264266
if (updated) {
265-
// Retry request
266267
if (this.reuseStrategy.keepAlive(connect, response, context)) {
267268
if (LOG.isDebugEnabled()) {
268269
LOG.debug("{} connection kept alive", exchangeId);
269270
}
270-
// Consume response content
271271
final HttpEntity entity = response.getEntity();
272272
EntityUtils.consume(entity);
273273
} else {
@@ -295,26 +295,11 @@ private ClassicHttpResponse createTunnelToTarget(
295295
return null;
296296
}
297297

298-
/**
299-
* Creates a tunnel to an intermediate proxy.
300-
* This method is <i>not</i> implemented in this class.
301-
* It just throws an exception here.
302-
*/
303298
private boolean createTunnelToProxy(
304299
final HttpRoute route,
305300
final int hop,
306301
final HttpClientContext context) throws HttpException {
307-
308-
// Have a look at createTunnelToTarget and replicate the parts
309-
// you need in a custom derived class. If your proxies don't require
310-
// authentication, it is not too hard. But for the stock version of
311-
// HttpClient, we cannot make such simplifying assumptions and would
312-
// have to include proxy authentication code. The HttpComponents team
313-
// is currently not in a position to support rarely used code of this
314-
// complexity. Feel free to submit patches that refactor the code in
315-
// createTunnelToTarget to facilitate re-use for proxy tunnelling.
316-
317302
throw new HttpException("Proxy chains are not supported.");
318303
}
319304

320-
}
305+
}

httpclient5/src/main/java/org/apache/hc/client5/http/impl/io/PoolingHttpClientConnectionManager.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import org.apache.hc.client5.http.io.HttpClientConnectionOperator;
5252
import org.apache.hc.client5.http.io.LeaseRequest;
5353
import org.apache.hc.client5.http.io.ManagedHttpClientConnection;
54+
import org.apache.hc.client5.http.protocol.HttpClientContext;
5455
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
5556
import org.apache.hc.client5.http.ssl.TlsSocketStrategy;
5657
import org.apache.hc.core5.annotation.Contract;
@@ -552,6 +553,8 @@ public void connect(final ConnectionEndpoint endpoint, final TimeValue timeout,
552553
final HttpHost firstHop = route.getProxyHost() != null ? route.getProxyHost() : route.getTargetHost();
553554
final SocketConfig socketConfig = resolveSocketConfig(route);
554555
final ConnectionConfig connectionConfig = resolveConnectionConfig(route);
556+
final TlsConfig tlsConfig = resolveTlsConfig(route.getTargetHost());
557+
HttpClientContext.castOrCreate(context).setHttpVersionPolicy(tlsConfig.getHttpVersionPolicy());
555558
final Timeout connectTimeout = timeout != null ? Timeout.of(timeout.getDuration(), timeout.getTimeUnit()) : connectionConfig.getConnectTimeout();
556559
if (LOG.isDebugEnabled()) {
557560
LOG.debug("{} connecting endpoint to {} ({})", ConnPoolSupport.getId(endpoint), firstHop, connectTimeout);
@@ -565,7 +568,7 @@ public void connect(final ConnectionEndpoint endpoint, final TimeValue timeout,
565568
route.getLocalSocketAddress(),
566569
connectTimeout,
567570
socketConfig,
568-
route.isTunnelled() ? null : resolveTlsConfig(route.getTargetHost()),
571+
route.isTunnelled() ? null : tlsConfig,
569572
context);
570573
if (LOG.isDebugEnabled()) {
571574
LOG.debug("{} connected {}", ConnPoolSupport.getId(endpoint), ConnPoolSupport.getId(conn));

httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import org.apache.hc.client5.http.nio.AsyncClientConnectionOperator;
5151
import org.apache.hc.client5.http.nio.AsyncConnectionEndpoint;
5252
import org.apache.hc.client5.http.nio.ManagedAsyncClientConnection;
53+
import org.apache.hc.client5.http.protocol.HttpClientContext;
5354
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
5455
import org.apache.hc.core5.annotation.Contract;
5556
import org.apache.hc.core5.annotation.Internal;
@@ -504,13 +505,15 @@ public Future<AsyncConnectionEndpoint> connect(
504505
if (LOG.isDebugEnabled()) {
505506
LOG.debug("{} connecting endpoint to {} ({})", ConnPoolSupport.getId(endpoint), firstHop, connectTimeout);
506507
}
508+
final TlsConfig targetTlsConfig = resolveTlsConfig(route.getTargetHost());
509+
HttpClientContext.castOrCreate(context).setHttpVersionPolicy(targetTlsConfig.getHttpVersionPolicy());
507510
final Object connectAttachment;
508511
if (route.isTunnelled()) {
509512
connectAttachment = null;
510513
} else if (attachment instanceof TlsConfig) {
511514
connectAttachment = attachment;
512515
} else {
513-
connectAttachment = resolveTlsConfig(route.getTargetHost());
516+
connectAttachment = targetTlsConfig;
514517
}
515518

516519
final Future<ManagedAsyncClientConnection> connectFuture = connectionOperator.connect(

httpclient5/src/main/java/org/apache/hc/client5/http/protocol/HttpClientContext.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
import org.apache.hc.core5.http.config.Lookup;
5454
import org.apache.hc.core5.http.protocol.HttpContext;
5555
import org.apache.hc.core5.http.protocol.HttpCoreContext;
56+
import org.apache.hc.core5.http2.HttpVersionPolicy;
5657

5758
/**
5859
* Client execution {@link HttpContext}. This class can be re-used for
@@ -201,6 +202,7 @@ public static HttpClientContext create() {
201202
private AuthCache authCache;
202203
private Object userToken;
203204
private RequestConfig requestConfig;
205+
private HttpVersionPolicy versionPolicy;
204206

205207
/**
206208
* Stores the {@code nextnonce} value provided by the server in an HTTP response.
@@ -488,6 +490,27 @@ public void setNextNonce(final String nextNonce) {
488490
this.nextNonce = nextNonce;
489491
}
490492

493+
/**
494+
* Represents the {@link HttpVersionPolicy} resolved for the target of the current route. The
495+
* connection manager populates this attribute before the connection is established so that
496+
* protocol interceptors can act on the effective TLS policy, for instance to advertise the
497+
* matching ALPN protocol identifiers on a {@code CONNECT} request.
498+
*
499+
* @since 5.7
500+
*/
501+
@Internal
502+
public HttpVersionPolicy getHttpVersionPolicy() {
503+
return versionPolicy;
504+
}
505+
506+
/**
507+
* @since 5.7
508+
*/
509+
@Internal
510+
public void setHttpVersionPolicy(final HttpVersionPolicy versionPolicy) {
511+
this.versionPolicy = versionPolicy;
512+
}
513+
491514
/**
492515
* Internal adaptor class that delegates all its method calls to a plain {@link HttpContext}.
493516
* To be removed in the future.

0 commit comments

Comments
 (0)