diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java b/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java index b83634b68..7c581acbd 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java @@ -19,9 +19,11 @@ import io.netty.channel.Channel; import io.netty.channel.ChannelHandler.Sharable; import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.DefaultHttpHeadersFactory; import io.netty.handler.codec.http.DefaultHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpHeadersFactory; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; @@ -41,6 +43,7 @@ import org.asynchttpclient.netty.request.NettyRequestSender; import java.io.IOException; +import java.util.Map; /** * HTTP/2 channel handler for stream child channels created by {@link io.netty.handler.codec.http2.Http2MultiplexHandler}. @@ -56,6 +59,7 @@ public final class Http2Handler extends AsyncHttpClientHandler { private static final HttpVersion HTTP_2 = new HttpVersion("HTTP", 2, 0, true); + private static final HttpHeadersFactory HEADERS_FACTORY = DefaultHttpHeadersFactory.headersFactory(); public Http2Handler(AsyncHttpClientConfig config, ChannelManager channelManager, NettyRequestSender requestSender) { super(config, channelManager, requestSender); @@ -146,14 +150,7 @@ private void handleHttp2HeadersFrame(Http2HeadersFrame headersFrame, Channel cha } HttpResponseStatus nettyStatus = HttpResponseStatus.valueOf(statusCode); - // Build HTTP/1.1-style headers, skipping HTTP/2 pseudo-headers (start with ':') - HttpHeaders responseHeaders = new DefaultHttpHeaders(); - h2Headers.forEach(entry -> { - CharSequence name = entry.getKey(); - if (name.length() > 0 && name.charAt(0) != ':') { - responseHeaders.add(name, entry.getValue()); - } - }); + HttpHeaders responseHeaders = copyHttp2Headers(h2Headers); // Build a synthetic HttpResponse so the existing interceptor chain can be reused unchanged HttpResponse syntheticResponse = new DefaultHttpResponse(HTTP_2, nettyStatus, responseHeaders); @@ -222,13 +219,7 @@ private void handleHttp2TrailingHeadersFrame(Http2HeadersFrame headersFrame, Cha NettyResponseFuture future, AsyncHandler handler) throws Exception { Http2Headers h2Headers = headersFrame.headers(); - HttpHeaders trailingHeaders = new DefaultHttpHeaders(); - h2Headers.forEach(entry -> { - CharSequence name = entry.getKey(); - if (name.length() > 0 && name.charAt(0) != ':') { - trailingHeaders.add(name, entry.getValue()); - } - }); + HttpHeaders trailingHeaders = copyHttp2Trailers(h2Headers); boolean abort = false; if (!trailingHeaders.isEmpty()) { @@ -240,6 +231,37 @@ private void handleHttp2TrailingHeadersFrame(Http2HeadersFrame headersFrame, Cha } } + static HttpHeaders copyHttp2Headers(Http2Headers h2Headers) { + return copyHttp2Headers(h2Headers, false); + } + + static HttpHeaders copyHttp2Trailers(Http2Headers h2Headers) { + return copyHttp2Headers(h2Headers, true); + } + + private static HttpHeaders copyHttp2Headers(Http2Headers h2Headers, boolean trailers) { + // HTTP/2 validation does not enforce HTTP token syntax or header values, so validate both while converting. + HttpHeaders headers = HEADERS_FACTORY.newHeaders(); + for (Map.Entry entry : h2Headers) { + CharSequence name = entry.getKey(); + if (name.length() == 0 || name.charAt(0) == ':') { + continue; + } + // The restriction is on the sender; drop prohibited trailers instead of failing after delivering the body. + if (trailers && !isPermittedTrailingHeader(name)) { + continue; + } + headers.add(name, entry.getValue()); + } + return headers; + } + + private static boolean isPermittedTrailingHeader(CharSequence name) { + return !HttpHeaderNames.CONTENT_LENGTH.contentEqualsIgnoreCase(name) + && !HttpHeaderNames.TRANSFER_ENCODING.contentEqualsIgnoreCase(name) + && !HttpHeaderNames.TRAILER.contentEqualsIgnoreCase(name); + } + /** * Processes an HTTP/2 RST_STREAM frame, which indicates the server aborted the stream. */ diff --git a/client/src/test/java/org/asynchttpclient/netty/handler/Http2HandlerTest.java b/client/src/test/java/org/asynchttpclient/netty/handler/Http2HandlerTest.java new file mode 100644 index 000000000..45c04fbe9 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/handler/Http2HandlerTest.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed 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. + */ +package org.asynchttpclient.netty.handler; + +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http2.DefaultHttp2Headers; +import io.netty.handler.codec.http2.Http2Headers; +import org.junit.jupiter.api.Test; + +import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH; +import static io.netty.handler.codec.http.HttpHeaderNames.TRAILER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Http2HandlerTest { + + @Test + void copiesRegularHeadersAndSkipsPseudoHeaders() { + Http2Headers source = new DefaultHttp2Headers(false) + .status("200") + .add(CONTENT_LENGTH, "5") + .add("x-test", "value"); + + HttpHeaders copy = Http2Handler.copyHttp2Headers(source); + + assertEquals("5", copy.get(CONTENT_LENGTH)); + assertEquals("value", copy.get("x-test")); + assertFalse(copy.contains(":status")); + } + + @Test + void rejectsInvalidHeaderNames() { + Http2Headers crlf = new DefaultHttp2Headers(false) + .add("invalid\r\nname", "value"); + Http2Headers space = new DefaultHttp2Headers(false) + .add("invalid name", "value"); + Http2Headers parenthesis = new DefaultHttp2Headers(false) + .add("invalid(name", "value"); + + assertThrows(IllegalArgumentException.class, () -> Http2Handler.copyHttp2Headers(crlf)); + assertThrows(IllegalArgumentException.class, () -> Http2Handler.copyHttp2Trailers(crlf)); + assertThrows(IllegalArgumentException.class, () -> Http2Handler.copyHttp2Headers(space)); + assertThrows(IllegalArgumentException.class, () -> Http2Handler.copyHttp2Trailers(space)); + assertThrows(IllegalArgumentException.class, () -> Http2Handler.copyHttp2Headers(parenthesis)); + assertThrows(IllegalArgumentException.class, () -> Http2Handler.copyHttp2Trailers(parenthesis)); + } + + @Test + void skipsEmptyHeaderNames() { + Http2Headers source = new DefaultHttp2Headers(false) + .add("", "value"); + + assertTrue(Http2Handler.copyHttp2Headers(source).isEmpty()); + } + + @Test + void rejectsInvalidHeaderValues() { + Http2Headers source = new DefaultHttp2Headers(false) + .add("x-test", "value\r\ninjected"); + + assertThrows(IllegalArgumentException.class, () -> Http2Handler.copyHttp2Headers(source)); + assertThrows(IllegalArgumentException.class, () -> Http2Handler.copyHttp2Trailers(source)); + } + + @Test + void dropsProhibitedTrailingHeaders() { + Http2Headers source = new DefaultHttp2Headers(false) + .status("200") + .add("grpc-status", "0") + .add(CONTENT_LENGTH, "5") + .add(TRAILER, "x-checksum"); + + HttpHeaders copy = Http2Handler.copyHttp2Trailers(source); + + assertEquals("0", copy.get("grpc-status")); + assertFalse(copy.contains(CONTENT_LENGTH)); + assertFalse(copy.contains(TRAILER)); + assertFalse(copy.contains(":status")); + } +}