From 4eb6a6e671eb18278d0f66a5b0a9009838fa4a1f Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:25:21 +0200 Subject: [PATCH] Avoid redundant HTTP/2 name validation HTTP/2 decoding already validates response header names. Rechecking them while converting to HttpHeaders repeats per-character work for every response and trailer. Use a factory that disables only name validation while preserving value validation, including CR/LF rejection. Cover both properties with focused tests. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex --- .../netty/handler/Http2Handler.java | 35 +++++++------- .../netty/handler/Http2HandlerTest.java | 48 +++++++++++++++++++ 2 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 client/src/test/java/org/asynchttpclient/netty/handler/Http2HandlerTest.java 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..9a743159c 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,10 @@ 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.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; @@ -56,6 +57,8 @@ public final class Http2Handler extends AsyncHttpClientHandler { private static final HttpVersion HTTP_2 = new HttpVersion("HTTP", 2, 0, true); + private static final HttpHeadersFactory RESPONSE_HEADERS_FACTORY = + DefaultHttpHeadersFactory.headersFactory().withNameValidation(false); public Http2Handler(AsyncHttpClientConfig config, ChannelManager channelManager, NettyRequestSender requestSender) { super(config, channelManager, requestSender); @@ -146,14 +149,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 +218,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 = copyHttp2Headers(h2Headers); boolean abort = false; if (!trailingHeaders.isEmpty()) { @@ -240,6 +230,19 @@ private void handleHttp2TrailingHeadersFrame(Http2HeadersFrame headersFrame, Cha } } + static HttpHeaders copyHttp2Headers(Http2Headers h2Headers) { + // The HTTP/2 decoder validates names but not values by default. Avoid repeating the name scan while + // retaining the HTTP/1 value validator that rejects CR/LF and other prohibited characters. + HttpHeaders headers = RESPONSE_HEADERS_FACTORY.newHeaders(); + h2Headers.forEach(entry -> { + CharSequence name = entry.getKey(); + if (name.length() > 0 && name.charAt(0) != ':') { + headers.add(name, entry.getValue()); + } + }); + return headers; + } + /** * 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..9743d209a --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/handler/Http2HandlerTest.java @@ -0,0 +1,48 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class Http2HandlerTest { + + @Test + void copiesDecoderValidatedHeaderNamesWithoutRevalidation() { + Http2Headers source = new DefaultHttp2Headers(false) + .status("200") + .add("invalid name", "value"); + + HttpHeaders copy = Http2Handler.copyHttp2Headers(source); + + assertEquals("value", copy.get("invalid name")); + assertFalse(copy.contains(":status")); + } + + @Test + void stillValidatesHeaderValues() { + Http2Headers source = new DefaultHttp2Headers(false) + .add("x-test", "value\r\ninjected"); + + assertThrows(IllegalArgumentException.class, () -> Http2Handler.copyHttp2Headers(source)); + } +}