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);
+    }
+}