You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ab...@apache.org on 2026/02/09 19:31:48 UTC
(httpcomponents-core) branch master updated: handle :protocol for extended CONNECT (#613)
This is an automated email from the ASF dual-hosted git repository.
abernal pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-core.git
The following commit(s) were added to refs/heads/master by this push:
new e056786e0 handle :protocol for extended CONNECT (#613)
e056786e0 is described below
commit e056786e08e2e0afad68ef4341a476368c86f058
Author: Arturo Bernal <ab...@apache.org>
AuthorDate: Mon Feb 9 19:31:42 2026 +0000
handle :protocol for extended CONNECT (#613)
Validate extended CONNECT requirements and emit :protocol in HTTP/2 requests.
---
.../hc/core5/http2/H2PseudoRequestHeaders.java | 4 +
.../http2/impl/DefaultH2RequestConverter.java | 66 +++++++++++--
.../http2/impl/TestDefaultH2RequestConverter.java | 105 +++++++++++++++++++++
.../impl/TestExtendedConnectRequestConverter.java | 81 ++++++++++++++++
4 files changed, 248 insertions(+), 8 deletions(-)
diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/H2PseudoRequestHeaders.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/H2PseudoRequestHeaders.java
index 0be8efc6d..7ac3b5ac7 100644
--- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/H2PseudoRequestHeaders.java
+++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/H2PseudoRequestHeaders.java
@@ -38,5 +38,9 @@ public final class H2PseudoRequestHeaders {
public static final String SCHEME = ":scheme";
public static final String AUTHORITY = ":authority";
public static final String PATH = ":path";
+ /**
+ * RFC 8441 extended CONNECT pseudo-header.
+ */
+ public static final String PROTOCOL = ":protocol";
}
diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/DefaultH2RequestConverter.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/DefaultH2RequestConverter.java
index 253b15726..1a1ef20ee 100644
--- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/DefaultH2RequestConverter.java
+++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/DefaultH2RequestConverter.java
@@ -61,6 +61,7 @@ public HttpRequest convert(final List<Header> headers) throws HttpException {
String scheme = null;
String authority = null;
String path = null;
+ String protocol = null;
final List<Header> messageHeaders = new ArrayList<>();
for (int i = 0; i < headers.size(); i++) {
@@ -98,6 +99,12 @@ public HttpRequest convert(final List<Header> headers) throws HttpException {
case H2PseudoRequestHeaders.AUTHORITY:
authority = value;
break;
+ case H2PseudoRequestHeaders.PROTOCOL:
+ if (protocol != null) {
+ throw new ProtocolException("Multiple '%s' request headers are illegal", name);
+ }
+ protocol = value;
+ break;
default:
throw new ProtocolException("Unsupported request header '%s'", name);
}
@@ -118,13 +125,26 @@ public HttpRequest convert(final List<Header> headers) throws HttpException {
if (authority == null) {
throw new ProtocolException("Header '%s' is mandatory for CONNECT request", H2PseudoRequestHeaders.AUTHORITY);
}
- if (scheme != null) {
- throw new ProtocolException("Header '%s' must not be set for CONNECT request", H2PseudoRequestHeaders.SCHEME);
- }
- if (path != null) {
- throw new ProtocolException("Header '%s' must not be set for CONNECT request", H2PseudoRequestHeaders.PATH);
+ if (protocol != null) {
+ if (scheme == null) {
+ throw new ProtocolException("Header '%s' is mandatory for extended CONNECT", H2PseudoRequestHeaders.SCHEME);
+ }
+ if (path == null) {
+ throw new ProtocolException("Header '%s' is mandatory for extended CONNECT", H2PseudoRequestHeaders.PATH);
+ }
+ validatePathPseudoHeader(method, scheme, path);
+ } else {
+ if (scheme != null) {
+ throw new ProtocolException("Header '%s' must not be set for CONNECT request", H2PseudoRequestHeaders.SCHEME);
+ }
+ if (path != null) {
+ throw new ProtocolException("Header '%s' must not be set for CONNECT request", H2PseudoRequestHeaders.PATH);
+ }
}
} else {
+ if (protocol != null) {
+ throw new ProtocolException("Header '%s' must not be set for %s request", H2PseudoRequestHeaders.PROTOCOL, method);
+ }
if (scheme == null) {
throw new ProtocolException("Mandatory request header '%s' not found", H2PseudoRequestHeaders.SCHEME);
}
@@ -143,6 +163,9 @@ public HttpRequest convert(final List<Header> headers) throws HttpException {
throw new ProtocolException(ex.getMessage(), ex);
}
httpRequest.setPath(path);
+ if (protocol != null) {
+ httpRequest.addHeader(new BasicHeader(H2PseudoRequestHeaders.PROTOCOL, protocol));
+ }
for (int i = 0; i < messageHeaders.size(); i++) {
httpRequest.addHeader(messageHeaders.get(i));
}
@@ -155,12 +178,26 @@ public List<Header> convert(final HttpRequest message) throws HttpException {
throw new ProtocolException("Request method is empty");
}
final boolean optionMethod = Method.CONNECT.name().equalsIgnoreCase(message.getMethod());
+ final Header protocolHeader = message.getFirstHeader(H2PseudoRequestHeaders.PROTOCOL);
+ final String protocol = protocolHeader != null ? protocolHeader.getValue() : null;
+ if (protocol != null && !optionMethod) {
+ throw new ProtocolException("Header name '%s' is invalid", H2PseudoRequestHeaders.PROTOCOL);
+ }
if (optionMethod) {
if (message.getAuthority() == null) {
throw new ProtocolException("CONNECT request authority is not set");
}
- if (message.getPath() != null) {
- throw new ProtocolException("CONNECT request path must be null");
+ if (protocol != null) {
+ if (TextUtils.isBlank(message.getScheme())) {
+ throw new ProtocolException("CONNECT request scheme is not set");
+ }
+ if (TextUtils.isBlank(message.getPath())) {
+ throw new ProtocolException("CONNECT request path is not set");
+ }
+ } else {
+ if (message.getPath() != null) {
+ throw new ProtocolException("CONNECT request path must be null");
+ }
}
} else {
if (TextUtils.isBlank(message.getScheme())) {
@@ -173,7 +210,14 @@ public List<Header> convert(final HttpRequest message) throws HttpException {
final List<Header> headers = new ArrayList<>();
headers.add(new BasicHeader(H2PseudoRequestHeaders.METHOD, message.getMethod(), false));
if (optionMethod) {
- headers.add(new BasicHeader(H2PseudoRequestHeaders.AUTHORITY, message.getAuthority(), false));
+ if (protocol != null) {
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.PROTOCOL, protocol, false));
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.SCHEME, message.getScheme(), false));
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.AUTHORITY, message.getAuthority(), false));
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.PATH, message.getPath(), false));
+ } else {
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.AUTHORITY, message.getAuthority(), false));
+ }
} else {
headers.add(new BasicHeader(H2PseudoRequestHeaders.SCHEME, message.getScheme(), false));
if (message.getAuthority() != null) {
@@ -186,6 +230,12 @@ public List<Header> convert(final HttpRequest message) throws HttpException {
final Header header = it.next();
final String name = header.getName();
final String value = header.getValue();
+ if (name.startsWith(":")) {
+ if (optionMethod && H2PseudoRequestHeaders.PROTOCOL.equals(name)) {
+ continue;
+ }
+ throw new ProtocolException("Header name '%s' is invalid", name);
+ }
if (!FieldValidationSupport.isNameValid(name)) {
throw new ProtocolException("Header name '%s' is invalid", name);
}
diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestDefaultH2RequestConverter.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestDefaultH2RequestConverter.java
index 3c1a03fb7..e7f4b3943 100644
--- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestDefaultH2RequestConverter.java
+++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestDefaultH2RequestConverter.java
@@ -241,6 +241,64 @@ void testConvertFromFieldsConnectPresentPath() {
"Header ':path' must not be set for CONNECT request");
}
+ @Test
+ void testConvertFromFieldsExtendedConnect() throws Exception {
+ final List<Header> headers = Arrays.asList(
+ new BasicHeader(":method", "CONNECT"),
+ new BasicHeader(":protocol", "websocket"),
+ new BasicHeader(":scheme", "https"),
+ new BasicHeader(":authority", "www.example.com"),
+ new BasicHeader(":path", "/chat"));
+
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ final HttpRequest request = converter.convert(headers);
+
+ Assertions.assertEquals("CONNECT", request.getMethod());
+ Assertions.assertEquals("https", request.getScheme());
+ Assertions.assertEquals("/chat", request.getPath());
+ Assertions.assertEquals("websocket", request.getFirstHeader(":protocol").getValue());
+ }
+
+ @Test
+ void testConvertFromFieldsExtendedConnectMissingScheme() {
+ final List<Header> headers = Arrays.asList(
+ new BasicHeader(":method", "CONNECT"),
+ new BasicHeader(":protocol", "websocket"),
+ new BasicHeader(":authority", "www.example.com"),
+ new BasicHeader(":path", "/chat"));
+
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ Assertions.assertThrows(HttpException.class, () -> converter.convert(headers),
+ "Header ':scheme' is mandatory for extended CONNECT");
+ }
+
+ @Test
+ void testConvertFromFieldsExtendedConnectMissingPath() {
+ final List<Header> headers = Arrays.asList(
+ new BasicHeader(":method", "CONNECT"),
+ new BasicHeader(":protocol", "websocket"),
+ new BasicHeader(":scheme", "https"),
+ new BasicHeader(":authority", "www.example.com"));
+
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ Assertions.assertThrows(HttpException.class, () -> converter.convert(headers),
+ "Header ':path' is mandatory for extended CONNECT");
+ }
+
+ @Test
+ void testConvertFromFieldsProtocolWithNonConnect() {
+ final List<Header> headers = Arrays.asList(
+ new BasicHeader(":method", "GET"),
+ new BasicHeader(":protocol", "websocket"),
+ new BasicHeader(":scheme", "https"),
+ new BasicHeader(":authority", "www.example.com"),
+ new BasicHeader(":path", "/"));
+
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ Assertions.assertThrows(HttpException.class, () -> converter.convert(headers),
+ "Header ':protocol' must not be set for GET request");
+ }
+
@Test
void testConvertFromMessageBasic() throws Exception {
@@ -332,6 +390,53 @@ void testConvertFromMessageConnectWithPath() {
"CONNECT request path must be null");
}
+ @Test
+ void testConvertFromMessageExtendedConnect() throws Exception {
+ final HttpRequest request = new BasicHttpRequest("CONNECT", new HttpHost("host"), "/chat");
+ request.setScheme("https");
+ request.setAuthority(new URIAuthority("host"));
+ request.addHeader(":protocol", "websocket");
+
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ final List<Header> headers = converter.convert(request);
+
+ Assertions.assertTrue(headers.stream().anyMatch(h -> ":protocol".equals(h.getName())));
+ }
+
+ @Test
+ void testConvertFromMessageExtendedConnectMissingScheme() {
+ final HttpRequest request = new BasicHttpRequest("CONNECT", new HttpHost("host"), "/chat");
+ request.setAuthority(new URIAuthority("host"));
+ request.setScheme(null);
+ request.addHeader(":protocol", "websocket");
+
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ Assertions.assertThrows(HttpException.class, () -> converter.convert(request),
+ "CONNECT request scheme is not set");
+ }
+
+ @Test
+ void testConvertFromMessageExtendedConnectMissingPath() {
+ final HttpRequest request = new BasicHttpRequest("CONNECT", new HttpHost("host"), null);
+ request.setAuthority(new URIAuthority("host"));
+ request.setScheme("https");
+ request.addHeader(":protocol", "websocket");
+
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ Assertions.assertThrows(HttpException.class, () -> converter.convert(request),
+ "CONNECT request path is not set");
+ }
+
+ @Test
+ void testConvertFromMessageProtocolWithNonConnect() {
+ final HttpRequest request = new BasicHttpRequest("GET", new HttpHost("host"), "/");
+ request.addHeader(":protocol", "websocket");
+
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ Assertions.assertThrows(HttpException.class, () -> converter.convert(request),
+ "Header name ':protocol' is invalid");
+ }
+
@Test
void testConvertFromFieldsTETrailerHeader() throws Exception {
diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestExtendedConnectRequestConverter.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestExtendedConnectRequestConverter.java
new file mode 100644
index 000000000..f46338597
--- /dev/null
+++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/TestExtendedConnectRequestConverter.java
@@ -0,0 +1,81 @@
+/*
+ * ====================================================================
+ * 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
+ * <http://www.apache.org/>.
+ *
+ */
+package org.apache.hc.core5.http2.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.apache.hc.core5.http2.H2PseudoRequestHeaders;
+import org.apache.hc.core5.net.URIAuthority;
+import org.junit.jupiter.api.Test;
+
+class TestExtendedConnectRequestConverter {
+
+ @Test
+ void parsesExtendedConnect() throws Exception {
+ final List<Header> headers = new ArrayList<>();
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.METHOD, Method.CONNECT.name(), false));
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.PROTOCOL, "websocket", false));
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.SCHEME, "https", false));
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.AUTHORITY, "example.com", false));
+ headers.add(new BasicHeader(H2PseudoRequestHeaders.PATH, "/echo", false));
+
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ final HttpRequest request = converter.convert(headers);
+ assertNotNull(request);
+ assertEquals(Method.CONNECT.name(), request.getMethod());
+ assertEquals("/echo", request.getPath());
+ assertEquals("websocket", request.getFirstHeader(H2PseudoRequestHeaders.PROTOCOL).getValue());
+ }
+
+ @Test
+ void emitsProtocolPseudoHeader() throws Exception {
+ final DefaultH2RequestConverter converter = new DefaultH2RequestConverter();
+ final HttpRequest request = new org.apache.hc.core5.http.message.BasicHttpRequest(Method.CONNECT.name(), "/echo");
+ request.setScheme("https");
+ request.setAuthority(new URIAuthority("example.com"));
+ request.setPath("/echo");
+ request.addHeader(H2PseudoRequestHeaders.PROTOCOL, "websocket");
+ final List<Header> headers = converter.convert(request);
+ boolean found = false;
+ for (final Header header : headers) {
+ if (H2PseudoRequestHeaders.PROTOCOL.equals(header.getName())) {
+ found = true;
+ break;
+ }
+ }
+ assertTrue(found);
+ }
+}