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
headers) throws HttpException { String scheme = null; String authority = null; String path = null; + String protocol = null; final List
messageHeaders = new ArrayList<>(); for (int i = 0; i < headers.size(); i++) { @@ -98,6 +99,12 @@ public HttpRequest convert(final List
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
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
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
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
convert(final HttpRequest message) throws HttpException { final List
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
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
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
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
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
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
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 + * . + * + */ +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
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
headers = converter.convert(request); + boolean found = false; + for (final Header header : headers) { + if (H2PseudoRequestHeaders.PROTOCOL.equals(header.getName())) { + found = true; + break; + } + } + assertTrue(found); + } +}