Skip to content

Commit 36079e1

Browse files
arturobernalgclaude
andcommitted
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. Configure via ConnectAlpnProvider through setConnectAlpnProvider on the classic and async builders; ConnectExec/AsyncConnectExec emit the header when a provider is set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent afba680 commit 36079e1

9 files changed

Lines changed: 695 additions & 37 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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;
28+
29+
import java.util.List;
30+
31+
import org.apache.hc.core5.http.HttpHost;
32+
33+
/**
34+
* Supplies the Application-Layer Protocol Negotiation (ALPN) protocol IDs
35+
* to advertise in the HTTP {@code ALPN} header on a {@code CONNECT} request
36+
* (RFC 7639).
37+
*
38+
* <p>If this method returns {@code null} or an empty list, the client will
39+
* not add the {@code ALPN} header.</p>
40+
*
41+
* <p>Implementations should be fast and side-effect free; it may be invoked
42+
* for each CONNECT attempt.</p>
43+
*
44+
* @since 5.6
45+
*/
46+
@FunctionalInterface
47+
public interface ConnectAlpnProvider {
48+
49+
/**
50+
* Returns the ALPN protocol IDs to advertise for a tunnel to {@code target}
51+
* over the given {@code route}.
52+
*
53+
* @param target the origin server the tunnel will connect to (non-null)
54+
* @param route the planned connection route, including proxy info (non-null)
55+
* @return list of protocol IDs (e.g., {@code "h2"}, {@code "http/1.1"});
56+
* {@code null} or empty to omit the header
57+
*/
58+
List<String> getAlpnForTunnel(HttpHost target, HttpRoute route);
59+
}
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: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import java.util.concurrent.atomic.AtomicReference;
3535

3636
import org.apache.hc.client5.http.AuthenticationStrategy;
37+
import org.apache.hc.client5.http.ConnectAlpnProvider;
3738
import org.apache.hc.client5.http.EndpointInfo;
3839
import org.apache.hc.client5.http.HttpRoute;
3940
import org.apache.hc.client5.http.RouteTracker;
@@ -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;
@@ -99,18 +101,31 @@ public final class AsyncConnectExec implements AsyncExecChainHandler {
99101
private final AuthCacheKeeper authCacheKeeper;
100102
private final HttpRouteDirector routeDirector;
101103

104+
private final ConnectAlpnProvider alpnProvider;
105+
106+
102107
public AsyncConnectExec(
103108
final HttpProcessor proxyHttpProcessor,
104109
final AuthenticationStrategy proxyAuthStrategy,
105110
final SchemePortResolver schemePortResolver,
106111
final boolean authCachingDisabled) {
112+
this(proxyHttpProcessor, proxyAuthStrategy, schemePortResolver, authCachingDisabled, null);
113+
}
114+
115+
public AsyncConnectExec(
116+
final HttpProcessor proxyHttpProcessor,
117+
final AuthenticationStrategy proxyAuthStrategy,
118+
final SchemePortResolver schemePortResolver,
119+
final boolean authCachingDisabled,
120+
final ConnectAlpnProvider alpnProvider) {
107121
Args.notNull(proxyHttpProcessor, "Proxy HTTP processor");
108122
Args.notNull(proxyAuthStrategy, "Proxy authentication strategy");
109123
this.proxyHttpProcessor = proxyHttpProcessor;
110124
this.proxyAuthStrategy = proxyAuthStrategy;
111125
this.authenticator = new AuthenticationHandler();
112126
this.authCacheKeeper = authCachingDisabled ? null : new AuthCacheKeeper(schemePortResolver);
113127
this.routeDirector = BasicRouteDirector.INSTANCE;
128+
this.alpnProvider = alpnProvider;
114129
}
115130

116131
static class State {
@@ -275,7 +290,7 @@ public void cancelled() {
275290
if (LOG.isDebugEnabled()) {
276291
LOG.debug("{} create tunnel", exchangeId);
277292
}
278-
createTunnel(state, proxy, target, scope, new AsyncExecCallback() {
293+
createTunnel(state, proxy, target, route, scope, new AsyncExecCallback() {
279294

280295
@Override
281296
public AsyncDataConsumer handleResponse(final HttpResponse response, final EntityDetails entityDetails) throws HttpException, IOException {
@@ -380,6 +395,7 @@ private void createTunnel(
380395
final State state,
381396
final HttpHost proxy,
382397
final HttpHost nextHop,
398+
final HttpRoute route,
383399
final AsyncExecChain.Scope scope,
384400
final AsyncExecCallback asyncExecCallback) {
385401

@@ -426,6 +442,13 @@ public void produceRequest(final RequestChannel requestChannel,
426442
final HttpRequest connect = new BasicHttpRequest(Method.CONNECT, nextHop, nextHop.toHostString());
427443
connect.setVersion(HttpVersion.HTTP_1_1);
428444

445+
// --- RFC 7639: inject ALPN header (if provided) ----------------
446+
if (alpnProvider != null) {
447+
final List<String> alpn = alpnProvider.getAlpnForTunnel(nextHop, route);
448+
if (alpn != null && !alpn.isEmpty()) {
449+
connect.setHeader(AlpnHeaderSupport.formatValue(alpn));
450+
}
451+
}
429452
proxyHttpProcessor.process(connect, null, clientContext);
430453
authenticator.addAuthResponse(proxy, ChallengeType.PROXY, connect, proxyAuthExchange, clientContext);
431454

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import java.util.function.UnaryOperator;
4040

4141
import org.apache.hc.client5.http.AuthenticationStrategy;
42+
import org.apache.hc.client5.http.ConnectAlpnProvider;
4243
import org.apache.hc.client5.http.ConnectionKeepAliveStrategy;
4344
import org.apache.hc.client5.http.EarlyHintsListener;
4445
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
@@ -257,6 +258,8 @@ private ExecInterceptorEntry(
257258
private boolean tlsRequired;
258259

259260

261+
private ConnectAlpnProvider connectAlpnProvider;
262+
260263
/**
261264
* Maps {@code Content-Encoding} tokens to decoder factories in insertion order.
262265
*/
@@ -936,6 +939,19 @@ public final HttpAsyncClientBuilder disableRequestPriority() {
936939
return this;
937940
}
938941

942+
/**
943+
* Sets the {@link ConnectAlpnProvider} used to populate the {@code ALPN} header
944+
* (RFC 7639) on {@code CONNECT} requests when establishing an HTTP tunnel through a proxy.
945+
*
946+
* @param connectAlpnProvider the provider, or {@code null} to not send an {@code ALPN} header.
947+
* @return this builder.
948+
* @since 5.7
949+
*/
950+
public HttpAsyncClientBuilder setConnectAlpnProvider(final ConnectAlpnProvider connectAlpnProvider) {
951+
this.connectAlpnProvider = connectAlpnProvider;
952+
return this;
953+
}
954+
939955
/**
940956
* Registers a global {@link org.apache.hc.client5.http.EarlyHintsListener}
941957
* that will be notified when the client receives {@code 103 Early Hints}
@@ -1086,7 +1102,8 @@ public CloseableHttpAsyncClient build() {
10861102
new DefaultHttpProcessor(new RequestTargetHost(), new RequestUserAgent(userAgentCopy)),
10871103
proxyAuthStrategyCopy,
10881104
schemePortResolver != null ? schemePortResolver : DefaultSchemePortResolver.INSTANCE,
1089-
authCachingDisabled),
1105+
authCachingDisabled,
1106+
connectAlpnProvider),
10901107
ChainElement.CONNECT.name());
10911108

10921109
if (earlyHintsListener != null) {

0 commit comments

Comments
 (0)