From a53b2bae32c66f43d1fd0d6147419a4790bacba3 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Sun, 21 Jun 2026 17:05:24 +0000 Subject: [PATCH] Match HTTP/2 content-encoding with AsciiString constants instead of toLowerCase --- .../handler/Http2ContentDecompressor.java | 24 +++++----- .../handler/Http2ContentDecompressorTest.java | 45 +++++++++++++++++++ 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/Http2ContentDecompressor.java b/client/src/main/java/org/asynchttpclient/netty/handler/Http2ContentDecompressor.java index c6c6aa71c..ecd0a7101 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/Http2ContentDecompressor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/Http2ContentDecompressor.java @@ -23,11 +23,12 @@ import io.netty.handler.codec.compression.DecompressionException; import io.netty.handler.codec.compression.JdkZlibDecoder; import io.netty.handler.codec.compression.ZlibWrapper; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http2.DefaultHttp2DataFrame; import io.netty.handler.codec.http2.Http2DataFrame; import io.netty.handler.codec.http2.Http2HeadersFrame; - -import java.util.Locale; +import io.netty.util.AsciiString; /** * HTTP/2 content decompressor that transparently decompresses gzip/deflate response bodies. @@ -59,19 +60,20 @@ public Http2ContentDecompressor(boolean keepEncodingHeader, long maxDecompressed public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof Http2HeadersFrame) { Http2HeadersFrame headersFrame = (Http2HeadersFrame) msg; - CharSequence contentEncoding = headersFrame.headers().get("content-encoding"); + CharSequence contentEncoding = headersFrame.headers().get(HttpHeaderNames.CONTENT_ENCODING); if (contentEncoding != null) { - // Locale.ROOT: a default-locale toLowerCase() would mangle "GZIP" under the Turkish locale - // ("gzıp", dotless i), so the contains() checks below would miss it and the body would be - // forwarded still-compressed. Matches the toLowerCaseHeaderName idiom on the request path. - String enc = contentEncoding.toString().toLowerCase(Locale.ROOT); - if (enc.contains("gzip") || enc.contains("deflate")) { - ZlibWrapper wrapper = enc.contains("gzip") ? ZlibWrapper.GZIP : ZlibWrapper.ZLIB_OR_NONE; + // Case-insensitive substring match against the AsciiString header-value constants in place, + // instead of allocating contentEncoding.toString().toLowerCase() per response. AsciiString + // case-folding is ASCII-only, hence locale-independent, so it also avoids the Turkish-locale + // hazard where a default-locale toLowerCase() would mangle "GZIP" and miss the match. + boolean gzip = AsciiString.containsIgnoreCase(contentEncoding, HttpHeaderValues.GZIP); + if (gzip || AsciiString.containsIgnoreCase(contentEncoding, HttpHeaderValues.DEFLATE)) { + ZlibWrapper wrapper = gzip ? ZlibWrapper.GZIP : ZlibWrapper.ZLIB_OR_NONE; decompressor = new EmbeddedChannel(false, new JdkZlibDecoder(wrapper)); if (!keepEncodingHeader) { - headersFrame.headers().remove("content-encoding"); + headersFrame.headers().remove(HttpHeaderNames.CONTENT_ENCODING); } - headersFrame.headers().remove("content-length"); + headersFrame.headers().remove(HttpHeaderNames.CONTENT_LENGTH); } } ctx.fireChannelRead(msg); diff --git a/client/src/test/java/org/asynchttpclient/netty/handler/Http2ContentDecompressorTest.java b/client/src/test/java/org/asynchttpclient/netty/handler/Http2ContentDecompressorTest.java index 928e53772..a9ef4b7a3 100644 --- a/client/src/test/java/org/asynchttpclient/netty/handler/Http2ContentDecompressorTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/handler/Http2ContentDecompressorTest.java @@ -34,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -60,6 +61,50 @@ private static ByteBuf gzip(String s) throws Exception { return Unpooled.wrappedBuffer(gzipBytes(s)); } + // Returns true iff the decompressor installed a decoder for the given content-encoding, detected by + // content-length being stripped (the code removes it only when it activates). Guards that the switch to + // AsciiString.containsIgnoreCase preserves the old toLowerCase().contains() activation set exactly. + private static boolean activatesDecompression(String contentEncoding) { + DefaultHttp2Headers headers = new DefaultHttp2Headers(); + headers.status("200"); + if (contentEncoding != null) { + headers.set("content-encoding", contentEncoding); + } + headers.set("content-length", "123"); + EmbeddedChannel ch = new EmbeddedChannel(new Http2ContentDecompressor(false)); + try { + ch.writeInbound(new DefaultHttp2HeadersFrame(headers, false)); + Http2HeadersFrame out = ch.readInbound(); + boolean clRemoved = !out.headers().contains("content-length"); + if (clRemoved) { + assertNull(out.headers().get("content-encoding"), + "content-encoding must be stripped when decompression activates and keepEncodingHeader=false"); + } + return clRemoved; + } finally { + ch.finishAndReleaseAll(); + } + } + + @Test + public void activatesForGzipDeflateCaseInsensitivelyAndSubstrings() { + // Case-insensitive substring match, identical to the old contains() logic. + assertTrue(activatesDecompression("gzip")); + assertTrue(activatesDecompression("GZIP")); + assertTrue(activatesDecompression("x-gzip")); + assertTrue(activatesDecompression("deflate")); + assertTrue(activatesDecompression("DEFLATE")); + assertTrue(activatesDecompression("gzip, deflate")); + } + + @Test + public void doesNotActivateForOtherOrAbsentEncodings() { + assertFalse(activatesDecompression(null), "no content-encoding -> no decompression"); + assertFalse(activatesDecompression("br"), "brotli is not handled by this decompressor"); + assertFalse(activatesDecompression("zstd"), "zstd is not handled by this decompressor"); + assertFalse(activatesDecompression("identity")); + } + @Test public void decompressesValidGzipBodyAndReleasesFrames() throws Exception { EmbeddedChannel ch = new EmbeddedChannel(new Http2ContentDecompressor(false));