From 35865e2932adce24ae4e34300930eadfa5ee6ab8 Mon Sep 17 00:00:00 2001 From: Netty Project Bot Date: Tue, 2 Jun 2026 19:06:49 +0000 Subject: [PATCH 01/64] [maven-release-plugin] prepare for next development iteration --- all/pom.xml | 2 +- bom/pom.xml | 4 ++-- buffer/pom.xml | 2 +- codec-dns/pom.xml | 2 +- codec-haproxy/pom.xml | 2 +- codec-http/pom.xml | 2 +- codec-http2/pom.xml | 2 +- codec-memcache/pom.xml | 2 +- codec-mqtt/pom.xml | 2 +- codec-redis/pom.xml | 2 +- codec-smtp/pom.xml | 2 +- codec-socks/pom.xml | 2 +- codec-stomp/pom.xml | 2 +- codec-xml/pom.xml | 2 +- codec/pom.xml | 2 +- common/pom.xml | 2 +- dev-tools/pom.xml | 2 +- example/pom.xml | 2 +- handler-proxy/pom.xml | 2 +- handler-ssl-ocsp/pom.xml | 2 +- handler/pom.xml | 2 +- microbench/pom.xml | 2 +- pom.xml | 4 ++-- resolver-dns-classes-macos/pom.xml | 2 +- resolver-dns-native-macos/pom.xml | 2 +- resolver-dns/pom.xml | 2 +- resolver/pom.xml | 2 +- testsuite-autobahn/pom.xml | 2 +- testsuite-http2/pom.xml | 2 +- testsuite-native-image-client-runtime-init/pom.xml | 2 +- testsuite-native-image-client/pom.xml | 2 +- testsuite-native-image/pom.xml | 2 +- testsuite-native/pom.xml | 2 +- testsuite-osgi/pom.xml | 2 +- testsuite-shading/pom.xml | 2 +- testsuite/pom.xml | 2 +- transport-blockhound-tests/pom.xml | 2 +- transport-classes-epoll/pom.xml | 2 +- transport-classes-kqueue/pom.xml | 2 +- transport-native-epoll/pom.xml | 2 +- transport-native-kqueue/pom.xml | 2 +- transport-native-unix-common-tests/pom.xml | 2 +- transport-native-unix-common/pom.xml | 2 +- transport-rxtx/pom.xml | 2 +- transport-sctp/pom.xml | 2 +- transport-udt/pom.xml | 2 +- transport/pom.xml | 2 +- 47 files changed, 49 insertions(+), 49 deletions(-) diff --git a/all/pom.xml b/all/pom.xml index ba61ac757d9..416982a55aa 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-all diff --git a/bom/pom.xml b/bom/pom.xml index 62a1818a333..aef471de41b 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -25,7 +25,7 @@ io.netty netty-bom - 4.1.135.Final + 4.1.136.Final-SNAPSHOT pom Netty/BOM @@ -49,7 +49,7 @@ https://github.com/netty/netty scm:git:git://github.com/netty/netty.git scm:git:ssh://git@github.com/netty/netty.git - netty-4.1.135.Final + HEAD diff --git a/buffer/pom.xml b/buffer/pom.xml index e0d88daaba2..3147b039745 100644 --- a/buffer/pom.xml +++ b/buffer/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-buffer diff --git a/codec-dns/pom.xml b/codec-dns/pom.xml index 6e13b157839..7c3dbdf11a0 100644 --- a/codec-dns/pom.xml +++ b/codec-dns/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-dns diff --git a/codec-haproxy/pom.xml b/codec-haproxy/pom.xml index cbdd73a59df..c610bced002 100644 --- a/codec-haproxy/pom.xml +++ b/codec-haproxy/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-haproxy diff --git a/codec-http/pom.xml b/codec-http/pom.xml index 14bf0988058..f74caa386bc 100644 --- a/codec-http/pom.xml +++ b/codec-http/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-http diff --git a/codec-http2/pom.xml b/codec-http2/pom.xml index b644004921d..6afce399178 100644 --- a/codec-http2/pom.xml +++ b/codec-http2/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-http2 diff --git a/codec-memcache/pom.xml b/codec-memcache/pom.xml index 32096c69d96..05d21ea8eb5 100644 --- a/codec-memcache/pom.xml +++ b/codec-memcache/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-memcache diff --git a/codec-mqtt/pom.xml b/codec-mqtt/pom.xml index 936e291b158..afbd86140f9 100644 --- a/codec-mqtt/pom.xml +++ b/codec-mqtt/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-mqtt diff --git a/codec-redis/pom.xml b/codec-redis/pom.xml index 0d8c15bc0ac..721e848c5e8 100644 --- a/codec-redis/pom.xml +++ b/codec-redis/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-redis diff --git a/codec-smtp/pom.xml b/codec-smtp/pom.xml index f5abadd2e22..5c415fe4a79 100644 --- a/codec-smtp/pom.xml +++ b/codec-smtp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-smtp diff --git a/codec-socks/pom.xml b/codec-socks/pom.xml index 71f1f68f55e..65d5a5b747d 100644 --- a/codec-socks/pom.xml +++ b/codec-socks/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-socks diff --git a/codec-stomp/pom.xml b/codec-stomp/pom.xml index 7c77b0434d1..e81b45807cb 100644 --- a/codec-stomp/pom.xml +++ b/codec-stomp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-stomp diff --git a/codec-xml/pom.xml b/codec-xml/pom.xml index bf04df20fe7..7fc33662b62 100644 --- a/codec-xml/pom.xml +++ b/codec-xml/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec-xml diff --git a/codec/pom.xml b/codec/pom.xml index 8709ebcfe32..6538a110f5e 100644 --- a/codec/pom.xml +++ b/codec/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-codec diff --git a/common/pom.xml b/common/pom.xml index 6e0ea10f510..a2b3bec19c2 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-common diff --git a/dev-tools/pom.xml b/dev-tools/pom.xml index 2710d318477..b87feb44fd4 100644 --- a/dev-tools/pom.xml +++ b/dev-tools/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-dev-tools diff --git a/example/pom.xml b/example/pom.xml index 9b2befeaf02..288e2d82547 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-example diff --git a/handler-proxy/pom.xml b/handler-proxy/pom.xml index 08b506272ca..89a75978298 100644 --- a/handler-proxy/pom.xml +++ b/handler-proxy/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-handler-proxy diff --git a/handler-ssl-ocsp/pom.xml b/handler-ssl-ocsp/pom.xml index 9e8691859e0..3791a8679a2 100644 --- a/handler-ssl-ocsp/pom.xml +++ b/handler-ssl-ocsp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-handler-ssl-ocsp diff --git a/handler/pom.xml b/handler/pom.xml index 9b8e6ad8340..3b1ce7cff83 100644 --- a/handler/pom.xml +++ b/handler/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-handler diff --git a/microbench/pom.xml b/microbench/pom.xml index 4ff731da7e4..3a4d23e249c 100644 --- a/microbench/pom.xml +++ b/microbench/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-microbench diff --git a/pom.xml b/pom.xml index 91d6fee8fca..28f5f7264fd 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ io.netty netty-parent pom - 4.1.135.Final + 4.1.136.Final-SNAPSHOT Netty https://netty.io/ @@ -53,7 +53,7 @@ https://github.com/netty/netty scm:git:git://github.com/netty/netty.git scm:git:ssh://git@github.com/netty/netty.git - netty-4.1.135.Final + HEAD diff --git a/resolver-dns-classes-macos/pom.xml b/resolver-dns-classes-macos/pom.xml index a60e38e31a8..9384ad80e3d 100644 --- a/resolver-dns-classes-macos/pom.xml +++ b/resolver-dns-classes-macos/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-resolver-dns-classes-macos diff --git a/resolver-dns-native-macos/pom.xml b/resolver-dns-native-macos/pom.xml index 3c8b517c664..801e4eb57bf 100644 --- a/resolver-dns-native-macos/pom.xml +++ b/resolver-dns-native-macos/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-resolver-dns-native-macos diff --git a/resolver-dns/pom.xml b/resolver-dns/pom.xml index e0f2ea79072..d46d5c397d2 100644 --- a/resolver-dns/pom.xml +++ b/resolver-dns/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-resolver-dns diff --git a/resolver/pom.xml b/resolver/pom.xml index b8e978e0d3b..1d9f846b6ce 100644 --- a/resolver/pom.xml +++ b/resolver/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-resolver diff --git a/testsuite-autobahn/pom.xml b/testsuite-autobahn/pom.xml index 1b8b7562649..7e5560b16e6 100644 --- a/testsuite-autobahn/pom.xml +++ b/testsuite-autobahn/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-testsuite-autobahn diff --git a/testsuite-http2/pom.xml b/testsuite-http2/pom.xml index 44e7cdc9b0e..ad05f562747 100644 --- a/testsuite-http2/pom.xml +++ b/testsuite-http2/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-testsuite-http2 diff --git a/testsuite-native-image-client-runtime-init/pom.xml b/testsuite-native-image-client-runtime-init/pom.xml index 5520fd8bbac..7d351cea009 100644 --- a/testsuite-native-image-client-runtime-init/pom.xml +++ b/testsuite-native-image-client-runtime-init/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-testsuite-native-image-client-runtime-init diff --git a/testsuite-native-image-client/pom.xml b/testsuite-native-image-client/pom.xml index 481a333e820..263bda53834 100644 --- a/testsuite-native-image-client/pom.xml +++ b/testsuite-native-image-client/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-testsuite-native-image-client diff --git a/testsuite-native-image/pom.xml b/testsuite-native-image/pom.xml index e147b5927e1..86c76aeecfa 100644 --- a/testsuite-native-image/pom.xml +++ b/testsuite-native-image/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-testsuite-native-image diff --git a/testsuite-native/pom.xml b/testsuite-native/pom.xml index 5e40ccd6acc..3493b181b15 100644 --- a/testsuite-native/pom.xml +++ b/testsuite-native/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-testsuite-native diff --git a/testsuite-osgi/pom.xml b/testsuite-osgi/pom.xml index 9c939e1eb89..c2fe1f0fc13 100644 --- a/testsuite-osgi/pom.xml +++ b/testsuite-osgi/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-testsuite-osgi diff --git a/testsuite-shading/pom.xml b/testsuite-shading/pom.xml index f6dd639f982..a447fb79fdf 100644 --- a/testsuite-shading/pom.xml +++ b/testsuite-shading/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-testsuite-shading diff --git a/testsuite/pom.xml b/testsuite/pom.xml index 3ecb0d3a819..5a07dd30aae 100644 --- a/testsuite/pom.xml +++ b/testsuite/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-testsuite diff --git a/transport-blockhound-tests/pom.xml b/transport-blockhound-tests/pom.xml index f53dd286c8c..5ad1d954c86 100644 --- a/transport-blockhound-tests/pom.xml +++ b/transport-blockhound-tests/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-blockhound-tests diff --git a/transport-classes-epoll/pom.xml b/transport-classes-epoll/pom.xml index 0f1ba0d857c..eeafc8819b2 100644 --- a/transport-classes-epoll/pom.xml +++ b/transport-classes-epoll/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-classes-epoll diff --git a/transport-classes-kqueue/pom.xml b/transport-classes-kqueue/pom.xml index ac36fc53ea6..65f4d75ae10 100644 --- a/transport-classes-kqueue/pom.xml +++ b/transport-classes-kqueue/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-classes-kqueue diff --git a/transport-native-epoll/pom.xml b/transport-native-epoll/pom.xml index e8f671660d9..878dddc6714 100644 --- a/transport-native-epoll/pom.xml +++ b/transport-native-epoll/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-native-epoll diff --git a/transport-native-kqueue/pom.xml b/transport-native-kqueue/pom.xml index 8894495c2d8..7c226f3bc7c 100644 --- a/transport-native-kqueue/pom.xml +++ b/transport-native-kqueue/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-native-kqueue diff --git a/transport-native-unix-common-tests/pom.xml b/transport-native-unix-common-tests/pom.xml index 2849aab4873..76141d4f1b5 100644 --- a/transport-native-unix-common-tests/pom.xml +++ b/transport-native-unix-common-tests/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-native-unix-common-tests diff --git a/transport-native-unix-common/pom.xml b/transport-native-unix-common/pom.xml index 3a359808679..f84d708f384 100644 --- a/transport-native-unix-common/pom.xml +++ b/transport-native-unix-common/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-native-unix-common diff --git a/transport-rxtx/pom.xml b/transport-rxtx/pom.xml index 135e0e3344f..4bc688ce5d1 100644 --- a/transport-rxtx/pom.xml +++ b/transport-rxtx/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-rxtx diff --git a/transport-sctp/pom.xml b/transport-sctp/pom.xml index 2b96e00b4dc..42d466325da 100644 --- a/transport-sctp/pom.xml +++ b/transport-sctp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-sctp diff --git a/transport-udt/pom.xml b/transport-udt/pom.xml index 7c0d3cdc938..2ea026fc40a 100644 --- a/transport-udt/pom.xml +++ b/transport-udt/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport-udt diff --git a/transport/pom.xml b/transport/pom.xml index 3075d32dd83..0920d34ae35 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.135.Final + 4.1.136.Final-SNAPSHOT netty-transport From 344d6db69a2eb2266e1cc0cb168b6309821baf77 Mon Sep 17 00:00:00 2001 From: Guimu <30684111+daguimu@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:31:02 +0800 Subject: [PATCH 02/64] SingleThreadEventExecutor: document Throwable safety contract on run() (#16814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: Subclasses of `SingleThreadEventExecutor` can silently take down the event-loop thread — and with it every `Channel` registered to that loop — if their `run()` implementation lets a `Throwable` escape from a task invocation. The default helpers (`runAllTasks*`, `safeExecute`) catch `Throwable` correctly, but the abstract `run()` contract gives no hint that this is a hard requirement; the existing one-line javadoc just says "Run the tasks in the taskQueue". Subclassers writing bespoke task loops on top of `pollTask`/`takeTask` have no guidance — see #16102 for the full report. Modification: Expand the javadoc on `SingleThreadEventExecutor#run()` to spell out: - `run()` must keep going until `confirmShutdown()` returns `true`; - an uncaught `Throwable` terminates the event-loop thread and silently breaks every `Channel` registered to it; - `runAllTasks()`, `runAllTasks(long)`, and `safeExecute(Runnable)` already handle `Throwable`, so prefer them; - custom loops built on `pollTask()`/`takeTask()` must wrap each task invocation themselves. No code change. Result: Implementers of `SingleThreadEventExecutor` see the safety contract on the method they are required to override, rather than discovering the failure mode in production. Refs #16102. The optional "Defensive Mechanism" piece (enforce an `UncaughtExceptionHandler` on event-loop threads) from the original issue is intentionally out of scope here — happy to do that as a follow-up if maintainers want it. --------- Co-authored-by: Norman Maurer --- .../util/concurrent/SingleThreadEventExecutor.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/io/netty/util/concurrent/SingleThreadEventExecutor.java b/common/src/main/java/io/netty/util/concurrent/SingleThreadEventExecutor.java index cd4b40e2721..03b49d0b213 100644 --- a/common/src/main/java/io/netty/util/concurrent/SingleThreadEventExecutor.java +++ b/common/src/main/java/io/netty/util/concurrent/SingleThreadEventExecutor.java @@ -537,7 +537,16 @@ protected void updateLastExecutionTime() { } /** - * Run the tasks in the {@link #taskQueue} + * Runs the task-processing loop until {@link #confirmShutdown()} returns {@code true}. + * + *

Implementations must not let a {@link Throwable} thrown by a task escape this + * method: any uncaught {@link Throwable} terminates the executor (logged at {@code WARN} + * and surfaced via {@link #terminationFuture()}), at which point every {@code Channel} + * registered with this executor stops processing I/O and new task submissions are rejected. + * The supplied helpers - {@link #runAllTasks()}, {@link #runAllTasks(long)}, and + * {@link #safeExecute(Runnable)} - catch {@code Throwable} for you; custom loops built on + * {@link #pollTask()} or {@link #takeTask()} are responsible for wrapping each task + * invocation accordingly. */ protected abstract void run(); From 13b6e99af1ade799a4aee10dde16341b12105db2 Mon Sep 17 00:00:00 2001 From: Guimu <30684111+daguimu@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:06:56 +0800 Subject: [PATCH 03/64] Make HTTP/2 frame hashCode consistent with equals (#16692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `AbstractHttp2StreamFrame` and `DefaultHttp2PingFrame` both violate the `Object` contract that requires `a.equals(b) => a.hashCode() == b.hashCode()`: ```java // AbstractHttp2StreamFrame class C extends AbstractHttp2StreamFrame { @Override public String name() { return null; } } Object o1 = new C(); Object o2 = new C(); o1.equals(o2); // true — both have null stream o1.hashCode() == o2.hashCode(); // false — identity hash from Object.hashCode() ``` ```java // DefaultHttp2PingFrame Object o1 = new DefaultHttp2PingFrame(1, true); Object o2 = new DefaultHttp2PingFrame(1, true); o1.equals(o2); // true — equal ack and content o1.hashCode() == o2.hashCode(); // false — hash seeded with identity from Object.hashCode() ``` Any code that puts these frames into a `HashMap`/`HashSet` silently fails to find entries it just inserted. ## Root Cause Both classes call `super.hashCode()` in paths where no struct­ural hash is available. `super.hashCode()` resolves to `Object.hashCode()`, which returns a per-instance identity hash, so it diverges from `equals()` which compares structural fields (stream for `AbstractHttp2StreamFrame`; ack and content for `DefaultHttp2PingFrame`). In the ping frame the problem is compounded: `content` is not folded into the hash at all, so pings differing only in content also collide. ```java // AbstractHttp2StreamFrame.hashCode (before) if (stream == null) { return super.hashCode(); // identity hash } return stream.hashCode(); ``` ```java // DefaultHttp2PingFrame.hashCode (before) int hash = super.hashCode(); // identity hash hash = hash * 31 + (ack ? 1 : 0); return hash; ``` ## Fix - `AbstractHttp2StreamFrame.hashCode`: when `stream` is `null`, return `0` (a constant) so two frames whose `equals()` returns `true` via `null == null` also produce the same hash. - `DefaultHttp2PingFrame.hashCode`: fold the full `content` and `ack` into the hash. Use the same `(int)(v ^ v >>> 32)` pattern already used by `DefaultHttp2ResetFrame.hashCode` to fold the `long` for Java 8 consistency, then combine with `ack` via the standard `hash * 31 + field` pattern. `AbstractHttp2StreamFrame` is the base of `DefaultHttp2DataFrame`, `DefaultHttp2ResetFrame`, `DefaultHttp2HeadersFrame`, `DefaultHttp2PriorityFrame`, and `DefaultHttp2WindowUpdateFrame`, all of which start their own `hashCode` with `int hash = super.hashCode();`. Fixing the base class transitively fixes them: when their `stream` is null, the base now contributes a deterministic value rather than identity. ## Tests Added | Change point | Test | |---|---| | `AbstractHttp2StreamFrame.hashCode` null-stream branch | `testAbstractHttp2StreamFrameEqualInstancesHaveEqualHashCodes` — two anonymous subclass instances with null stream are `.equals()` true and their hashes match. | | `DefaultHttp2PingFrame.hashCode` contract | `testDefaultHttp2PingFrameEqualInstancesHaveEqualHashCodes` — two pings with the same `ack` and `content` are `.equals()` true and their hashes match. | | Regression (sanity) for ping hash | `testDefaultHttp2PingFrameHashCodeDistinguishesDifferentValues` — pings that differ in `content` or `ack` do not trivially collide. | All 195 tests in the HTTP/2 frame test set pass locally (0 failures / 0 errors / 2 pre-existing skips). Each failing test was verified to reproduce the contract violation against the pre-fix code (hash mismatch for equal instances). ## Impact - `AbstractHttp2StreamFrame` subclasses (`DefaultHttp2DataFrame`, `DefaultHttp2ResetFrame`, `DefaultHttp2HeadersFrame`, `DefaultHttp2PriorityFrame`, `DefaultHttp2WindowUpdateFrame`) and `DefaultHttp2PingFrame` can now be used as keys in hashed collections. - The concrete hash values change for instances whose `stream` is `null` — any caller that serialized or persisted a hash-code externally would see a difference, but no public API, no `equals()` semantics, and no wire format changes. Fixes #13659 --- .../codec/http2/AbstractHttp2StreamFrame.java | 3 +- .../codec/http2/DefaultHttp2PingFrame.java | 3 +- .../codec/http2/Http2DefaultFramesTest.java | 46 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/AbstractHttp2StreamFrame.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/AbstractHttp2StreamFrame.java index 8d23c120f02..2590a85f606 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/AbstractHttp2StreamFrame.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/AbstractHttp2StreamFrame.java @@ -48,8 +48,9 @@ public boolean equals(Object o) { @Override public int hashCode() { Http2FrameStream stream = this.stream; + // Must be consistent with equals; super.hashCode() is Object's identity hash. if (stream == null) { - return super.hashCode(); + return 0; } return stream.hashCode(); } diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2PingFrame.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2PingFrame.java index 2ef44858aa3..5b548a654d9 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2PingFrame.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2PingFrame.java @@ -61,7 +61,8 @@ public boolean equals(Object o) { @Override public int hashCode() { - int hash = super.hashCode(); + // Must be consistent with equals; super.hashCode() is Object's identity hash. + int hash = (int) (content ^ content >>> 32); hash = hash * 31 + (ack ? 1 : 0); return hash; } diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2DefaultFramesTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2DefaultFramesTest.java index aeb3c243376..06783554046 100644 --- a/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2DefaultFramesTest.java +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2DefaultFramesTest.java @@ -19,7 +19,9 @@ import io.netty.buffer.Unpooled; 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.assertNotEquals; public class Http2DefaultFramesTest { @@ -41,4 +43,48 @@ public void testEqualOperation() { dflt.release(); } } + + // Reproduces https://github.com/netty/netty/issues/13659 + // AbstractHttp2StreamFrame.equals() treats two frames with a null stream as equal, but + // AbstractHttp2StreamFrame.hashCode() previously returned super.hashCode() (identity hash) + // in that case, producing different hashes for equal instances and violating the + // Object.hashCode contract. + @Test + public void testAbstractHttp2StreamFrameEqualInstancesHaveEqualHashCodes() { + AbstractHttp2StreamFrame a = new TestStreamFrame(); + AbstractHttp2StreamFrame b = new TestStreamFrame(); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + // Reproduces https://github.com/netty/netty/issues/13659 for DefaultHttp2PingFrame. + // equals() compares ack and content; before the fix hashCode() folded in the identity + // hash of Object, so two equal pings had different hash codes. + @Test + public void testDefaultHttp2PingFrameEqualInstancesHaveEqualHashCodes() { + DefaultHttp2PingFrame a = new DefaultHttp2PingFrame(42L, true); + DefaultHttp2PingFrame b = new DefaultHttp2PingFrame(42L, true); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + // Smoke test that the hash actually folds in content and ack. This is a regression guard + // against the pre-fix implementation, which seeded the hash with Object identity and never + // folded content into the result at all. Specific hashCode values are an implementation + // detail; we only assert that the chosen widely-spread inputs do not collide. + @Test + public void testDefaultHttp2PingFrameHashCodeDistinguishesDifferentValues() { + DefaultHttp2PingFrame a = new DefaultHttp2PingFrame(0L, false); + DefaultHttp2PingFrame differentContent = new DefaultHttp2PingFrame(Long.MAX_VALUE, false); + DefaultHttp2PingFrame differentAck = new DefaultHttp2PingFrame(0L, true); + assertNotEquals(a.hashCode(), differentContent.hashCode()); + assertNotEquals(a.hashCode(), differentAck.hashCode()); + } + + private static final class TestStreamFrame extends AbstractHttp2StreamFrame { + @Override + public String name() { + return "TEST"; + } + } } From 66cb6f1d37994d72f4c1419a4d95ff39f36fd6aa Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Fri, 5 Jun 2026 09:57:08 -0700 Subject: [PATCH 04/64] Add BlockHound exception for DnsQueryIdSpace (#16896) (#16915) Motivation: DnsQueryIdSpace uses SecureRandom.nextBytes. This can call java.io.FileInputStream#readBytes, which triggers a BlockingOperationError when BlockHound is enabled. Modification: - Allow blocking calls in DnsQueryIdSpace#nextId and DnsQueryIdSpace$DnsQueryIdRange#pushId. - Add testDnsNameResolverAllowsBlockingCalls to verify that DnsNameResolver does not trigger BlockHound exceptions. Result: No BlockHound exceptions during DNS resolution. Co-authored-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../java/io/netty/util/internal/Hidden.java | 8 ++++ .../NettyBlockHoundIntegrationTest.java | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/common/src/main/java/io/netty/util/internal/Hidden.java b/common/src/main/java/io/netty/util/internal/Hidden.java index e227c90c7ef..bdc32dad601 100644 --- a/common/src/main/java/io/netty/util/internal/Hidden.java +++ b/common/src/main/java/io/netty/util/internal/Hidden.java @@ -164,6 +164,14 @@ public void applyTo(BlockHound.Builder builder) { "io.netty.resolver.dns.UnixResolverDnsServerAddressStreamProvider", "parseEtcResolverOptions"); + builder.allowBlockingCallsInside( + "io.netty.resolver.dns.DnsQueryIdSpace", + "nextId"); + + builder.allowBlockingCallsInside( + "io.netty.resolver.dns.DnsQueryIdSpace$DnsQueryIdRange", + "pushId"); + builder.allowBlockingCallsInside( "io.netty.resolver.HostsFileEntriesProvider$ParserImpl", "parse"); diff --git a/transport-blockhound-tests/src/test/java/io/netty/util/internal/NettyBlockHoundIntegrationTest.java b/transport-blockhound-tests/src/test/java/io/netty/util/internal/NettyBlockHoundIntegrationTest.java index ed0845ac0ee..66e169a11dc 100644 --- a/transport-blockhound-tests/src/test/java/io/netty/util/internal/NettyBlockHoundIntegrationTest.java +++ b/transport-blockhound-tests/src/test/java/io/netty/util/internal/NettyBlockHoundIntegrationTest.java @@ -38,6 +38,7 @@ import io.netty.handler.ssl.SslProvider; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import io.netty.handler.ssl.util.SelfSignedCertificate; +import io.netty.resolver.dns.DnsNameResolver; import io.netty.resolver.dns.DnsNameResolverBuilder; import io.netty.resolver.dns.DnsServerAddressStreamProviders; import io.netty.util.HashedWheelTimer; @@ -45,6 +46,7 @@ import io.netty.util.concurrent.DefaultThreadFactory; import io.netty.util.concurrent.EventExecutor; import io.netty.util.concurrent.FastThreadLocalThread; +import io.netty.util.concurrent.GenericFutureListener; import io.netty.util.concurrent.GlobalEventExecutor; import io.netty.util.concurrent.ImmediateEventExecutor; import io.netty.util.concurrent.ImmediateExecutor; @@ -58,6 +60,7 @@ import reactor.blockhound.BlockingOperationError; import reactor.blockhound.integration.BlockHoundIntegration; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.List; @@ -72,6 +75,7 @@ import java.util.concurrent.Future; import java.util.concurrent.FutureTask; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -435,6 +439,47 @@ public void testUnixResolverDnsServerAddressStreamProvider_ParseEtcResolverSearc } } + @Test + @Timeout(value = 5000, unit = TimeUnit.MILLISECONDS) + public void testDnsNameResolverAllowsBlockingCalls() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + List error = new ArrayList<>(); + ThreadFactory threadFactory = new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new DefaultThreadFactory("test").newThread(r); + t.setUncaughtExceptionHandler((t1, e) -> { + error.add(e); + latch.countDown(); + }); + return t; + } + }; + EventLoopGroup group = new NioEventLoopGroup(1, threadFactory); + try (DnsNameResolver resolver = new DnsNameResolverBuilder(group.next()) + .datagramChannelFactory(NioDatagramChannel::new) + .build()) { + resolver.resolve("netty.io").addListener( + new GenericFutureListener>() { + @Override + public void operationComplete(io.netty.util.concurrent.Future future) { + if (!future.isSuccess()) { + error.add(future.cause()); + } + latch.countDown(); + } + }); + latch.await(); + for (Throwable t : error) { + if (t instanceof BlockingOperationError || t.getCause() instanceof BlockingOperationError) { + fail("BlockingOperationError was thrown: " + t); + } + } + } finally { + group.shutdownGracefully(); + } + } + private static void doTestParseResolverFilesAllowsBlockingCalls(Callable callable) throws InterruptedException { SingleThreadEventExecutor executor = From d2bbe5ed507aa8e708cd567ee5f75f263f6e86ac Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Fri, 5 Jun 2026 09:58:07 -0700 Subject: [PATCH 05/64] FlowControlHandler: Fix autoRead behavior (#16912) Motivation: The current implementation still has two problems: 1. The handling of auto-read and self-triggered channelReadComplete events is hidden inside helper methods, making the control flow harder to follow and reason about. 2. When auto-read is enabled, FlowControlHandler should behave as if it is not present in the pipeline. However, the current implementation violates this contract: 1. read() does not always delegate to ctx.read() when auto-read is enabled. 2. channelReadComplete() does not always propagate channelReadComplete when auto-read is enabled. 3. When all reads are satisfied, FlowControlHandler may self-fire channelReadComplete even though it needs to wait for upstream firing channelReadComplete when auto-read is enabled. Modification: 1. Moved auto-read handling into the top-level control flow, making case-handling explicit. 2. Fixed all cases where FlowControlHandler deviated from transparent behavior when auto-read is enabled. Result: 1. With auto-read enabled, FlowControlHandler now behaves transparently and preserves the expected channelReadComplete propagation semantics. 2. The control flow is easier to understand and reason about. Co-authored-by: Szymon Habrainski <56340221+schiemon@users.noreply.github.com> --- .../http/HttpContentDecompressorTest.java | 51 ++- .../handler/flow/FlowControlHandler.java | 107 ++++--- .../handler/flow/FlowControlHandlerTest.java | 300 ++++++++++++++++++ 3 files changed, 418 insertions(+), 40 deletions(-) diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentDecompressorTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentDecompressorTest.java index b0bccd7ad18..734eecdaf6d 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentDecompressorTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentDecompressorTest.java @@ -15,7 +15,6 @@ */ package io.netty.handler.codec.http; -import io.netty.buffer.AdaptiveByteBufAllocator; import io.netty.buffer.ByteBuf; import io.netty.buffer.PooledByteBufAllocator; import io.netty.buffer.Unpooled; @@ -25,6 +24,8 @@ import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.compression.Brotli; import io.netty.handler.codec.compression.Zstd; +import io.netty.handler.flow.FlowControlHandler; +import io.netty.util.ReferenceCountUtil; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -80,6 +81,54 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) { assertFalse(channel.finishAndReleaseAll()); } + // See https://github.com/netty/netty/issues/15053. + @Test + public void testFlowControlHandlerEmitsOneMessagePerRead() { + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger readCompletes = new AtomicInteger(); + EmbeddedChannel channel = new EmbeddedChannel( + new FlowControlHandler(), + new HttpContentDecompressor(0), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reads.incrementAndGet(); + ReferenceCountUtil.release(msg); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + + channel.config().setAutoRead(false); + + HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED); + + assertFalse(channel.writeInbound(response)); + assertFalse(channel.writeInbound(new DefaultHttpContent(Unpooled.EMPTY_BUFFER))); + assertFalse(channel.writeInbound(new DefaultHttpContent(Unpooled.EMPTY_BUFFER))); + + assertEquals(0, reads.get()); + assertEquals(0, readCompletes.get()); + + channel.read(); + assertEquals(1, reads.get()); + assertEquals(1, readCompletes.get()); + + channel.read(); + assertEquals(2, reads.get()); + assertEquals(2, readCompletes.get()); + + channel.read(); + assertEquals(3, reads.get()); + assertEquals(3, readCompletes.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + static String[] encodings() { List encodings = new ArrayList(); encodings.add("gzip"); diff --git a/handler/src/main/java/io/netty/handler/flow/FlowControlHandler.java b/handler/src/main/java/io/netty/handler/flow/FlowControlHandler.java index 7f1f5428964..0baabda8044 100644 --- a/handler/src/main/java/io/netty/handler/flow/FlowControlHandler.java +++ b/handler/src/main/java/io/netty/handler/flow/FlowControlHandler.java @@ -33,7 +33,7 @@ /** * The {@link FlowControlHandler} ensures that only one message per {@code read()} is sent downstream. - * + *

* Classes such as {@link ByteToMessageDecoder} or {@link MessageToByteEncoder} are free to emit as * many events as they like for any given input. A channel's auto reading configuration doesn't usually * apply in these scenarios. This is causing problems in downstream {@link ChannelHandler}s that would @@ -74,7 +74,27 @@ public class FlowControlHandler extends ChannelDuplexHandler { private ChannelConfig config; - private boolean shouldConsume; + /** + * Number of unsatisfied downstream {@code read()} calls. A downstream {@code read()} is considered unsatisfied + * if auto-read is off and if it has not yet been paired with a {@code fireChannelRead} or + * a cumulative {@code fireChannelReadComplete}. + *

+ * A {@code read()} can be satisfied in three ways, whichever comes first: + *

    + *
  • inside the {@code read()} call itself, by {@code dequeue()}ing a message
  • + *
  • in a {@code channelRead()}
  • + *
  • in a {@code channelReadComplete()}
  • + *
+ * A {@code read()} can be satisfied with auto-read on. + *

+ * When one or more {@code read()} calls are unsatisfied, a downstream {@code channelReadComplete} is fired + * only when either of the following happens: + *

    + *
  • auto-read is off and {@code unsatisfiedReads} returns to zero after {@code dequeue()}ing, or
  • + *
  • an upstream {@code channelReadComplete} arrives
  • + *
+ */ + private int unsatisfiedReads; public FlowControlHandler() { this(true); @@ -123,7 +143,8 @@ public void handlerAdded(ChannelHandlerContext ctx) throws Exception { public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { super.handlerRemoved(ctx); if (!isQueueEmpty()) { - dequeue(ctx, queue.size()); + dequeueAll(ctx); + ctx.fireChannelReadComplete(); } destroy(); } @@ -136,14 +157,22 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { @Override public void read(ChannelHandlerContext ctx) throws Exception { - if (dequeue(ctx, 1) == 0) { - // It seems no messages were consumed. We need to read() some - // messages from upstream and once one arrives it need to be - // relayed to downstream to keep the flow going. - shouldConsume = true; - ctx.read(); - } else if (config.isAutoRead()) { + if (config.isAutoRead()) { + dequeueAll(ctx); ctx.read(); + } else { + unsatisfiedReads++; + + if (dequeueOne(ctx)) { + if (unsatisfiedReads == 0) { + ctx.fireChannelReadComplete(); + } + } else { + // Could not satisfy the read() from the queue. + // We need to request data from upstream so we can satisfy the read() in channelRead() or + // channelReadComplete() if it is going to be an empty read. + ctx.read(); + } } } @@ -155,44 +184,49 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception queue.offer(msg); - // We just received one message. Do we need to relay it regardless - // of the auto reading configuration? The answer is yes if this - // method was called as a result of a prior read() call. - int minConsume = shouldConsume ? 1 : 0; - shouldConsume = false; + if (config.isAutoRead()) { + dequeueAll(ctx); + } else if (unsatisfiedReads > 0) { + dequeueOne(ctx); - dequeue(ctx, minConsume); + if (unsatisfiedReads == 0) { + ctx.fireChannelReadComplete(); + } + } } @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { - if (isQueueEmpty()) { + // Upstream closed the read cycle. Collapse every outstanding read() into a single downstream + // channelReadComplete; spurious upstream completions with no pending read are dropped. + if (config.isAutoRead() || unsatisfiedReads > 0) { + unsatisfiedReads = 0; ctx.fireChannelReadComplete(); - } else { - // Don't relay completion events from upstream as they - // make no sense in this context. See dequeue() where - // a new set of completion events is being produced. } } + private boolean dequeueOne(ChannelHandlerContext ctx) { + return dequeue(ctx, 1) > 0; + } + + private int dequeueAll(ChannelHandlerContext ctx) { + return dequeue(ctx, -1); + } + /** - * Dequeues one or many (or none) messages depending on the channel's auto - * reading state and returns the number of messages that were consumed from - * the internal queue. - * - * The {@code minConsume} argument is used to force {@code dequeue()} into - * consuming that number of messages regardless of the channel's auto - * reading configuration. + * Dequeues up to {@code maxConsume} messages, fires them downstream and + * updates {@code unsatisfiedReads} accordingly. If {@code maxConsume} is negative, + * there is no upper limit on the number of messages to dequeue and fire downstream. * * @see #read(ChannelHandlerContext) * @see #channelRead(ChannelHandlerContext, Object) */ - private int dequeue(ChannelHandlerContext ctx, int minConsume) { + private int dequeue(ChannelHandlerContext ctx, int maxConsume) { int consumed = 0; - // fireChannelRead(...) may call ctx.read() and so this method may reentrance. Because of this we need to - // check if queue was set to null in the meantime and if so break the loop. - while (queue != null && (consumed < minConsume || config.isAutoRead())) { + // fireChannelRead(...) may call ctx.read() and so this method may be re-entered. Because of that + // we need to check if queue was set to null in the meantime and, if so, break out of the loop. + while (queue != null && (consumed < maxConsume || maxConsume < 0)) { Object msg = queue.poll(); if (msg == null) { break; @@ -202,18 +236,13 @@ private int dequeue(ChannelHandlerContext ctx, int minConsume) { ctx.fireChannelRead(msg); } - // We're firing a completion event every time one (or more) - // messages were consumed and the queue ended up being drained - // to an empty state. if (queue != null && queue.isEmpty()) { queue.recycle(); queue = null; - - if (consumed > 0) { - ctx.fireChannelReadComplete(); - } } + unsatisfiedReads = Math.max(unsatisfiedReads - consumed, 0); + return consumed; } diff --git a/handler/src/test/java/io/netty/handler/flow/FlowControlHandlerTest.java b/handler/src/test/java/io/netty/handler/flow/FlowControlHandlerTest.java index dd45e640174..16d6d003d0a 100644 --- a/handler/src/test/java/io/netty/handler/flow/FlowControlHandlerTest.java +++ b/handler/src/test/java/io/netty/handler/flow/FlowControlHandlerTest.java @@ -48,6 +48,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Exchanger; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static java.util.concurrent.TimeUnit.*; @@ -662,6 +663,305 @@ public Boolean call() { } } + @Test + public void testCompletingReadWithNonEmptyQueue() throws Exception { + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger readCompletes = new AtomicInteger(); + final EmbeddedChannel channel = new EmbeddedChannel( + false, false, + new FlowControlHandler(), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reads.incrementAndGet(); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + + channel.config().setAutoRead(false); + channel.register(); + + assertFalse(channel.writeInbound("msg1", "msg2")); + assertEquals(0, reads.get()); + assertEquals(0, readCompletes.get()); + + channel.read(); + assertEquals(1, reads.get()); + assertEquals(1, readCompletes.get()); + + channel.read(); + assertEquals(2, reads.get()); + assertEquals(2, readCompletes.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + + @Test + public void testSuppressingUpstreamReadCompletes() throws Exception { + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger readCompletes = new AtomicInteger(); + final EmbeddedChannel channel = new EmbeddedChannel( + false, false, + new FlowControlHandler(), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reads.incrementAndGet(); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + + channel.config().setAutoRead(false); + channel.register(); + + assertEquals(0, reads.get()); + assertEquals(0, readCompletes.get()); + + channel.flushInbound(); + channel.flushInbound(); + channel.flushInbound(); + + assertEquals(0, reads.get()); + assertEquals(0, readCompletes.get()); + + channel.read(); + channel.writeOneInbound("msg").syncUninterruptibly(); + assertEquals(1, reads.get()); + assertEquals(1, readCompletes.get()); + + channel.flushInbound(); + channel.flushInbound(); + assertEquals(1, reads.get()); + assertEquals(1, readCompletes.get()); + + channel.read(); + channel.flushInbound(); + + assertEquals(1, reads.get()); + assertEquals(2, readCompletes.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + + @Test + public void testEmptyRead() throws Exception { + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger readCompletes = new AtomicInteger(); + final EmbeddedChannel channel = new EmbeddedChannel( + false, false, + new FlowControlHandler(), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reads.incrementAndGet(); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + + channel.config().setAutoRead(false); + channel.register(); + + // Downstream issues a read() but upstream has no data and only fires channelReadComplete. + // FlowControlHandler must forward that channelReadComplete to satisfy the outstanding read. + channel.read(); + channel.flushInbound(); + + assertEquals(0, reads.get()); + assertEquals(1, readCompletes.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + + @Test + public void testMultipleReadsOnEmptyQueue() throws Exception { + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger readCompletes = new AtomicInteger(); + final EmbeddedChannel channel = new EmbeddedChannel( + false, false, + new FlowControlHandler(), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reads.incrementAndGet(); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + channel.config().setAutoRead(false); + channel.register(); + + channel.read(); + channel.read(); + channel.read(); + + channel.writeOneInbound("msg1"); + + assertEquals(1, reads.get()); + assertEquals(0, readCompletes.get()); + + channel.flushInbound(); + + assertEquals(1, reads.get()); + assertEquals(1, readCompletes.get()); + + channel.read(); + channel.read(); + channel.read(); + + // empty read + channel.flushInbound(); + + assertEquals(1, reads.get()); + assertEquals(2, readCompletes.get()); + + // quick check that internal state is not broken + channel.writeOneInbound("msg2"); + channel.flushInbound(); + + assertEquals(1, reads.get()); + assertEquals(2, readCompletes.get()); + + channel.read(); + + assertEquals(2, reads.get()); + assertEquals(3, readCompletes.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + + @Test + public void testCompleteReadOnUpstreamCompleteWhenAutoReadOn() throws Exception { + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger readCompletes = new AtomicInteger(); + final EmbeddedChannel channel = new EmbeddedChannel( + false, false, + new FlowControlHandler(), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reads.incrementAndGet(); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + + assertTrue(channel.config().isAutoRead()); + channel.register(); + + channel.writeOneInbound("msg1").syncUninterruptibly(); + channel.writeOneInbound("msg2").syncUninterruptibly(); + channel.writeOneInbound("msg3").syncUninterruptibly(); + + // All three messages must arrive before channelReadComplete signals end-of-batch. + assertEquals(3, reads.get()); + // As auto-read is on, FlowControlHandler should not fire a channelReadComplete on its own but should wait + // for upstream to fire it. + assertEquals(0, readCompletes.get()); + + // Upstream now fires channelReadComplete and FlowControlHandler should pass it through. + channel.flushInbound(); + + assertEquals(3, reads.get()); + assertEquals(1, readCompletes.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + + @Test + public void testSatisfyPendingReadsAfterDisablingAutoRead() throws Exception { + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger readCompletes = new AtomicInteger(); + final EmbeddedChannel channel = new EmbeddedChannel( + false, false, + new FlowControlHandler(), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reads.incrementAndGet(); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + + channel.config().setAutoRead(false); + channel.register(); + + // We issue two reads with auto-read off. We expect at least two messages to be delivered, even when we are + // going to turn off auto-read in a moment. + channel.read(); + channel.read(); + channel.config().setAutoRead(true); + + // We got the first message with auto-read on. It immediately satisfies the first read. + channel.writeOneInbound("msg1").syncUninterruptibly(); + + assertEquals(1, reads.get()); + assertEquals(0, readCompletes.get()); + + channel.config().setAutoRead(false); + channel.config().setAutoRead(true); + // In the end auto-read is off, and we have one remaining unsatisfied read. + channel.config().setAutoRead(false); + + // sanity check: nothing should happen. + assertEquals(1, reads.get()); + assertEquals(0, readCompletes.get()); + + // The second message is delivered right away, satisfying the second read, and completing the batch. + channel.writeOneInbound("msg2").syncUninterruptibly(); + + assertEquals(2, reads.get()); + assertEquals(1, readCompletes.get()); + + // The third message is queued but not delivered as autoRead is off, and we have no unsatisfied reads anymore. + channel.writeOneInbound("msg3").syncUninterruptibly(); + + assertEquals(2, reads.get()); + assertEquals(1, readCompletes.get()); + + // Upstream fires channelReadComplete. + channel.flushInbound(); + + // As autoRead is off, FlowControlHandler is the one determining the end of the read cycle, not upstream. + // It ignores the channelReadComplete. + assertEquals(2, reads.get()); + assertEquals(1, readCompletes.get()); + + channel.config().setAutoRead(true); + + // The third message is dequeued and delivered. + assertEquals(3, reads.get()); + assertEquals(1, readCompletes.get()); + + channel.flushInbound(); + + assertEquals(3, reads.get()); + assertEquals(2, readCompletes.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + /** * This is a fictional message decoder. It decodes each {@code byte} * into three strings. From fd1b64c4b0a9bb68dabdd0454b4c100ef0037213 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:33:32 +0200 Subject: [PATCH 06/64] Auto-port 4.1: Fix incorrect bounds in error message of HpackDecoder.setMaxHeaderListSize (#16911) Auto-port of #16901 to 4.1 Cherry-picked commit: 667e3e8aa80962c6e044bba657d216586a51f8b7 --- Motivation: HpackDecoder#setMaxHeaderListSize validates the supplied value against MIN_HEADER_LIST_SIZE and MAX_HEADER_LIST_SIZE, but the error message thrown when the value is out of range incorrectly references MIN_HEADER_TABLE_SIZE and MAX_HEADER_TABLE_SIZE. This is misleading: it reports header table size limits while the method actually validates the header list size, which makes the resulting Http2Exception confusing to diagnose. Modifications: In HpackDecoder#setMaxHeaderListSize, update the connectionError call so that the formatted message uses MIN_HEADER_LIST_SIZE and MAX_HEADER_LIST_SIZE instead of the header-table-size constants, matching the actual range check performed on the argument. Result: When an invalid maxHeaderListSize is supplied, the thrown Http2Exception now reports the correct lower and upper bounds, making the error self-consistent and easier to debug. No behavioral change in validation logic. Co-authored-by: skyguard1 --- .../main/java/io/netty/handler/codec/http2/HpackDecoder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/HpackDecoder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/HpackDecoder.java index 8fb6a2635ad..72284b22dfe 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/HpackDecoder.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/HpackDecoder.java @@ -335,7 +335,7 @@ void setMaxHeaderTableSize(long maxHeaderTableSize) throws Http2Exception { void setMaxHeaderListSize(long maxHeaderListSize) throws Http2Exception { if (maxHeaderListSize < MIN_HEADER_LIST_SIZE || maxHeaderListSize > MAX_HEADER_LIST_SIZE) { throw connectionError(PROTOCOL_ERROR, "Header List Size must be >= %d and <= %d but was %d", - MIN_HEADER_TABLE_SIZE, MAX_HEADER_TABLE_SIZE, maxHeaderListSize); + MIN_HEADER_LIST_SIZE, MAX_HEADER_LIST_SIZE, maxHeaderListSize); } this.maxHeaderListSize = maxHeaderListSize; } From eeb637853eb6839a5ed1eacab9387ad4677501a9 Mon Sep 17 00:00:00 2001 From: Guimu <30684111+daguimu@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:37:18 +0800 Subject: [PATCH 07/64] MQTT: Fix MQTT decoder size check after variable header replay (#16916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Motivation #16787 fixed the `READ_VARIABLE_HEADER` too-long check on the **4.2** branch by replacing a `ReplayingDecoderByteBuf.readableBytes()` probe with the message size declared by the fixed header. That fix was auto-ported to 4.1 in #16838, **but a later CVE-2026-44248 security merge re-introduced the broken code on 4.1**, so the 4.1 branch still carries the regression: ```java int initialAvailableBytes = buffer.readableBytes(); ... if (initialAvailableBytes < maxBytesInMessage) { throw signal; // REPLAY } else { bailOut = true; // too long } ``` Because `MqttDecoder extends ReplayingDecoder`, `buffer` is a `ReplayingDecoderByteBuf` whose `readableBytes()` returns `Integer.MAX_VALUE - readerIndex` rather than the bytes actually buffered. With the default `maxBytesInMessage` of `8092`, the `initialAvailableBytes < maxBytesInMessage` check is therefore effectively always false. So when `decodeVariableHeader` asks for a `REPLAY` because the variable header has not fully arrived yet, the decoder takes the `bailOut` branch and rejects the message with a `TooLongFrameException` instead of waiting for the rest — a valid message whose variable header is split across reads is dropped. ### Modification Re-apply the #16787 fix on 4.1: drop the `initialAvailableBytes` / `readableBytes()` probe and decide based on `bytesRemainingBeforeVariableHeader` (the remaining length declared by the fixed header). A genuinely oversized message is still rejected by the existing `bytesRemainingBeforeVariableHeader > maxBytesInMessage` check; an incomplete one now correctly `REPLAY`s. ### Result A message whose variable header arrives in chunks is decoded once complete, instead of being rejected as too long, while declared-oversize messages still fail with `TooLongFrameException`. The two regression tests from #16787 are ported here. All `codec-mqtt` tests pass locally. Note: this targets **4.1 only** and intentionally carries no cherry-pick label — 4.2 already has the fix via #16787. Found while addressing @chrisvest's review comment on #16813 about the same `readableBytes()` antipattern. From 2d781e210e893d30d46466c7257103911866ca23 Mon Sep 17 00:00:00 2001 From: Guimu <30684111+daguimu@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:38:28 +0800 Subject: [PATCH 08/64] MQTT: Make the decodeProperties early-REPLAY check actually fire (#16813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: The CVE-2026-44248 fix in 82f47fa53571d04d8add02e3a01762cebd139a00 added a guard at the top of `decodeProperties` to trigger an early REPLAY when the cumulation buffer did not yet have the full properties block: ```java if (buffer.readableBytes() < totalPropertiesLength) { buffer.readSlice(totalPropertiesLength); } ``` Because `MqttDecoder` extends `ReplayingDecoder`, the buffer passed to `decodeProperties` is a `ReplayingDecoderByteBuf` whose `readableBytes()` returns `Integer.MAX_VALUE - readerIndex` rather than the actual number of bytes available (see `ReplayingDecoderByteBuf.readableBytes()`). The `if`-condition is therefore false in practice and the `readSlice()` call never executes, so the early-REPLAY optimization is effectively dead code. When the properties block arrives in chunks the decoder still falls back to partial parsing followed by a mid-loop REPLAY, which defeats the optimization's intent on slow streams. Modification: Replace the broken `readableBytes()` check with a `getByte()` probe at `buffer.readerIndex() + totalPropertiesLength - 1`. `ReplayingDecoderByteBuf.checkIndex` throws `Signal.REPLAY` when `index + 1 > writerIndex` — i.e. exactly when the cumulation buffer does not yet hold the full properties block. The call has no side effect on `readerIndex`, so subsequent parsing is unchanged once the data arrives. A `totalPropertiesLength > 0` guard skips the probe when there are no properties. Result: The early-REPLAY guard now actually fires when properties data is incomplete, restoring the CVE-2026-44248 fix's intent of avoiding repeated partial-properties parsing on slow streams. No semantic change on the happy path; all 107 existing `codec-mqtt` tests pass locally. Note: this is complementary to #16787, which addresses the *outer* variable-header REPLAY handling broken by the same CVE patch (same `buffer.readableBytes()` antipattern in a ReplayingDecoder context). Both fixes are independent and can land in either order; together they restore the optimization the CVE-2026-44248 fix originally aimed for. --- .../java/io/netty/handler/codec/mqtt/MqttDecoder.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java index 86bd8b04535..da561ae8212 100644 --- a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java +++ b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java @@ -749,9 +749,14 @@ private static Result decodeProperties(ByteBuf buffer) { final long propertiesLength = decodeVariableByteInteger(buffer); int totalPropertiesLength = unpackA(propertiesLength); int numberOfBytesConsumed = unpackB(propertiesLength); - if (buffer.readableBytes() < totalPropertiesLength) { - // Force an early REPLAY to avoid repeatedly parsing the properties. - buffer.readSlice(totalPropertiesLength); + if (totalPropertiesLength > 0) { + // Force an early REPLAY when the buffer does not yet have the full properties block, + // so we don't repeatedly parse partial properties as data arrives. A direct + // buffer.readableBytes() check is unusable here because ReplayingDecoderByteBuf + // returns Integer.MAX_VALUE - readerIndex; touching the last byte via getByte() + // routes through ReplayingDecoderByteBuf.checkIndex(), which throws REPLAY if the + // buffer's writerIndex hasn't reached that position yet. + buffer.getByte(buffer.readerIndex() + totalPropertiesLength - 1); } MqttProperties decodedProperties = new MqttProperties(); From e45c3cf18a6182d7e8ee550cbdbf2e1d7143de6c Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Thu, 11 Jun 2026 00:57:39 -0700 Subject: [PATCH 09/64] Reject control characters at the boundary of HTTP method names (#16723) (#16933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `HttpMethod`'s constructor accepts a wire-level method name such as `\x00GET\x00` and silently treats it as `GET`. Because the method name is later compared against expected values (`HttpMethod.GET`, etc.), this masks the difference between a clean `GET` and a control-byte-padded one — a known HTTP request-smuggling vector when Netty sits behind a proxy or in front of a backend that interprets the bytes differently. A reproducer is in https://github.com/netty/netty/issues/15047: ``` printf '\x00GET\x00 / HTTP/1.1\r\n\r\n' | nc localhost 80 # → request is decoded as method=GET, isSuccess=true ``` ## Root Cause `HttpMethod(String)` runs `checkNonEmptyAfterTrim(name, …)` before validating the name as an HTTP token. `String.trim()` strips every character with code point ≤ 0x20, which includes `NUL`, `CR`, `LF`, `VT`, `FF`, and the rest of the C0 range. After the trim the surviving string is a clean `"GET"`, which passes `HttpHeaderValidationUtil.validateToken` even though the wire bytes contained a non-token character at the boundary. ## Fix In `codec-http/src/main/java/io/netty/handler/codec/http/HttpMethod.java`: - Replace the `String.trim()`-based pre-pass with an explicit loop that only skips the single space (`0x20`) and horizontal tab (`0x09`) characters at the start and end. - Throw `IllegalArgumentException("name cannot be empty")` if the result is empty. - Run the existing `HttpHeaderValidationUtil.validateToken` facade against the resulting substring, so any non-token character — including a `NUL` left at the boundary — is reported via `"Illegal character in HTTP Method: 0x…"`. The HTTP request decoder already wraps `createMessage` exceptions into a decoder failure on the resulting `HttpRequest`, so the upstream effect is that `\x00GET\x00 …` produces an `HttpRequest` with `decoderResult().isSuccess() == false` instead of a phantom `GET`. ## Tests Added New `codec-http/src/test/java/io/netty/handler/codec/http/HttpMethodTest.java` (17 tests). NUL bytes are constructed via `String.valueOf((char) 0x00)` so the source file stays text-only. | Change point | Test | |--------------|------| | Cached lookup of standard methods unchanged | `valueOfReturnsCachedInstanceForKnownMethods` | | Custom method names still accepted | `constructorAcceptsCustomMethodName` | | `SP` trim still works (regression) | `constructorTrimsLeadingAndTrailingSpaces` | | `HT` trim still works (regression) | `constructorTrimsLeadingAndTrailingTabs` | | Reject NUL at start/end/both/embedded | `constructorRejectsLeadingNul`, `constructorRejectsTrailingNul`, `constructorRejectsLeadingAndTrailingNul`, `constructorRejectsEmbeddedNul` | | Reject other C0 control chars previously stripped by `trim()` | `constructorRejectsCarriageReturn`, `constructorRejectsLineFeed`, `constructorRejectsVerticalTab`, `constructorRejectsFormFeed` | | Reject embedded space (still a non-token char per RFC 7230) | `constructorRejectsEmbeddedSpace` | | Reject empty / blank-only names | `constructorRejectsEmptyString`, `constructorRejectsBlankString` | | End-to-end: decoder fails on NUL-padded method | `requestDecoderRejectsNulPaddedMethod` | | End-to-end regression: clean `GET` still parses | `requestDecoderAcceptsCleanMethod` | `mvn -pl codec-http test` runs 7834 tests with 0 failures locally. ## Impact - API: `HttpMethod`'s public constructor becomes stricter — inputs containing characters that were previously silently stripped (anything below `0x20` other than `SP` and `HT`) now throw `IllegalArgumentException`. Method names that already conform to RFC 7230's `token` rule are unaffected, and lenient `SP`/`HT` padding is still tolerated for backward compatibility. - Wire effect: A request whose method on the wire contains `NUL`, `CR`, `LF`, `VT`, `FF`, or other control bytes now produces a failed `HttpRequest` (`decoderResult().isSuccess() == false`) instead of being silently normalised — the existing `HttpRequestDecoder` failure plumbing handles the rest. - No changes outside `HttpMethod` and the new test class. Fixes https://github.com/netty/netty/issues/15047 (cherry picked from commit https://github.com/netty/netty/commit/6ad888eb6464b4863610a8bd49affbb1093c0f3b) --------- Co-authored-by: Guimu <30684111+daguimu@users.noreply.github.com> --- .../netty/handler/codec/http/HttpMethod.java | 126 +++++-------- .../handler/codec/http/HttpMethodTest.java | 170 ++++++++++++++++++ 2 files changed, 214 insertions(+), 82 deletions(-) create mode 100644 codec-http/src/test/java/io/netty/handler/codec/http/HttpMethodTest.java diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpMethod.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpMethod.java index cdfba4eb68f..2797e2fb822 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpMethod.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpMethod.java @@ -17,8 +17,7 @@ import io.netty.util.AsciiString; -import static io.netty.util.internal.MathUtil.findNextPositivePowerOfTwo; -import static io.netty.util.internal.ObjectUtil.checkNonEmptyAfterTrim; +import static io.netty.util.internal.ObjectUtil.checkNotNull; /** * The request method of HTTP or its derived protocols, such as @@ -27,9 +26,6 @@ */ public class HttpMethod implements Comparable { - private static final String GET_STRING = "GET"; - private static final String POST_STRING = "POST"; - /** * The OPTIONS method represents a request for information about the communication options * available on the request/response chain identified by the Request-URI. This method allows @@ -37,7 +33,7 @@ public class HttpMethod implements Comparable { * capabilities of a server, without implying a resource action or initiating a resource * retrieval. */ - public static final HttpMethod OPTIONS = new HttpMethod("OPTIONS"); + public static final HttpMethod OPTIONS = new HttpMethod(AsciiString.cached("OPTIONS")); /** * The GET method means retrieve whatever information (in the form of an entity) is identified @@ -45,64 +41,49 @@ public class HttpMethod implements Comparable { * produced data which shall be returned as the entity in the response and not the source text * of the process, unless that text happens to be the output of the process. */ - public static final HttpMethod GET = new HttpMethod(GET_STRING); + public static final HttpMethod GET = new HttpMethod(AsciiString.cached("GET")); /** * The HEAD method is identical to GET except that the server MUST NOT return a message-body * in the response. */ - public static final HttpMethod HEAD = new HttpMethod("HEAD"); + public static final HttpMethod HEAD = new HttpMethod(AsciiString.cached("HEAD")); /** * The POST method is used to request that the origin server accept the entity enclosed in the * request as a new subordinate of the resource identified by the Request-URI in the * Request-Line. */ - public static final HttpMethod POST = new HttpMethod(POST_STRING); + public static final HttpMethod POST = new HttpMethod(AsciiString.cached("POST")); /** * The PUT method requests that the enclosed entity be stored under the supplied Request-URI. */ - public static final HttpMethod PUT = new HttpMethod("PUT"); + public static final HttpMethod PUT = new HttpMethod(AsciiString.cached("PUT")); /** * The PATCH method requests that a set of changes described in the * request entity be applied to the resource identified by the Request-URI. */ - public static final HttpMethod PATCH = new HttpMethod("PATCH"); + public static final HttpMethod PATCH = new HttpMethod(AsciiString.cached("PATCH")); /** * The DELETE method requests that the origin server delete the resource identified by the * Request-URI. */ - public static final HttpMethod DELETE = new HttpMethod("DELETE"); + public static final HttpMethod DELETE = new HttpMethod(AsciiString.cached("DELETE")); /** * The TRACE method is used to invoke a remote, application-layer loop- back of the request * message. */ - public static final HttpMethod TRACE = new HttpMethod("TRACE"); + public static final HttpMethod TRACE = new HttpMethod(AsciiString.cached("TRACE")); /** * This specification reserves the method name CONNECT for use with a proxy that can dynamically * switch to being a tunnel */ - public static final HttpMethod CONNECT = new HttpMethod("CONNECT"); - - private static final EnumNameMap methodMap; - - static { - methodMap = new EnumNameMap( - new EnumNameMap.Node(OPTIONS.toString(), OPTIONS), - new EnumNameMap.Node(GET.toString(), GET), - new EnumNameMap.Node(HEAD.toString(), HEAD), - new EnumNameMap.Node(POST.toString(), POST), - new EnumNameMap.Node(PUT.toString(), PUT), - new EnumNameMap.Node(PATCH.toString(), PATCH), - new EnumNameMap.Node(DELETE.toString(), DELETE), - new EnumNameMap.Node(TRACE.toString(), TRACE), - new EnumNameMap.Node(CONNECT.toString(), CONNECT)); - } + public static final HttpMethod CONNECT = new HttpMethod(AsciiString.cached("CONNECT")); /** * Returns the {@link HttpMethod} represented by the specified name. @@ -110,20 +91,39 @@ public class HttpMethod implements Comparable { * will be returned. Otherwise, a new instance will be returned. */ public static HttpMethod valueOf(String name) { - // fast-path - if (name == GET_STRING) { - return GET; - } - if (name == POST_STRING) { - return POST; + if (name.equals("GET")) { + return HttpMethod.GET; + } else if (name.equals("POST")) { + return HttpMethod.POST; + } else if (name.equals("PUT")) { + return HttpMethod.PUT; + } else if (name.equals("CONNECT")) { + return HttpMethod.CONNECT; + } else if (name.equals("OPTIONS")) { + return HttpMethod.OPTIONS; + } else if (name.equals("HEAD")) { + return HttpMethod.HEAD; + } else if (name.equals("PATCH")) { + return HttpMethod.PATCH; + } else if (name.equals("DELETE")) { + return HttpMethod.DELETE; + } else if (name.equals("TRACE")) { + return HttpMethod.TRACE; } - // "slow"-path - HttpMethod result = methodMap.get(name); - return result != null ? result : new HttpMethod(name); + return new HttpMethod(name); } private final AsciiString name; + /** + * Private constructor for the built-in constants defined in this class. + * The names are compiler-controlled literals that are already valid HTTP tokens, + * so there is no need to validate or trim them at runtime. + */ + private HttpMethod(AsciiString name) { + this.name = name; + } + /** * Creates a new HTTP method with the specified name. You will not need to * create a new method unless you are implementing a protocol derived from @@ -132,8 +132,12 @@ public static HttpMethod valueOf(String name) { * ICAP */ public HttpMethod(String name) { - name = checkNonEmptyAfterTrim(name, "name"); - int index = HttpUtil.validateToken(name); + checkNotNull(name, "name"); + // The name must be non-empty and contain only valid HTTP token characters. + if (name.isEmpty()) { + throw new IllegalArgumentException("name cannot be empty"); + } + int index = HttpHeaderValidationUtil.validateToken(name); if (index != -1) { throw new IllegalArgumentException( "Illegal character in HTTP Method: 0x" + Integer.toHexString(name.charAt(index))); @@ -185,46 +189,4 @@ public int compareTo(HttpMethod o) { } return name().compareTo(o.name()); } - - private static final class EnumNameMap { - private final EnumNameMap.Node[] values; - private final int valuesMask; - - EnumNameMap(EnumNameMap.Node... nodes) { - values = (EnumNameMap.Node[]) new EnumNameMap.Node[findNextPositivePowerOfTwo(nodes.length)]; - valuesMask = values.length - 1; - for (EnumNameMap.Node node : nodes) { - int i = hashCode(node.key) & valuesMask; - if (values[i] != null) { - throw new IllegalArgumentException("index " + i + " collision between values: [" + - values[i].key + ", " + node.key + ']'); - } - values[i] = node; - } - } - - T get(String name) { - EnumNameMap.Node node = values[hashCode(name) & valuesMask]; - return node == null || !node.key.equals(name) ? null : node.value; - } - - private static int hashCode(String name) { - // This hash code needs to produce a unique index in the "values" array for each HttpMethod. If new - // HttpMethods are added this algorithm will need to be adjusted. The constructor will "fail fast" if there - // are duplicates detected. - // For example with the current set of HttpMethods it just so happens that the String hash code value - // shifted right by 6 bits modulo 16 is unique relative to all other HttpMethod values. - return name.hashCode() >>> 6; - } - - private static final class Node { - final String key; - final T value; - - Node(String key, T value) { - this.key = key; - this.value = value; - } - } - } } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/HttpMethodTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/HttpMethodTest.java new file mode 100644 index 00000000000..0e3fbb589d8 --- /dev/null +++ b/codec-http/src/test/java/io/netty/handler/codec/http/HttpMethodTest.java @@ -0,0 +1,170 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project 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: + * + * https://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 io.netty.handler.codec.http; + +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.util.ReferenceCountUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import java.nio.charset.StandardCharsets; + +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class HttpMethodTest { + + private static final String NUL = String.valueOf((char) 0x00); + + @Test + public void valueOfReturnsCachedInstanceForKnownMethods() { + assertSame(HttpMethod.GET, HttpMethod.valueOf("GET")); + assertSame(HttpMethod.POST, HttpMethod.valueOf("POST")); + } + + @Test + public void constructorAcceptsCustomMethodName() { + HttpMethod custom = new HttpMethod("CUSTOM"); + assertEquals("CUSTOM", custom.name()); + } + + @Test + public void constructorRejectsLeadingAndTrailingSpaces() { + // SP is not a valid HTTP token character; the name must already be a clean token. + assertThrows(IllegalArgumentException.class, newInstance(" GET ")); + } + + @Test + public void constructorRejectsLeadingAndTrailingTabs() { + // HT is not a valid HTTP token character; the name must already be a clean token. + assertThrows(IllegalArgumentException.class, newInstance("\tGET\t")); + } + + @Test + public void constructorRejectsEmptyName() { + assertThrows(IllegalArgumentException.class, newInstance("")); + } + + @Test + public void constructorRejectsLeadingNul() { + assertThrows(IllegalArgumentException.class, newInstance(NUL + "GET")); + } + + @Test + public void constructorRejectsTrailingNul() { + assertThrows(IllegalArgumentException.class, newInstance("GET" + NUL)); + } + + @Test + public void constructorRejectsLeadingAndTrailingNul() { + assertThrows(IllegalArgumentException.class, newInstance(NUL + "GET" + NUL)); + } + + @Test + public void constructorRejectsEmbeddedNul() { + assertThrows(IllegalArgumentException.class, newInstance("GE" + NUL + "T")); + } + + @Test + public void constructorRejectsCarriageReturn() { + assertThrows(IllegalArgumentException.class, newInstance("GET\r")); + } + + @Test + public void constructorRejectsLineFeed() { + assertThrows(IllegalArgumentException.class, newInstance("GET\n")); + } + + @Test + public void constructorRejectsVerticalTab() { + assertThrows(IllegalArgumentException.class, newInstance("GET" + (char) 0x0B)); + } + + @Test + public void constructorRejectsFormFeed() { + assertThrows(IllegalArgumentException.class, newInstance("GET\f")); + } + + @Test + public void constructorRejectsEmbeddedSpace() { + assertThrows(IllegalArgumentException.class, newInstance("GE T")); + } + + @Test + public void constructorRejectsEmptyString() { + assertThrows(IllegalArgumentException.class, newInstance("")); + } + + @Test + public void constructorRejectsBlankString() { + assertThrows(IllegalArgumentException.class, newInstance(" ")); + } + + @Test + public void requestDecoderRejectsNulPaddedMethod() { + // RFC 9112 forbids any non-token character in the method. NUL-padded methods are + // a known request-smuggling vector if silently stripped, so the decoder must + // surface a decoder failure rather than producing a valid GET message. + EmbeddedChannel ch = new EmbeddedChannel(new HttpRequestDecoder()); + try { + byte[] data = (NUL + "GET" + NUL + " / HTTP/1.1\r\nHost: x\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII); + ch.writeInbound(Unpooled.wrappedBuffer(data)); + HttpRequest req = ch.readInbound(); + try { + assertNotNull(req); + assertFalse(req.decoderResult().isSuccess(), + "decoder must reject method names containing NUL bytes"); + } finally { + ReferenceCountUtil.release(req); + } + } finally { + ch.finishAndReleaseAll(); + } + } + + @Test + public void requestDecoderAcceptsCleanMethod() { + // Regression: ordinary GET requests must still parse normally. + EmbeddedChannel ch = new EmbeddedChannel(new HttpRequestDecoder()); + try { + byte[] data = "GET / HTTP/1.1\r\nHost: x\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + ch.writeInbound(Unpooled.wrappedBuffer(data)); + HttpRequest req = ch.readInbound(); + try { + assertNotNull(req); + assertEquals(HttpMethod.GET, req.method()); + } finally { + ReferenceCountUtil.release(req); + } + } finally { + ch.finishAndReleaseAll(); + } + } + + private static Executable newInstance(final String name) { + return new Executable() { + @Override + public void execute() { + new HttpMethod(name); + } + }; + } +} From 465c9e6e477ef89f28a4d52b0ae3dd5bb2c086e0 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:54:22 +0200 Subject: [PATCH 10/64] Auto-port 4.1: Update to latest tcnative release (#16941) Auto-port of #16936 to 4.1 Cherry-picked commit: 09156ac7394680c083d58903b95f3a49d6e4a370 --- Motivation: tcnative 2.0.78.Final was released Modifications: Update to latest release Result: Depend on latest tcnative release Co-authored-by: Norman Maurer --- bom/pom.xml | 2 +- pom.xml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bom/pom.xml b/bom/pom.xml index aef471de41b..9e149adbfe5 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -73,7 +73,7 @@ - 2.0.77.Final + 2.0.78.Final diff --git a/pom.xml b/pom.xml index 28f5f7264fd..d1bcc35790c 100644 --- a/pom.xml +++ b/pom.xml @@ -680,7 +680,7 @@ boringssl-snapshot netty-tcnative-boringssl-static - 2.0.78.Final-SNAPSHOT + 2.0.79.Final-SNAPSHOT ${os.detected.classifier} @@ -831,7 +831,7 @@ fedora,suse,arch netty-tcnative - 2.0.77.Final + 2.0.78.Final ${os.detected.classifier} org.conscrypt conscrypt-openjdk-uber From 13ff5ca75f33a8e4a129584b417949c817244007 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:09:56 +0200 Subject: [PATCH 11/64] Auto-port 4.1: Fix HTTP 2 PUSH_PROMISE stream association validation (#16955) Auto-port of #16952 to 4.1 Cherry-picked commit: 6dabf563b6acdc30448f2b09685fbdcd5d88e39a --- Motivation: `DefaultHttp2FrameReader` did not verify that `PUSH_PROMISE` frames are associated with a stream before processing the frame-specific payload. As a result, a `PUSH_PROMISE` frame with stream ID `0` was not rejected by the same stream association validation used by DATA, HEADERS, PRIORITY, and RST_STREAM frames. Per RFC 9113 section 6.6, `PUSH_PROMISE` frames identify the stream they are associated with, and receipt of a `PUSH_PROMISE` frame with stream ID `0` MUST be treated as a connection error of type `PROTOCOL_ERROR`. Modifications: - Add `verifyAssociatedWithAStream()` to `verifyPushPromiseFrame()`. - Add a regression test for `PUSH_PROMISE` with stream ID `0`. - Verify that the error is connection-level `PROTOCOL_ERROR`. - Verify that `onPushPromiseRead(...)` is not invoked for the invalid frame. Result: Invalid `PUSH_PROMISE` frames with stream ID `0` are now rejected consistently with other stream-associated HTTP/2 frame types. Co-authored-by: skyguard1 --- .../codec/http2/DefaultHttp2FrameReader.java | 1 + .../http2/DefaultHttp2FrameReaderTest.java | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameReader.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameReader.java index 31e95afda4c..1ccf1d1ac48 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameReader.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameReader.java @@ -343,6 +343,7 @@ private void verifySettingsFrame() throws Http2Exception { } private void verifyPushPromiseFrame() throws Http2Exception { + verifyAssociatedWithAStream(); verifyNotProcessingHeaders(); // Subtract the length of the promised stream ID field, to determine the length of the diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2FrameReaderTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2FrameReaderTest.java index 97117dac95e..6050b3b3c43 100644 --- a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2FrameReaderTest.java +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2FrameReaderTest.java @@ -340,6 +340,27 @@ public void execute() throws Throwable { } } + @Test + public void failedWhenPushPromiseFrameNotAssociateWithStream() throws Http2Exception { + final ByteBuf input = Unpooled.buffer(); + try { + writeFrameHeader(input, INT_FIELD_LENGTH, PUSH_PROMISE, new Http2Flags().endOfHeaders(true), 0); + input.writeInt(2); + Http2Exception ex = assertThrows(Http2Exception.class, new Executable() { + @Override + public void execute() throws Throwable { + frameReader.readFrame(ctx, input, listener); + } + }); + assertFalse(ex instanceof Http2Exception.StreamException); + assertEquals(Http2Error.PROTOCOL_ERROR, ex.error()); + verify(listener, never()).onPushPromiseRead(any(ChannelHandlerContext.class), anyInt(), anyInt(), + any(Http2Headers.class), anyInt()); + } finally { + input.release(); + } + } + @Test public void readPriorityFrame() throws Http2Exception { ByteBuf input = Unpooled.buffer(); From 7a95c6795a67fc9ace02c2872a7e2b0561829515 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:54:30 +0200 Subject: [PATCH 12/64] Auto-port 4.1: Fix GZIP FEXTRA extra-field handling in JdkZlibDecoder (#16957) Auto-port of #16951 to 4.1 Cherry-picked commit: a655e648bf3195eca5293bfd9b679fef8109c42c --- ## Problem `JdkZlibDecoder` cannot decode a gzip stream that carries an **FEXTRA** extra field (`FLG.FEXTRA`, `0x04`). Such streams are valid per [RFC 1952](https://datatracker.ietf.org/doc/html/rfc1952#section-2.3.1.1) and decode fine with `java.util.zip.GZIPInputStream`, but netty throws `DecompressionException: decompression failure`. This affects, for example, gzip variants that use the extra field (BGZF and others). ## Root Cause In `JdkZlibDecoder` the gzip XLEN handling has two combined defects: ```java private int xlen = -1; ... xlen |= xlen1 << 8 | xlen2; // FLG_READ, when FEXTRA is set ... case XLEN_READ: if (xlen != -1) { // never true -> extra field never skipped ... in.skipBytes(xlen); } ``` 1. `xlen` starts at the `-1` "no extra field" sentinel and the length is merged with `xlen |= ...`. OR-ing anything into `-1` (`0xFFFFFFFF`) leaves it `-1`, so `if (xlen != -1)` is always `false` and the extra field is **never skipped**. The unskipped bytes are then handed to the `Inflater` as deflate data, which fails. 2. XLEN is a **little-endian** unsigned 16-bit value, but it was assembled as `xlen1 << 8 | xlen2` (big-endian). So even with defect 1 fixed in isolation, the wrong number of bytes would be skipped (e.g. a 6-byte extra field would skip 1536). This has been latent since gzip support was added, because `GZIPOutputStream` never emits an FEXTRA field, so no existing test exercised this path. ## Fix Assemble XLEN as an assignment in little-endian order: ```java xlen = xlen2 << 8 | xlen1; ``` Added a `JdkZlibTest` case that crafts a gzip stream with an FEXTRA field, cross-checks that it is valid by decoding it with the JDK `GZIPInputStream`, and asserts `JdkZlibDecoder` decodes it to the same bytes. ## Result `JdkZlibDecoder` correctly skips the gzip extra field and decodes streams that carry one. Full `JdkZlibTest` (23 tests) passes. Co-authored-by: Guimu <30684111+daguimu@users.noreply.github.com> Co-authored-by: Norman Maurer --- .../codec/compression/JdkZlibDecoder.java | 7 +- .../codec/compression/JdkZlibTest.java | 109 ++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/codec/src/main/java/io/netty/handler/codec/compression/JdkZlibDecoder.java b/codec/src/main/java/io/netty/handler/codec/compression/JdkZlibDecoder.java index ac2b75c8077..b018244b020 100644 --- a/codec/src/main/java/io/netty/handler/codec/compression/JdkZlibDecoder.java +++ b/codec/src/main/java/io/netty/handler/codec/compression/JdkZlibDecoder.java @@ -324,6 +324,7 @@ private boolean handleGzipFooter(ByteBuf in) { if (!finished) { inflater.reset(); crc.reset(); + xlen = -1; gzipState = GzipState.HEADER_START; return true; } @@ -394,7 +395,11 @@ private boolean readGZIPHeader(ByteBuf in) { crc.update(xlen1); crc.update(xlen2); - xlen |= xlen1 << 8 | xlen2; + // XLEN is a little-endian unsigned 16-bit value (RFC 1952), so xlen1 is the + // low byte and xlen2 the high byte. This must be an assignment, not |=: xlen + // starts at the -1 "no extra field" sentinel, and OR-ing into -1 (0xFFFFFFFF) + // would leave it -1, so the extra field was never skipped. + xlen = xlen2 << 8 | xlen1; } gzipState = GzipState.XLEN_READ; // fall through diff --git a/codec/src/test/java/io/netty/handler/codec/compression/JdkZlibTest.java b/codec/src/test/java/io/netty/handler/codec/compression/JdkZlibTest.java index ce70db2da40..58e261a29ee 100644 --- a/codec/src/test/java/io/netty/handler/codec/compression/JdkZlibTest.java +++ b/codec/src/test/java/io/netty/handler/codec/compression/JdkZlibTest.java @@ -34,6 +34,7 @@ import java.util.zip.Deflater; import java.util.zip.GZIPOutputStream; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -138,6 +139,114 @@ public void testConcatenatedStreamsReadFullyWhenFragmented() throws IOException } } + @Test + public void testGZIPDecodeWithExtraField() throws Exception { + byte[] data = "Hello, gzip FEXTRA world!".getBytes(CharsetUtil.UTF_8); + byte[] extra = { 0x42, 0x43, 0x02, 0x00, (byte) 0x99, 0x00 }; // 6 arbitrary bytes + byte[] gzipWithExtra = gzipWithExtraField(data, extra); + + // Sanity-check the crafted stream is a valid gzip by decoding it with the JDK itself. + assertArrayEquals(data, jdkGunzip(gzipWithExtra)); + + // netty must decode it identically; before the FEXTRA fix the extra bytes were never + // skipped, corrupting the deflate stream and throwing DecompressionException. + EmbeddedChannel ch = new EmbeddedChannel(createDecoder(ZlibWrapper.GZIP)); + try { + assertTrue(ch.writeInbound(Unpooled.copiedBuffer(gzipWithExtra))); + ByteBuf out = ch.readInbound(); + assertEquals(new String(data, CharsetUtil.UTF_8), out.toString(CharsetUtil.UTF_8)); + out.release(); + } finally { + assertFalse(ch.finish()); + ch.close(); + } + } + + @Test + public void testConcatenatedGzipFirstStreamHasExtraField() throws Exception { + // Regression guard: with concatenated streams, an FEXTRA field on the first stream must not + // leak its XLEN into the second stream's header parsing. The xlen state has to be reset + // between streams; otherwise the second stream (which has no extra field) would skip + // xlen bytes that are actually deflate data and fail to decode. + String firstText = "first stream"; + String secondText = "second stream"; + byte[] first = firstText.getBytes(CharsetUtil.UTF_8); + byte[] second = secondText.getBytes(CharsetUtil.UTF_8); + byte[] extra = { 0x42, 0x43, 0x02, 0x00, (byte) 0x99, 0x00 }; + + byte[] firstGz = gzipWithExtraField(first, extra); // first stream HAS an extra field + byte[] secondGz = gzip(second); // second stream has none + byte[] both = new byte[firstGz.length + secondGz.length]; + System.arraycopy(firstGz, 0, both, 0, firstGz.length); + System.arraycopy(secondGz, 0, both, firstGz.length, secondGz.length); + + EmbeddedChannel ch = new EmbeddedChannel(new JdkZlibDecoder(true, 0)); + try { + assertTrue(ch.writeInbound(Unpooled.copiedBuffer(both))); + ByteArrayOutputStream decoded = new ByteArrayOutputStream(); + ByteBuf msg; + while ((msg = ch.readInbound()) != null) { + msg.readBytes(decoded, msg.readableBytes()); + msg.release(); + } + assertArrayEquals((firstText + secondText).getBytes(CharsetUtil.UTF_8), + decoded.toByteArray()); + decoded.close(); + } finally { + assertFalse(ch.finish()); + } + } + + private static byte[] gzip(byte[] data) throws IOException { + ByteArrayOutputStream bytesOut = new ByteArrayOutputStream(); + GZIPOutputStream gzipOut = new GZIPOutputStream(bytesOut); + gzipOut.write(data); + gzipOut.close(); + return bytesOut.toByteArray(); + } + + private static byte[] gzipWithExtraField(byte[] data, byte[] extra) throws IOException { + // GZIPOutputStream never emits an FEXTRA field, so build a standard gzip stream first ... + byte[] standard = gzip(data); + + // ... then splice in an FEXTRA field by hand: set the FEXTRA flag in FLG and insert + // XLEN (2 bytes, little-endian per RFC 1952) followed by the extra subfield, right after + // the fixed 10-byte gzip header. + ByteArrayOutputStream withExtra = new ByteArrayOutputStream(); + try { + byte[] header = Arrays.copyOfRange(standard, 0, 10); + header[3] |= 0x04; // FLG.FEXTRA + withExtra.write(header); + withExtra.write(extra.length & 0xff); // XLEN low byte (little-endian) + withExtra.write((extra.length >>> 8) & 0xff); // XLEN high byte + withExtra.write(extra); + withExtra.write(standard, 10, standard.length - 10); + return withExtra.toByteArray(); + } finally { + withExtra.close(); + } + } + + private static byte[] jdkGunzip(byte[] gz) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + java.util.zip.GZIPInputStream in = + new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(gz)); + try { + byte[] buf = new byte[256]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + } finally { + in.close(); + } + return out.toByteArray(); + } finally { + out.close(); + } + } + @Test public void testDecodeWithHeaderFollowingFooter() throws Exception { byte[] bytes = new byte[1024]; From a1e9aaccbc219c0f0c53ae470d6d5a159fa96acc Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:18:43 +0200 Subject: [PATCH 13/64] Auto-port 4.1: Add opt-in validation of mandatory pseudo-header fields for HTTP/2 (#16964) Auto-port of #16932 to 4.1 Cherry-picked commit: 91ec8cd3a4b09c8f02682ddfd7712befa3ce224e --- Motivation: RFC 9113 Section 8.3 requires HTTP/2 requests to include `:method`, `:scheme` and `:path`, and responses to include `:status`. Netty's message API (HttpConversionUtil) already rejects a missing :method, :path or :status, but the raw frame API (Http2FrameCodec / Http2MultiplexHandler) does not validate this and accepts requests and responses with missing required pseudo-headers. This is reported in #10633 and #13630. Modification: Add an optional `validateRequiredPseudoHeaders` setting (disabled by default) to `DefaultHttp2ConnectionDecoder` and the HTTP/2 builders. When enabled, initial request and response HEADERS that omit a required pseudo-header are rejected with a `PROTOCOL_ERROR` stream error. `CONNECT` and extended `CONNECT` requests are handled according to RFC 9113 Section 8.5 and RFC 8441. Result: The raw frame API can now opt into RFC 9113 Section 8.3 required-pseudo-header validation, while preserving existing behavior by default. Co-authored-by: Aayush Atharva <24762260+hyperxpro@users.noreply.github.com> --- ...AbstractHttp2ConnectionHandlerBuilder.java | 24 +++- .../http2/DefaultHttp2ConnectionDecoder.java | 81 +++++++++++ .../http2/Http2ConnectionHandlerBuilder.java | 5 + .../codec/http2/Http2FrameCodecBuilder.java | 8 +- .../http2/Http2MultiplexCodecBuilder.java | 8 +- .../HttpToHttp2ConnectionHandlerBuilder.java | 5 + .../DefaultHttp2ConnectionDecoderTest.java | 135 ++++++++++++++++++ .../codec/http2/Http2FrameCodecTest.java | 24 ++++ 8 files changed, 287 insertions(+), 3 deletions(-) diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/AbstractHttp2ConnectionHandlerBuilder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/AbstractHttp2ConnectionHandlerBuilder.java index 66c4f92d35c..cc80a2ff28e 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/AbstractHttp2ConnectionHandlerBuilder.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/AbstractHttp2ConnectionHandlerBuilder.java @@ -99,6 +99,7 @@ public abstract class AbstractHttp2ConnectionHandlerBuilderRFC 9113, 8.3. Disabled by default. + */ + protected boolean isValidateRequiredPseudoHeaders() { + return validateRequiredPseudoHeaders != null ? validateRequiredPseudoHeaders : false; + } + + /** + * Sets if request and response {@code HEADERS} that omit a mandatory pseudo-header field are rejected, + * according to RFC 9113, 8.3. + * Disabled by default. + */ + protected B validateRequiredPseudoHeaders(boolean validateRequiredPseudoHeaders) { + enforceNonCodecConstraints("validateRequiredPseudoHeaders"); + this.validateRequiredPseudoHeaders = validateRequiredPseudoHeaders; + return self(); + } + /** * Returns the logger that is used for the encoder and decoder. * @@ -643,7 +664,8 @@ private T buildFromConnection(Http2Connection connection) { } DefaultHttp2ConnectionDecoder decoder = new DefaultHttp2ConnectionDecoder(connection, encoder, reader, - promisedRequestVerifier(), isAutoAckSettingsFrame(), isAutoAckPingFrame(), isValidateHeaders()); + promisedRequestVerifier(), isAutoAckSettingsFrame(), isAutoAckPingFrame(), isValidateHeaders(), + isValidateRequiredPseudoHeaders()); return buildFromCodec(decoder, encoder); } diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoder.java index a96a700f2ce..f1e363d9dc7 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoder.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoder.java @@ -17,9 +17,11 @@ import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpStatusClass; import io.netty.handler.codec.http.HttpUtil; import io.netty.handler.codec.http2.Http2Connection.Endpoint; +import io.netty.handler.codec.http2.Http2Headers.PseudoHeaderName; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; @@ -63,6 +65,7 @@ public class DefaultHttp2ConnectionDecoder implements Http2ConnectionDecoder { private final boolean autoAckPing; private final Http2Connection.PropertyKey contentLengthKey; private final boolean validateHeaders; + private final boolean validateRequiredPseudoHeaders; public DefaultHttp2ConnectionDecoder(Http2Connection connection, Http2ConnectionEncoder encoder, @@ -129,7 +132,39 @@ public DefaultHttp2ConnectionDecoder(Http2Connection connection, boolean autoAckSettings, boolean autoAckPing, boolean validateHeaders) { + this(connection, encoder, frameReader, requestVerifier, autoAckSettings, autoAckPing, validateHeaders, false); + } + + /** + * Create a new instance. + * @param connection The {@link Http2Connection} associated with this decoder. + * @param encoder The {@link Http2ConnectionEncoder} associated with this decoder. + * @param frameReader Responsible for reading/parsing the raw frames. As opposed to this object which applies + * h2 semantics on top of the frames. + * @param requestVerifier Determines if push promised streams are valid. + * @param autoAckSettings {@code false} to disable automatically applying and sending settings acknowledge frame. + * The {@code Http2ConnectionEncoder} is expected to be an instance of + * {@link Http2SettingsReceivedConsumer} and will apply the earliest received but not yet + * ACKed SETTINGS when writing the SETTINGS ACKs. {@code true} to enable automatically + * applying and sending settings acknowledge frame. + * @param autoAckPing {@code false} to disable automatically sending ping acknowledge frame. {@code true} to enable + * automatically sending ping ack frame. + * @param validateHeaders {@code true} to validate headers according to + * RFC 7540, 8.1.2.6. + * @param validateRequiredPseudoHeaders {@code true} to reject request/response HEADERS that omit a mandatory + * pseudo-header field, according to + * RFC 9113, 8.3. + */ + public DefaultHttp2ConnectionDecoder(Http2Connection connection, + Http2ConnectionEncoder encoder, + Http2FrameReader frameReader, + Http2PromisedRequestVerifier requestVerifier, + boolean autoAckSettings, + boolean autoAckPing, + boolean validateHeaders, + boolean validateRequiredPseudoHeaders) { this.validateHeaders = validateHeaders; + this.validateRequiredPseudoHeaders = validateRequiredPseudoHeaders; this.autoAckPing = autoAckPing; if (autoAckSettings) { settingsReceivedConsumer = null; @@ -244,6 +279,48 @@ private void verifyContentLength(Http2Stream stream, int data, boolean isEnd) th } } + /** + * Validates that an initial request or response HEADERS frame carries the mandatory pseudo-header fields, + * as required by RFC 9113, 8.3. + * Trailers and informational (1xx) responses are handled by the caller and do not reach this method. + */ + private static void validateRequiredPseudoHeaders(boolean server, int streamId, Http2Headers headers) + throws Http2Exception { + if (server) { + // Request pseudo-header fields (RFC 9113, 8.3.1). + CharSequence method = headers.method(); + if (method == null) { + throw streamError(streamId, PROTOCOL_ERROR, + "Request is missing mandatory :method pseudo-header field."); + } + // CONNECT (RFC 9113, 8.5) omits :scheme/:path and carries :authority; extended CONNECT (RFC 8441), + // identified by :protocol, follows the regular :scheme/:path rules. + if (HttpMethod.CONNECT.asciiName().contentEquals(method) && + !headers.contains(PseudoHeaderName.PROTOCOL.value())) { + if (headers.authority() == null) { + throw streamError(streamId, PROTOCOL_ERROR, + "CONNECT request is missing mandatory :authority pseudo-header field."); + } + } else { + if (headers.scheme() == null) { + throw streamError(streamId, PROTOCOL_ERROR, + "Request is missing mandatory :scheme pseudo-header field."); + } + CharSequence path = headers.path(); + if (path == null || path.length() == 0) { + throw streamError(streamId, PROTOCOL_ERROR, + "Request is missing mandatory :path pseudo-header field."); + } + } + } else { + // Response pseudo-header fields (RFC 9113, 8.3.2). + if (headers.status() == null) { + throw streamError(streamId, PROTOCOL_ERROR, + "Response is missing mandatory :status pseudo-header field."); + } + } + } + /** * Handles all inbound frames from the network. */ @@ -395,6 +472,10 @@ public void onHeadersRead(ChannelHandlerContext ctx, int streamId, Http2Headers } if (!isTrailers) { + if (validateRequiredPseudoHeaders && !isInformational) { + // Reject initial request/response HEADERS that omit a mandatory pseudo-header (RFC 9113, 8.3). + validateRequiredPseudoHeaders(connection.isServer(), stream.id(), headers); + } // extract the content-length header List contentLength = headers.getAll(HttpHeaderNames.CONTENT_LENGTH); if (contentLength != null && !contentLength.isEmpty()) { diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ConnectionHandlerBuilder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ConnectionHandlerBuilder.java index be9db7ebd84..246f6119742 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ConnectionHandlerBuilder.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ConnectionHandlerBuilder.java @@ -31,6 +31,11 @@ public Http2ConnectionHandlerBuilder validateHeaders(boolean validateHeaders) { return super.validateHeaders(validateHeaders); } + @Override + public Http2ConnectionHandlerBuilder validateRequiredPseudoHeaders(boolean validateRequiredPseudoHeaders) { + return super.validateRequiredPseudoHeaders(validateRequiredPseudoHeaders); + } + @Override public Http2ConnectionHandlerBuilder initialSettings(Http2Settings settings) { return super.initialSettings(settings); diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2FrameCodecBuilder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2FrameCodecBuilder.java index 2a4a1320d0b..436f723e2cd 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2FrameCodecBuilder.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2FrameCodecBuilder.java @@ -109,6 +109,11 @@ public Http2FrameCodecBuilder validateHeaders(boolean validateHeaders) { return super.validateHeaders(validateHeaders); } + @Override + public Http2FrameCodecBuilder validateRequiredPseudoHeaders(boolean validateRequiredPseudoHeaders) { + return super.validateRequiredPseudoHeaders(validateRequiredPseudoHeaders); + } + @Override public Http2FrameLogger frameLogger() { return super.frameLogger(); @@ -239,7 +244,8 @@ public Http2FrameCodec build() { encoder = new StreamBufferingEncoder(encoder); } Http2ConnectionDecoder decoder = new DefaultHttp2ConnectionDecoder(connection, encoder, frameReader, - promisedRequestVerifier(), isAutoAckSettingsFrame(), isAutoAckPingFrame(), isValidateHeaders()); + promisedRequestVerifier(), isAutoAckSettingsFrame(), isAutoAckPingFrame(), isValidateHeaders(), + isValidateRequiredPseudoHeaders()); int maxConsecutiveEmptyDataFrames = decoderEnforceMaxConsecutiveEmptyDataFrames(); if (maxConsecutiveEmptyDataFrames > 0) { decoder = new Http2EmptyDataFrameConnectionDecoder(decoder, maxConsecutiveEmptyDataFrames); diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2MultiplexCodecBuilder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2MultiplexCodecBuilder.java index 945c232b7a1..046931851ba 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2MultiplexCodecBuilder.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2MultiplexCodecBuilder.java @@ -127,6 +127,11 @@ public Http2MultiplexCodecBuilder validateHeaders(boolean validateHeaders) { return super.validateHeaders(validateHeaders); } + @Override + public Http2MultiplexCodecBuilder validateRequiredPseudoHeaders(boolean validateRequiredPseudoHeaders) { + return super.validateRequiredPseudoHeaders(validateRequiredPseudoHeaders); + } + @Override public Http2FrameLogger frameLogger() { return super.frameLogger(); @@ -254,7 +259,8 @@ public Http2MultiplexCodec build() { encoder = new StreamBufferingEncoder(encoder); } Http2ConnectionDecoder decoder = new DefaultHttp2ConnectionDecoder(connection, encoder, frameReader, - promisedRequestVerifier(), isAutoAckSettingsFrame(), isAutoAckPingFrame(), isValidateHeaders()); + promisedRequestVerifier(), isAutoAckSettingsFrame(), isAutoAckPingFrame(), isValidateHeaders(), + isValidateRequiredPseudoHeaders()); int maxConsecutiveEmptyDataFrames = decoderEnforceMaxConsecutiveEmptyDataFrames(); if (maxConsecutiveEmptyDataFrames > 0) { decoder = new Http2EmptyDataFrameConnectionDecoder(decoder, maxConsecutiveEmptyDataFrames); diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpToHttp2ConnectionHandlerBuilder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpToHttp2ConnectionHandlerBuilder.java index 3d35a9f7e8c..2d6be3cc274 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpToHttp2ConnectionHandlerBuilder.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpToHttp2ConnectionHandlerBuilder.java @@ -32,6 +32,11 @@ public HttpToHttp2ConnectionHandlerBuilder validateHeaders(boolean validateHeade return super.validateHeaders(validateHeaders); } + @Override + public HttpToHttp2ConnectionHandlerBuilder validateRequiredPseudoHeaders(boolean validateRequiredPseudoHeaders) { + return super.validateRequiredPseudoHeaders(validateRequiredPseudoHeaders); + } + @Override public HttpToHttp2ConnectionHandlerBuilder initialSettings(Http2Settings settings) { return super.initialSettings(settings); diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoderTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoderTest.java index b2039a0846b..24a0dfb6699 100644 --- a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoderTest.java +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoderTest.java @@ -601,6 +601,141 @@ public void execute() throws Throwable { assertThat(ex.getMessage()).contains(pseudoHeader); } + // Builds a decoder with validateRequiredPseudoHeaders enabled, primed past the preface. + private Http2FrameListener strictDecode() throws Exception { + DefaultHttp2ConnectionDecoder strict = new DefaultHttp2ConnectionDecoder( + connection, encoder, reader, ALWAYS_VERIFY, true, true, true, true); + strict.lifecycleManager(lifecycleManager); + strict.frameListener(listener); + decode(strict).onSettingsRead(ctx, new Http2Settings()); + return decode(strict); + } + + private static Http2Headers request() { + return new DefaultHttp2Headers().method("GET").scheme("https").authority("example.org").path("/"); + } + + private Http2Exception assertHeadersRejected(final Http2FrameListener dec, final Http2Headers headers, + final boolean endOfStream) throws Http2Exception { + Http2Exception ex = assertThrows(Http2Exception.class, new Executable() { + @Override + public void execute() throws Throwable { + dec.onHeadersRead(ctx, STREAM_ID, headers, 0, endOfStream); + } + }); + assertEquals(PROTOCOL_ERROR, ex.error()); + verify(listener, never()).onHeadersRead(eq(ctx), eq(STREAM_ID), eq(headers), eq(0), + eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(endOfStream)); + return ex; + } + + @ParameterizedTest + @ValueSource(strings = {":method", ":scheme", ":path"}) + public void requestMissingMandatoryPseudoHeaderRejectedWhenEnabled(String missing) throws Exception { + when(connection.isServer()).thenReturn(true); + Http2Headers headers = request(); + headers.remove(missing); + assertThat(assertHeadersRejected(strictDecode(), headers, true).getMessage()).contains(missing); + } + + @Test + public void requestEmptyPathRejectedWhenEnabled() throws Exception { + when(connection.isServer()).thenReturn(true); + // Disable header-level validation so the empty :path reaches the decoder's own check. + Http2Headers headers = new DefaultHttp2Headers(false) + .method("GET").scheme("https").authority("example.org").path(""); + assertThat(assertHeadersRejected(strictDecode(), headers, true).getMessage()).contains(":path"); + } + + @Test + public void emptyRequestRejectedWhenEnabled() throws Exception { + when(connection.isServer()).thenReturn(true); + assertHeadersRejected(strictDecode(), EmptyHttp2Headers.INSTANCE, true); + } + + @Test + public void validRequestAcceptedWhenEnabled() throws Exception { + when(connection.isServer()).thenReturn(true); + Http2Headers headers = request(); + strictDecode().onHeadersRead(ctx, STREAM_ID, headers, 0, true); + verify(listener).onHeadersRead(eq(ctx), eq(STREAM_ID), eq(headers), eq(0), + eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(true)); + } + + @Test + public void connectRequestWithoutSchemeAndPathAcceptedWhenEnabled() throws Exception { + when(connection.isServer()).thenReturn(true); + // A CONNECT request (RFC 9113, 8.5) omits :scheme and :path and only carries :authority. + Http2Headers headers = new DefaultHttp2Headers().method("CONNECT").authority("example.org:443"); + strictDecode().onHeadersRead(ctx, STREAM_ID, headers, 0, false); + verify(listener).onHeadersRead(eq(ctx), eq(STREAM_ID), eq(headers), eq(0), + eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false)); + } + + @Test + public void connectRequestMissingAuthorityRejectedWhenEnabled() throws Exception { + when(connection.isServer()).thenReturn(true); + Http2Headers headers = new DefaultHttp2Headers().method("CONNECT"); + assertThat(assertHeadersRejected(strictDecode(), headers, false).getMessage()).contains(":authority"); + } + + @Test + public void extendedConnectRequiresSchemeAndPathWhenEnabled() throws Exception { + when(connection.isServer()).thenReturn(true); + // Extended CONNECT (RFC 8441) is identified by :protocol and must include :scheme and :path. + Http2Headers headers = new DefaultHttp2Headers().method("CONNECT").authority("example.org"); + headers.add(Http2Headers.PseudoHeaderName.PROTOCOL.value(), "websocket"); + assertThat(assertHeadersRejected(strictDecode(), headers, false).getMessage()).contains(":scheme"); + } + + @Test + public void extendedConnectAcceptedWhenEnabled() throws Exception { + when(connection.isServer()).thenReturn(true); + Http2Headers headers = new DefaultHttp2Headers().method("CONNECT").scheme("https") + .authority("example.org").path("/chat"); + headers.add(Http2Headers.PseudoHeaderName.PROTOCOL.value(), "websocket"); + strictDecode().onHeadersRead(ctx, STREAM_ID, headers, 0, false); + verify(listener).onHeadersRead(eq(ctx), eq(STREAM_ID), eq(headers), eq(0), + eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false)); + } + + @Test + public void responseMissingStatusRejectedWhenEnabled() throws Exception { + // isServer() defaults to false, so inbound HEADERS are treated as a response. + assertThat(assertHeadersRejected(strictDecode(), EmptyHttp2Headers.INSTANCE, true).getMessage()) + .contains(":status"); + } + + @Test + public void validResponseAcceptedWhenEnabled() throws Exception { + Http2Headers headers = new DefaultHttp2Headers().status("200"); + strictDecode().onHeadersRead(ctx, STREAM_ID, headers, 0, true); + verify(listener).onHeadersRead(eq(ctx), eq(STREAM_ID), eq(headers), eq(0), + eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(true)); + } + + @Test + public void trailersNotRequiredToCarryPseudoHeadersWhenEnabled() throws Exception { + when(connection.isServer()).thenReturn(true); + Http2FrameListener dec = strictDecode(); + // Valid initial request headers. + dec.onHeadersRead(ctx, STREAM_ID, request(), 0, false); + // Trailers carry no pseudo-headers and must still be accepted (check applies to initial HEADERS only). + Http2Headers trailers = new DefaultHttp2Headers().add("x-trailer", "value"); + dec.onHeadersRead(ctx, STREAM_ID, trailers, 0, true); + verify(listener).onHeadersRead(eq(ctx), eq(STREAM_ID), eq(trailers), eq(0), + eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(true)); + } + + @Test + public void defaultDecoderDoesNotValidateMandatoryPseudoHeaders() throws Exception { + when(connection.isServer()).thenReturn(true); + // The default decoder has validateRequiredPseudoHeaders disabled, so an empty request is accepted. + decode().onHeadersRead(ctx, STREAM_ID, EmptyHttp2Headers.INSTANCE, 0, true); + verify(listener).onHeadersRead(eq(ctx), eq(STREAM_ID), eq(EmptyHttp2Headers.INSTANCE), eq(0), + eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(true)); + } + @Test public void tooManyHeadersEOSThrows() throws Exception { tooManyHeaderThrows(true); diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2FrameCodecTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2FrameCodecTest.java index 485499dbb72..82679072f4c 100644 --- a/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2FrameCodecTest.java +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/Http2FrameCodecTest.java @@ -317,6 +317,30 @@ public void sendRstStream() throws Exception { assertTrue(channel.isActive()); } + @Test + public void validateRequiredPseudoHeadersOptionRejectsMalformedRequest() throws Exception { + // Verify the builder option is propagated into the decoder end-to-end. + setUp(Http2FrameCodecBuilder.forServer().validateRequiredPseudoHeaders(true), new Http2Settings()); + + Http2Headers malformed = new DefaultHttp2Headers().method(HttpMethod.GET.asciiName()); + frameInboundWriter.writeInboundHeaders(3, malformed, 31, false); + + // The malformed request (no :scheme/:path) must be rejected with a PROTOCOL_ERROR stream error. + Http2FrameStreamException e = assertThrows(Http2FrameStreamException.class, new Executable() { + @Override + public void execute() throws Throwable { + inboundHandler.checkException(); + } + }); + assertEquals(Http2Error.PROTOCOL_ERROR, e.error()); + // It must not be delivered as a HEADERS frame. + Object msg; + while ((msg = inboundHandler.readInboundMessageOrUserEvent()) != null) { + assertFalse(msg instanceof Http2HeadersFrame); + ReferenceCountUtil.release(msg); + } + } + @Test public void receiveRstStream() throws Exception { frameInboundWriter.writeInboundHeaders(3, request, 31, false); From 82e3dc18d0043db82a5285ad2782c21eb33a0237 Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Wed, 17 Jun 2026 14:13:11 -0700 Subject: [PATCH 14/64] Strictly validate MQTT UTF-8 Encoded String (#16939) (#16965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: Per MQTT 3.1.1 [MQTT-1.5.3-1] / MQTT 5.0 [MQTT-1.5.4-1], the character data in a UTF-8 Encoded String MUST be well-formed UTF-8 as defined by the Unicode specification and restated in RFC 3629. In particular it MUST NOT contain encodings of code points between U+D800 and U+DFFF, overlong sequences, or sequences longer than 4 bytes. If received, the Control Packet MUST be treated as a Malformed Packet. `MqttDecoder` currently decodes every UTF-8 Encoded String via `ByteBuf#readString(size, UTF_8)`, which delegates to `new String(bytes, UTF_8)`. That constructor uses replacement semantics: malformed UTF-8 sequences are silently replaced with U+FFFD instead of being reported, and an embedded U+0000 byte is accepted. This affects every UTF-8 Encoded String in MQTT, including ClientId, Will Topic, Topic Name (PUBLISH / SUBSCRIBE / UNSUBSCRIBE filter), User Name, and the MQTT 5 string properties (Content Type, Response Topic, Reason String, Authentication Method, Server Reference, User Property, …). Beyond spec compliance, silently rewriting these fields can cause routing/ACL/identity mismatches between the wire representation and what the application sees. Modifications: * `MqttDecoder` now performs strict UTF-8 validation by default: * UTF-8 strings are decoded through a per-instance `CharsetDecoder` configured with `CodingErrorAction.REPORT` for both `onMalformedInput` and `onUnmappableCharacter`. Any `CharacterCodingException` is converted to a `DecoderException`. * After successful decoding, the resulting `String` is scanned for `U+0000`; if present, a `DecoderException` is thrown. * The exceptions propagate through the existing decode error path (`MqttMessageFactory.newInvalidMessage`), so callers continue to receive a single `MqttMessage` with `decoderResult().isFailure() == true`, matching the existing behaviour for other malformed-packet conditions (e.g. non-zero reserved flag). * A new opt-out constructor `MqttDecoder(int maxBytesInMessage, int maxClientIdLength, boolean strictUtf8Validation)` is provided for users that need to retain the historical replacement-char behaviour. The pre-existing constructors delegate to it with `strictUtf8Validation = true`. * The previously-static `decodeString` and `decodeProperties` helpers are converted to instance methods to access the new flag (the surrounding variable-header / payload decoders were already instance methods that call them; no signature changes for any other helpers). * New tests in `MqttCodecTest` exercise: * invalid 2-byte continuation (`0xC3 0x28`) * truncated multi-byte sequence at end-of-string (lone `0xC3`) * Modified-UTF-8 overlong NUL (`0xC0 0x80`) * isolated UTF-16 high surrogate (`0xED 0xA0 0x80` → U+D800) * 5-byte sequence forbidden by RFC 3629 (`0xF8 …`) * embedded U+0000 * well-formed multi-byte UTF-8 ("hello") decodes successfully * empty ClientId is still accepted under strict mode * legacy mode (`strictUtf8Validation = false`) still accepts malformed UTF-8 (replaced with U+FFFD) and embedded U+0000 Result: * MQTT spec [MQTT-1.5.3-1/2] and [MQTT-1.5.4-1/2] are enforced for every UTF-8 Encoded String parsed by `MqttDecoder` (ClientId, Will Topic, Topic Name, Topic Filter, User Name, MQTT 5 string properties, including User Property key/value pairs). * Behaviour change: packets that previously decoded into messages containing U+FFFD or U+0000 will now be reported as malformed. Users relying on the old behaviour can opt out via the new constructor flag. * No API removed; the existing `MqttDecoder()`, `MqttDecoder(int)` and `MqttDecoder(int, int)` constructors remain source- and binary- compatible. (cherry picked from commit ba8e9e64f89fc91b5085cd8bc21bd9ef83264ba1) Co-authored-by: skyguard1 --- .../netty/handler/codec/mqtt/MqttDecoder.java | 88 ++++++++++- .../handler/codec/mqtt/MqttCodecTest.java | 148 ++++++++++++++++++ 2 files changed, 229 insertions(+), 7 deletions(-) diff --git a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java index da561ae8212..8612a15de9d 100644 --- a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java +++ b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java @@ -27,6 +27,10 @@ import io.netty.util.Signal; import io.netty.util.internal.ObjectUtil; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; import java.util.ArrayList; import java.util.List; @@ -67,19 +71,42 @@ enum DecoderState { private final int maxBytesInMessage; private final int maxClientIdLength; + private final boolean strictUtf8Validation; + // Lazily-initialised UTF-8 decoder reused across calls in the same channel/decoder + // instance. ReplayingDecoder is invoked from a single thread per channel, so a non + // thread-safe CharsetDecoder is safe to cache here. + private CharsetDecoder utf8Decoder; public MqttDecoder() { - this(DEFAULT_MAX_BYTES_IN_MESSAGE, DEFAULT_MAX_CLIENT_ID_LENGTH); + this(DEFAULT_MAX_BYTES_IN_MESSAGE, DEFAULT_MAX_CLIENT_ID_LENGTH, true); } public MqttDecoder(int maxBytesInMessage) { - this(maxBytesInMessage, DEFAULT_MAX_CLIENT_ID_LENGTH); + this(maxBytesInMessage, DEFAULT_MAX_CLIENT_ID_LENGTH, true); } public MqttDecoder(int maxBytesInMessage, int maxClientIdLength) { + this(maxBytesInMessage, maxClientIdLength, true); + } + + /** + * Creates a new {@link MqttDecoder}. + * + * @param maxBytesInMessage the maximum number of bytes a decoded message may consume. + * @param maxClientIdLength the maximum length of the Client Identifier (CONNECT payload). + * @param strictUtf8Validation if {@code true} (default), every UTF-8 Encoded String is + * validated according to MQTT 3.1.1 and MQTT 5.0 + * malformed UTF-8 sequences (including + * surrogates and overlong forms) and an embedded U+0000 are + * rejected as a Malformed Packet. If {@code false}, the legacy + * behaviour is preserved, malformed bytes are silently replaced + * with {@code U+FFFD} and U+0000 is accepted. + */ + public MqttDecoder(int maxBytesInMessage, int maxClientIdLength, boolean strictUtf8Validation) { super(DecoderState.READ_FIXED_HEADER); this.maxBytesInMessage = ObjectUtil.checkPositive(maxBytesInMessage, "maxBytesInMessage"); this.maxClientIdLength = ObjectUtil.checkPositive(maxClientIdLength, "maxClientIdLength"); + this.strictUtf8Validation = strictUtf8Validation; } @Override @@ -652,11 +679,11 @@ private void validateNoBytesRemain(int numberOfBytesConsumed) { } } - private static Result decodeString(ByteBuf buffer) { + private Result decodeString(ByteBuf buffer) { return decodeString(buffer, 0, Integer.MAX_VALUE); } - private static Result decodeString(ByteBuf buffer, int minBytes, int maxBytes) { + private Result decodeString(ByteBuf buffer, int minBytes, int maxBytes) { int size = decodeMsbLsb(buffer); int numberOfBytesConsumed = 2; if (size < minBytes || size > maxBytes) { @@ -664,12 +691,59 @@ private static Result decodeString(ByteBuf buffer, int minBytes, int max numberOfBytesConsumed += size; return new Result(null, numberOfBytesConsumed); } - String s = buffer.toString(buffer.readerIndex(), size, CharsetUtil.UTF_8); - buffer.skipBytes(size); + final String s; + if (strictUtf8Validation) { + s = readStrictUtf8(buffer, size); + } else { + s = buffer.toString(buffer.readerIndex(), size, CharsetUtil.UTF_8); + buffer.skipBytes(size); + } numberOfBytesConsumed += size; return new Result(s, numberOfBytesConsumed); } + /** + * Reads {@code length} bytes from {@code buffer} and decodes them as a strictly validated + * UTF-8 Encoded String per MQTT 3.1.1 and MQTT 5.0. + * Throws a {@link DecoderException} if the sequence is malformed or contains U+0000. + */ + private String readStrictUtf8(ByteBuf buffer, int length) { + if (length == 0) { + return ""; + } + final int readerIndex = buffer.readerIndex(); + final ByteBuffer nioBuf; + if (buffer.nioBufferCount() == 1) { + nioBuf = buffer.nioBuffer(readerIndex, length); + } else { + // Composite/multi-component buffer: copy out to ensure a contiguous view for the + // CharsetDecoder. Strict UTF-8 validation requires examining all bytes anyway. + byte[] tmp = new byte[length]; + buffer.getBytes(readerIndex, tmp); + nioBuf = ByteBuffer.wrap(tmp); + } + if (utf8Decoder == null) { + utf8Decoder = CharsetUtil.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + } + utf8Decoder.reset(); + final String s; + try { + s = utf8Decoder.decode(nioBuf).toString(); + } catch (CharacterCodingException e) { + buffer.skipBytes(length); + throw new DecoderException("invalid UTF-8 string in MQTT packet", e); + } + buffer.skipBytes(length); + // The UTF-8 Encoded String MUST NOT include an encoding + // of the null character U+0000. If received, this is a Malformed Packet. + if (s.indexOf('\u0000') >= 0) { + throw new DecoderException("MQTT UTF-8 Encoded String must not contain U+0000"); + } + return s; + } + /** * * @return the decoded byte[], numberOfBytesConsumed = byte[].length + 2 @@ -745,7 +819,7 @@ private static final class Result { } } - private static Result decodeProperties(ByteBuf buffer) { + private Result decodeProperties(ByteBuf buffer) { final long propertiesLength = decodeVariableByteInteger(buffer); int totalPropertiesLength = unpackA(propertiesLength); int numberOfBytesConsumed = unpackB(propertiesLength); diff --git a/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java b/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java index 1862db6202c..f5584293a04 100644 --- a/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java +++ b/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java @@ -18,6 +18,7 @@ import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.Unpooled; import io.netty.buffer.UnpooledByteBufAllocator; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; @@ -63,6 +64,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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; @@ -1334,4 +1336,150 @@ private void validateReasonCodeAndPropertiesVariableHeader(MqttReasonCodeAndProp final MqttProperties actualProps = actual.properties(); validateProperties(expectedProps, actualProps); } + + /** + * Builds a minimal MQTT 3.1.1 CONNECT packet whose ClientId field contains the supplied + * raw bytes (length prefix is computed automatically). Protocol = "MQTT", level = 4, + * clean-session flag set, keepalive 60. + */ + private static ByteBuf buildConnectWithClientIdBytes(byte[] clientIdBytes) { + ByteBuf buf = Unpooled.buffer(); + // variable header (10 bytes) + ClientId field (2 + N bytes); test packets stay small, + // so a single-byte Remaining Length is sufficient. + int remainingLength = 10 + 2 + clientIdBytes.length; + buf.writeByte(0x10); // CONNECT + buf.writeByte(remainingLength); + // Variable header + buf.writeShort(4); + buf.writeBytes(new byte[] {'M', 'Q', 'T', 'T'}); + buf.writeByte(0x04); // protocol level (MQTT 3.1.1) + buf.writeByte(0x02); // clean session + buf.writeShort(60); // keep alive + // Payload: ClientId + buf.writeShort(clientIdBytes.length); + buf.writeBytes(clientIdBytes); + return buf; + } + + private static MqttMessage decodeUtf8TestPacket(MqttDecoder decoder, ByteBuf in) { + EmbeddedChannel channel = new EmbeddedChannel(decoder); + try { + channel.writeInbound(in); + return channel.readInbound(); + } finally { + channel.finishAndReleaseAll(); + } + } + + private static void assertMalformedUtf8(MqttMessage msg) { + assertNotNull(msg); + assertTrue(msg.decoderResult().isFailure(), "expected decoder failure but got: " + msg); + assertInstanceOf(DecoderException.class, msg.decoderResult().cause()); + } + + @Test + public void invalidTwoByteUtf8SequenceIsRejectedByDefault() { + // 0xC3 must be followed by a 10xxxxxx continuation byte; 0x28 is not. + ByteBuf packet = buildConnectWithClientIdBytes(new byte[] {(byte) 0xC3, 0x28}); + assertMalformedUtf8(decodeUtf8TestPacket(new MqttDecoder(), packet)); + } + + @Test + public void truncatedMultibyteUtf8SequenceIsRejected() { + // Lone 0xC3 with no continuation byte at all (string ends mid-sequence). + ByteBuf packet = buildConnectWithClientIdBytes(new byte[] {(byte) 0xC3}); + assertMalformedUtf8(decodeUtf8TestPacket(new MqttDecoder(), packet)); + } + + @Test + public void overlongNullUtf8EncodingIsRejected() { + // 0xC0 0x80 is the (invalid) Modified-UTF-8 overlong encoding of U+0000. + ByteBuf packet = buildConnectWithClientIdBytes(new byte[] {(byte) 0xC0, (byte) 0x80}); + assertMalformedUtf8(decodeUtf8TestPacket(new MqttDecoder(), packet)); + } + + @Test + public void isolatedHighSurrogateUtf8IsRejected() { + // 0xED 0xA0 0x80 = U+D800, an unpaired UTF-16 high surrogate; not valid UTF-8. + ByteBuf packet = buildConnectWithClientIdBytes(new byte[] {(byte) 0xED, (byte) 0xA0, (byte) 0x80}); + assertMalformedUtf8(decodeUtf8TestPacket(new MqttDecoder(), packet)); + } + + @Test + public void fiveByteOverlongUtf8SequenceIsRejected() { + // 0xF8 starts a 5-byte sequence which is not allowed by RFC 3629. + ByteBuf packet = buildConnectWithClientIdBytes( + new byte[] {(byte) 0xF8, (byte) 0x88, (byte) 0x80, (byte) 0x80, (byte) 0x80}); + assertMalformedUtf8(decodeUtf8TestPacket(new MqttDecoder(), packet)); + } + + @Test + public void embeddedNullCharacterInUtf8StringIsRejected() { + // U+0000 must cause Malformed Packet. + ByteBuf packet = buildConnectWithClientIdBytes(new byte[] {'a', 0x00, 'b'}); + assertMalformedUtf8(decodeUtf8TestPacket(new MqttDecoder(), packet)); + } + + @Test + public void wellFormedMultibyteUtf8IsAccepted() { + byte[] cid = {'h', 'e', 'l', 'l', 'o'}; + ByteBuf packet = buildConnectWithClientIdBytes(cid); + MqttMessage msg = decodeUtf8TestPacket(new MqttDecoder(), packet); + assertNotNull(msg); + try { + assertTrue(msg.decoderResult().isSuccess(), + "expected success but got: " + msg.decoderResult().cause()); + assertInstanceOf(MqttConnectMessage.class, msg); + assertEquals("hello", ((MqttConnectMessage) msg).payload().clientIdentifier()); + } finally { + ReferenceCountUtil.release(msg); + } + } + + @Test + public void emptyClientIdIsAcceptedUnderStrictUtf8() { + ByteBuf packet = buildConnectWithClientIdBytes(new byte[0]); + MqttMessage msg = decodeUtf8TestPacket(new MqttDecoder(), packet); + assertNotNull(msg); + try { + assertTrue(msg.decoderResult().isSuccess()); + } finally { + ReferenceCountUtil.release(msg); + } + } + + @Test + public void legacyModeAcceptsMalformedUtf8AsReplacementChar() { + ByteBuf packet = buildConnectWithClientIdBytes(new byte[] {(byte) 0xC3, 0x28}); + MqttDecoder lenient = new MqttDecoder(8 * 1024 * 1024, 23, false); + MqttMessage msg = decodeUtf8TestPacket(lenient, packet); + assertNotNull(msg); + try { + assertTrue(msg.decoderResult().isSuccess(), + "lenient mode should accept malformed UTF-8"); + assertInstanceOf(MqttConnectMessage.class, msg); + String cid = ((MqttConnectMessage) msg).payload().clientIdentifier(); + assertNotNull(cid); + // java.lang.String inserts U+FFFD for malformed input. Exact length is JDK + // implementation defined; just make sure decoding did not throw. + assertTrue(cid.indexOf('\uFFFD') >= 0 || cid.length() > 0, + "expected replacement char or non-empty result"); + } finally { + ReferenceCountUtil.release(msg); + } + } + + @Test + public void legacyModeAcceptsEmbeddedNullCharacter() { + ByteBuf packet = buildConnectWithClientIdBytes(new byte[] {'a', 0x00, 'b'}); + MqttDecoder lenient = new MqttDecoder(8 * 1024 * 1024, 23, false); + MqttMessage msg = decodeUtf8TestPacket(lenient, packet); + assertNotNull(msg); + try { + assertTrue(msg.decoderResult().isSuccess()); + assertEquals("a\u0000b", ((MqttConnectMessage) msg).payload().clientIdentifier()); + } finally { + ReferenceCountUtil.release(msg); + } + } } From dc6801681e97eb3c0f01b0c79314366d26eef41f Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:29:10 +0200 Subject: [PATCH 15/64] Auto-port 4.1: Stop DateFormatter trailing token from running past the parse end (#16968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-port of #16958 to 4.1 Cherry-picked commit: 692d23f9899c34e9fed537e28ba940bfc952a763 --- ## Problem `DateFormatter.parseHttpDate(txt, start, end)` is documented to parse only the `[start, end)` substring. Its tokenizer loop correctly stops at `end`, but the **trailing token** — the one still open when the loop finishes — was terminated at `txt.length()` instead of `end`: ```java // terminate trailing token return tokenStart != -1 && parseToken(txt, tokenStart, txt.length()); ``` When `end < txt.length()` and the date's *last* token is the one that completes the parse, the trailing token swallows the bytes after `end` and fails to parse. This is reachable in practice through cookies. A `Set-Cookie` header like `foo=bar; Expires=; Path=/` makes `ClientCookieDecoder` call `parseHttpDate(header, start, end)` with `end` pointing at the `;` before `Path`. RFC 6265 §5.1.1 cookie-date tokens are **order-independent**, so a valid date whose year (or day) is the last token — e.g. `Sun 08:49:37 06 Nov 1994` — parses fine in isolation but returns `null` as a substring, silently dropping the expiry and downgrading the cookie to a session cookie. Standard `Sun, 06 Nov 1994 08:49:37 GMT` ordering does not trigger it (the time token completes the parse mid-loop, before the trailing `GMT`), which is why existing tests miss it. ## Fix Terminate the trailing token at `end` rather than `txt.length()`. Since `end <= txt.length()` always, this only ever shrinks the trailing token to the intended bound; the full-string `parseHttpDate(txt)` path (where `end == txt.length()`) is unaffected. Added a `DateFormatterTest` case that parses such a date both in isolation and as a substring and asserts they agree. ## Result `parseHttpDate` honours the `end` bound for the trailing token; valid order-independent cookie dates followed by other attributes now parse correctly. Full `DateFormatterTest` (14) passes. Co-authored-by: Guimu <30684111+daguimu@users.noreply.github.com> Co-authored-by: Norman Maurer --- .../main/java/io/netty/handler/codec/DateFormatter.java | 4 ++-- .../java/io/netty/handler/codec/DateFormatterTest.java | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/codec/src/main/java/io/netty/handler/codec/DateFormatter.java b/codec/src/main/java/io/netty/handler/codec/DateFormatter.java index 8897cb5a602..8791103175f 100644 --- a/codec/src/main/java/io/netty/handler/codec/DateFormatter.java +++ b/codec/src/main/java/io/netty/handler/codec/DateFormatter.java @@ -388,8 +388,8 @@ private boolean parse1(CharSequence txt, int start, int end) { } } - // terminate trailing token - return tokenStart != -1 && parseToken(txt, tokenStart, txt.length()); + // terminate trailing token at end, not txt.length(), so a substring parse doesn't overrun + return tokenStart != -1 && parseToken(txt, tokenStart, end); } private boolean normalizeAndValidate() { diff --git a/codec/src/test/java/io/netty/handler/codec/DateFormatterTest.java b/codec/src/test/java/io/netty/handler/codec/DateFormatterTest.java index e7b3d1af4c1..38f29d749e0 100644 --- a/codec/src/test/java/io/netty/handler/codec/DateFormatterTest.java +++ b/codec/src/test/java/io/netty/handler/codec/DateFormatterTest.java @@ -37,6 +37,14 @@ public void testParseWithSingleDigitDay() { assertEquals(DATE, parseHttpDate("Sun, 6 Nov 1994 08:49:37 GMT")); } + @Test + public void testParseHttpDateSubstringDoesNotOverrunEnd() { + // Set-Cookie header format with the date anywhere but last (RFC 6265 tokens are unordered). + String dateText = "Sun 08:49:37 06 Nov 1994"; + assertEquals(DATE, parseHttpDate(dateText)); + assertEquals(DATE, parseHttpDate(dateText + "; Path=/", 0, dateText.length())); + } + @Test public void testParseWithDoubleDigitDay() { assertEquals(DATE, parseHttpDate("Sun, 06 Nov 1994 08:49:37 GMT")); From 74b2fcf85724fc955fd4562ff3ef0bb703afa287 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:58:35 +0200 Subject: [PATCH 16/64] Auto-port 4.1: IpFilter: Deprecate constructor which use accept by default (#16973) Auto-port of #16961 to 4.1 Cherry-picked commit: f9d8c42ea816f87ac21e8ad16c6bf39759493799 --- Motivation: We should better let the user explicit configure if something should be accepted by default or not so it's not done by mistake. Modifications: - Deprecate constructor which accept by default Result: Make user aware of default behaviour Co-authored-by: Norman Maurer --- .../main/java/io/netty/handler/ipfilter/RuleBasedIpFilter.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/handler/src/main/java/io/netty/handler/ipfilter/RuleBasedIpFilter.java b/handler/src/main/java/io/netty/handler/ipfilter/RuleBasedIpFilter.java index 92eac83e543..20ceb5d6cc5 100644 --- a/handler/src/main/java/io/netty/handler/ipfilter/RuleBasedIpFilter.java +++ b/handler/src/main/java/io/netty/handler/ipfilter/RuleBasedIpFilter.java @@ -53,7 +53,9 @@ public class RuleBasedIpFilter extends AbstractRemoteAddressFilter {@code acceptIfNotFound} is set to {@code true}.

* * @param rules An array of {@link IpFilterRule} containing all rules. + * @deprecated Use {@link RuleBasedIpFilter#RuleBasedIpFilter(boolean, IpFilterRule...)} */ + @Deprecated public RuleBasedIpFilter(IpFilterRule... rules) { this(true, rules); } From ec224a0f27fb66d1b074cd983e5daffb674dfb30 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Fri, 19 Jun 2026 02:58:29 +0200 Subject: [PATCH 17/64] Add RFC 10008 QUERY Method support (#16966) (#16978) Motivation: Add basic blocks for RFC 10008 (The HTTP QUERY method) Modification: Added the QUERY HttpMethod as well as the Accept-Query header constant Result: Downstream consumers have an easier time implementing RFC 10008 Co-authored-by: Mario Daniel Ruiz Saavedra --- .../java/io/netty/handler/codec/http/HttpHeaderNames.java | 4 ++++ .../main/java/io/netty/handler/codec/http/HttpMethod.java | 8 ++++++++ .../handler/codec/http/HttpHeaderValidationUtilTest.java | 1 + .../java/io/netty/handler/codec/http/HttpMethodTest.java | 1 + .../netty/handler/codec/http/HttpMethodMapBenchmark.java | 4 ++++ 5 files changed, 18 insertions(+) diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpHeaderNames.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpHeaderNames.java index 9b68ea32b53..87fbfb13669 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpHeaderNames.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpHeaderNames.java @@ -49,6 +49,10 @@ public final class HttpHeaderNames { * {@code "accept-patch"} */ public static final AsciiString ACCEPT_PATCH = AsciiString.cached("accept-patch"); + /** + * {@code "accept-query"} + */ + public static final AsciiString ACCEPT_QUERY = AsciiString.cached("accept-query"); /** * {@code "access-control-allow-credentials"} */ diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpMethod.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpMethod.java index 2797e2fb822..4eefcf945df 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpMethod.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpMethod.java @@ -85,6 +85,12 @@ public class HttpMethod implements Comparable { */ public static final HttpMethod CONNECT = new HttpMethod(AsciiString.cached("CONNECT")); + /** + * The QUERY method requests that the request target process the enclosed content in a safe and + * idempotent manner and then respond with the result of that processing. + */ + public static final HttpMethod QUERY = new HttpMethod(AsciiString.cached("QUERY")); + /** * Returns the {@link HttpMethod} represented by the specified name. * If the specified name is a standard HTTP method name, a cached instance @@ -109,6 +115,8 @@ public static HttpMethod valueOf(String name) { return HttpMethod.DELETE; } else if (name.equals("TRACE")) { return HttpMethod.TRACE; + } else if (name.equals("QUERY")) { + return HttpMethod.QUERY; } return new HttpMethod(name); } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/HttpHeaderValidationUtilTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/HttpHeaderValidationUtilTest.java index 654b1562f73..95574e5c378 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/HttpHeaderValidationUtilTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/HttpHeaderValidationUtilTest.java @@ -50,6 +50,7 @@ public static List connectionRelatedHeaders() { list.add(header(false, HttpHeaderNames.ACCEPT_LANGUAGE)); list.add(header(false, HttpHeaderNames.ACCEPT_RANGES)); list.add(header(false, HttpHeaderNames.ACCEPT_PATCH)); + list.add(header(false, HttpHeaderNames.ACCEPT_QUERY)); list.add(header(false, HttpHeaderNames.ACCESS_CONTROL_ALLOW_CREDENTIALS)); list.add(header(false, HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS)); list.add(header(false, HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS)); diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/HttpMethodTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/HttpMethodTest.java index 0e3fbb589d8..bc02375764b 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/HttpMethodTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/HttpMethodTest.java @@ -37,6 +37,7 @@ public class HttpMethodTest { public void valueOfReturnsCachedInstanceForKnownMethods() { assertSame(HttpMethod.GET, HttpMethod.valueOf("GET")); assertSame(HttpMethod.POST, HttpMethod.valueOf("POST")); + assertSame(HttpMethod.QUERY, HttpMethod.valueOf("QUERY")); } @Test diff --git a/microbench/src/main/java/io/netty/handler/codec/http/HttpMethodMapBenchmark.java b/microbench/src/main/java/io/netty/handler/codec/http/HttpMethodMapBenchmark.java index bb657a73436..c96b37b7bf5 100644 --- a/microbench/src/main/java/io/netty/handler/codec/http/HttpMethodMapBenchmark.java +++ b/microbench/src/main/java/io/netty/handler/codec/http/HttpMethodMapBenchmark.java @@ -37,6 +37,7 @@ import static io.netty.handler.codec.http.HttpMethod.POST; import static io.netty.handler.codec.http.HttpMethod.PUT; import static io.netty.handler.codec.http.HttpMethod.TRACE; +import static io.netty.handler.codec.http.HttpMethod.QUERY; import static io.netty.util.internal.MathUtil.findNextPositivePowerOfTwo; @State(Scope.Benchmark) @@ -61,6 +62,7 @@ public class HttpMethodMapBenchmark extends AbstractMicrobenchmark { "POST", "PUT", "PATCH", + "QUERY", "DELETE", "TRACE", "CONNECT" @@ -98,6 +100,7 @@ public class HttpMethodMapBenchmark extends AbstractMicrobenchmark { OLD_MAP.put(DELETE.toString(), DELETE); OLD_MAP.put(TRACE.toString(), TRACE); OLD_MAP.put(CONNECT.toString(), CONNECT); + OLD_MAP.put(QUERY.toString(), QUERY); NEW_MAP = new SimpleStringMap( new SimpleStringMap.Node(OPTIONS.toString(), OPTIONS), @@ -106,6 +109,7 @@ public class HttpMethodMapBenchmark extends AbstractMicrobenchmark { new SimpleStringMap.Node(POST.toString(), POST), new SimpleStringMap.Node(PUT.toString(), PUT), new SimpleStringMap.Node(PATCH.toString(), PATCH), + new SimpleStringMap.Node(QUERY.toString(), QUERY), new SimpleStringMap.Node(DELETE.toString(), DELETE), new SimpleStringMap.Node(TRACE.toString(), TRACE), new SimpleStringMap.Node(CONNECT.toString(), CONNECT)); From d6be98bc7b7ad22258c62c33873f2463bb070ec4 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Fri, 19 Jun 2026 03:06:36 +0200 Subject: [PATCH 18/64] Correctly release and fail queued traffic-shaping writes on close (#16959) (#16976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: The AbstractTrafficShapingHandler#calculateSize supports ByteBuf, ByteBufHolder, and FileRegion, so traffic-shaping handlers may queue delayed ByteBufHolder messages such as HTTP content. When a channel becomes inactive and queued writes are discarded, ChannelTrafficShapingHandler, GlobalTrafficShapingHandler, and GlobalChannelTrafficShapingHandler only release messages that are direct ByteBuf instances. This leaks queued ByteBufHolder / other ReferenceCounted messages. The corresponding write promises are also left incomplete even though the messages will never be written. Modifications: • Add a shared queued-write cleanup helper in AbstractTrafficShapingHandler. • Use ReferenceCountUtil.safeRelease(...) instead of instanceof ByteBuf checks. • Fail discarded queued write promises with ClosedChannelException. • Reset per-channel queue size after discarded queued writes are cleaned up. • Add tests covering queued ByteBufHolder writes for: ◦ ChannelTrafficShapingHandler ◦ GlobalTrafficShapingHandler ◦ GlobalChannelTrafficShapingHandler Result: Queued delayed writes are cleaned up consistently when the channel is closed: reference-counted messages are released and callers waiting on write promises are notified with failure. Internal queue-size accounting is left in a clean state on removal. --------- Co-authored-by: Norman Maurer Co-authored-by: skyguard1 --- .../AbstractTrafficShapingHandler.java | 9 ++++ .../traffic/ChannelTrafficShapingHandler.java | 10 ++-- .../GlobalChannelTrafficShapingHandler.java | 10 ++-- .../traffic/GlobalTrafficShapingHandler.java | 10 ++-- .../traffic/TrafficShapingHandlerTest.java | 49 +++++++++++++++++++ 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/handler/src/main/java/io/netty/handler/traffic/AbstractTrafficShapingHandler.java b/handler/src/main/java/io/netty/handler/traffic/AbstractTrafficShapingHandler.java index 0fecbf85361..870762c0d7e 100644 --- a/handler/src/main/java/io/netty/handler/traffic/AbstractTrafficShapingHandler.java +++ b/handler/src/main/java/io/netty/handler/traffic/AbstractTrafficShapingHandler.java @@ -28,6 +28,7 @@ import io.netty.channel.FileRegion; import io.netty.util.Attribute; import io.netty.util.AttributeKey; +import io.netty.util.ReferenceCountUtil; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; @@ -579,6 +580,14 @@ protected void submitWrite(final ChannelHandlerContext ctx, final Object msg, abstract void submitWrite( ChannelHandlerContext ctx, Object msg, long size, long delay, long now, ChannelPromise promise); + /** + * Releases the given {@code msg} and fails the given {@code promise} with the supplied {@code cause}. + */ + static void releaseAndFailQueuedWrite(Object msg, ChannelPromise promise, Throwable cause) { + ReferenceCountUtil.safeRelease(msg); + promise.tryFailure(cause); + } + @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { setUserDefinedWritability(ctx, true); diff --git a/handler/src/main/java/io/netty/handler/traffic/ChannelTrafficShapingHandler.java b/handler/src/main/java/io/netty/handler/traffic/ChannelTrafficShapingHandler.java index 7004cc5b413..f138aabc82d 100644 --- a/handler/src/main/java/io/netty/handler/traffic/ChannelTrafficShapingHandler.java +++ b/handler/src/main/java/io/netty/handler/traffic/ChannelTrafficShapingHandler.java @@ -15,10 +15,10 @@ */ package io.netty.handler.traffic; -import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelPromise; +import java.nio.channels.ClosedChannelException; import java.util.ArrayDeque; import java.util.concurrent.TimeUnit; @@ -148,12 +148,12 @@ public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { queueSize -= size; ctx.write(toSend.toSend, toSend.promise); } - } else { + } else if (!messagesQueue.isEmpty()) { + ClosedChannelException cause = new ClosedChannelException(); for (ToSend toSend : messagesQueue) { - if (toSend.toSend instanceof ByteBuf) { - ((ByteBuf) toSend.toSend).release(); - } + releaseAndFailQueuedWrite(toSend.toSend, toSend.promise, cause); } + queueSize = 0; } messagesQueue.clear(); } diff --git a/handler/src/main/java/io/netty/handler/traffic/GlobalChannelTrafficShapingHandler.java b/handler/src/main/java/io/netty/handler/traffic/GlobalChannelTrafficShapingHandler.java index 9c35938802d..5393e05b7a9 100644 --- a/handler/src/main/java/io/netty/handler/traffic/GlobalChannelTrafficShapingHandler.java +++ b/handler/src/main/java/io/netty/handler/traffic/GlobalChannelTrafficShapingHandler.java @@ -19,7 +19,6 @@ import static io.netty.util.internal.ObjectUtil.checkPositive; import static io.netty.util.internal.ObjectUtil.checkPositiveOrZero; -import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler.Sharable; import io.netty.channel.ChannelConfig; @@ -31,6 +30,7 @@ import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; +import java.nio.channels.ClosedChannelException; import java.util.AbstractCollection; import java.util.ArrayDeque; import java.util.Collection; @@ -495,13 +495,13 @@ public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { queuesSize.addAndGet(-size); ctx.write(toSend.toSend, toSend.promise); } - } else { + } else if (!perChannel.messagesQueue.isEmpty()) { queuesSize.addAndGet(-perChannel.queueSize); + ClosedChannelException cause = new ClosedChannelException(); for (ToSend toSend : perChannel.messagesQueue) { - if (toSend.toSend instanceof ByteBuf) { - ((ByteBuf) toSend.toSend).release(); - } + releaseAndFailQueuedWrite(toSend.toSend, toSend.promise, cause); } + perChannel.queueSize = 0; } perChannel.messagesQueue.clear(); } diff --git a/handler/src/main/java/io/netty/handler/traffic/GlobalTrafficShapingHandler.java b/handler/src/main/java/io/netty/handler/traffic/GlobalTrafficShapingHandler.java index 99da696ce2d..3f48aea4c97 100644 --- a/handler/src/main/java/io/netty/handler/traffic/GlobalTrafficShapingHandler.java +++ b/handler/src/main/java/io/netty/handler/traffic/GlobalTrafficShapingHandler.java @@ -15,7 +15,6 @@ */ package io.netty.handler.traffic; -import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandler.Sharable; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; @@ -24,6 +23,7 @@ import io.netty.util.internal.ObjectUtil; import io.netty.util.internal.PlatformDependent; +import java.nio.channels.ClosedChannelException; import java.util.ArrayDeque; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; @@ -276,13 +276,13 @@ public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { queuesSize.addAndGet(-size); ctx.write(toSend.toSend, toSend.promise); } - } else { + } else if (!perChannel.messagesQueue.isEmpty()) { queuesSize.addAndGet(-perChannel.queueSize); + ClosedChannelException cause = new ClosedChannelException(); for (ToSend toSend : perChannel.messagesQueue) { - if (toSend.toSend instanceof ByteBuf) { - ((ByteBuf) toSend.toSend).release(); - } + releaseAndFailQueuedWrite(toSend.toSend, toSend.promise, cause); } + perChannel.queueSize = 0; } perChannel.messagesQueue.clear(); } diff --git a/handler/src/test/java/io/netty/handler/traffic/TrafficShapingHandlerTest.java b/handler/src/test/java/io/netty/handler/traffic/TrafficShapingHandlerTest.java index cc71e5c2ebf..ac5fa5e0407 100644 --- a/handler/src/test/java/io/netty/handler/traffic/TrafficShapingHandlerTest.java +++ b/handler/src/test/java/io/netty/handler/traffic/TrafficShapingHandlerTest.java @@ -16,17 +16,22 @@ package io.netty.handler.traffic; +import java.nio.channels.ClosedChannelException; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import io.netty.bootstrap.Bootstrap; import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBufHolder; +import io.netty.buffer.DefaultByteBufHolder; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPromise; import io.netty.channel.DefaultEventLoopGroup; +import io.netty.channel.embedded.EmbeddedChannel; import io.netty.channel.local.LocalAddress; import io.netty.channel.local.LocalChannel; import io.netty.channel.local.LocalServerChannel; @@ -35,12 +40,16 @@ import org.junit.jupiter.api.AfterAll; 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.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; public class TrafficShapingHandlerTest { private static final long READ_LIMIT_BYTES_PER_SECOND = 1; + private static final long WRITE_LIMIT_BYTES_PER_SECOND = 1; private static final ScheduledExecutorService SES = Executors.newSingleThreadScheduledExecutor(); private static final DefaultEventLoopGroup GROUP = new DefaultEventLoopGroup(1); @@ -70,6 +79,46 @@ public void testHandlerRemove() throws Exception { } } + @Test + public void testQueuedWritesReleasedAndFailedOnClose() { + testQueuedWritesReleasedAndFailedOnClose0(new ChannelTrafficShapingHandler( + WRITE_LIMIT_BYTES_PER_SECOND, 0, 0)); + + GlobalTrafficShapingHandler trafficHandler1 = + new GlobalTrafficShapingHandler(SES, WRITE_LIMIT_BYTES_PER_SECOND, 0, 0); + try { + testQueuedWritesReleasedAndFailedOnClose0(trafficHandler1); + } finally { + trafficHandler1.release(); + } + + GlobalChannelTrafficShapingHandler trafficHandler2 = + new GlobalChannelTrafficShapingHandler(SES, WRITE_LIMIT_BYTES_PER_SECOND, 0, + WRITE_LIMIT_BYTES_PER_SECOND, 0, 0); + try { + testQueuedWritesReleasedAndFailedOnClose0(trafficHandler2); + } finally { + trafficHandler2.release(); + } + } + + private static void testQueuedWritesReleasedAndFailedOnClose0(AbstractTrafficShapingHandler trafficHandler) { + EmbeddedChannel ch = new EmbeddedChannel(trafficHandler); + ByteBufHolder holder = new DefaultByteBufHolder(Unpooled.buffer(64).writeZero(64)); + ChannelPromise promise = ch.newPromise(); + + ch.writeOneOutbound(holder, promise); + assertFalse(promise.isDone()); + assertNull(ch.readOutbound()); + assertEquals(1, holder.refCnt()); + + ch.close().syncUninterruptibly(); + assertEquals(0, holder.refCnt()); + assertTrue(promise.isDone()); + assertTrue(promise.cause() instanceof ClosedChannelException); + assertFalse(ch.finishAndReleaseAll()); + } + private void testHandlerRemove0(final AbstractTrafficShapingHandler trafficHandler) throws Exception { Channel svrChannel = null; From e9eab3d8efe6f74480994997dc11068c2f3d954d Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:11:39 +0200 Subject: [PATCH 19/64] Auto-port 4.1: FlowControlHandler: respect auto-read when toggled while dequeueing (#16983) Auto-port of #16949 to 4.1 Cherry-picked commit: 8081e23752570e2346e931e416396e9fa0fa99fc --- ## Motivation `FlowControlHandler` does not respect changes to the channel's auto-read setting while flushing the queue. As such, a downstream handler that disables auto-read from within `channelRead()` cannot stop `FlowControlHandler` from flushing the rest of the queue. ## Modification - Merge `dequeueOne()` and `dequeueAll()` into a single `dequeue()` loop that re-checks `config.isAutoRead()` and `unsatisfiedReads` before every message. - Add tests for auto-read toggled on and off from `channelRead` and re-entrant reads satisfied from the queue, plus previously missing coverage for handler removal, `channelInactive` release, and `releaseMessages = false`. ## Result A downstream handler that disables auto-read from `channelRead()` now stops delivery of the rest of the queue. Fixes #16945 Co-authored-by: Szymon Habrainski <56340221+schiemon@users.noreply.github.com> Co-authored-by: Norman Maurer --- .../handler/flow/FlowControlHandler.java | 106 ++++---- .../handler/flow/FlowControlHandlerTest.java | 242 ++++++++++++++++++ 2 files changed, 297 insertions(+), 51 deletions(-) diff --git a/handler/src/main/java/io/netty/handler/flow/FlowControlHandler.java b/handler/src/main/java/io/netty/handler/flow/FlowControlHandler.java index 0baabda8044..97df1513f6b 100644 --- a/handler/src/main/java/io/netty/handler/flow/FlowControlHandler.java +++ b/handler/src/main/java/io/netty/handler/flow/FlowControlHandler.java @@ -96,6 +96,11 @@ public class FlowControlHandler extends ChannelDuplexHandler { */ private int unsatisfiedReads; + /** + * {@code true} while a {@link #dequeue(ChannelHandlerContext)} loop is on the stack. + */ + private boolean dequeuing; + public FlowControlHandler() { this(true); } @@ -143,7 +148,8 @@ public void handlerAdded(ChannelHandlerContext ctx) throws Exception { public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { super.handlerRemoved(ctx); if (!isQueueEmpty()) { - dequeueAll(ctx); + unsatisfiedReads = queue.size(); + dequeue(ctx); ctx.fireChannelReadComplete(); } destroy(); @@ -157,22 +163,25 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { @Override public void read(ChannelHandlerContext ctx) throws Exception { - if (config.isAutoRead()) { - dequeueAll(ctx); - ctx.read(); - } else { + if (!config.isAutoRead()) { unsatisfiedReads++; + } - if (dequeueOne(ctx)) { - if (unsatisfiedReads == 0) { - ctx.fireChannelReadComplete(); - } - } else { - // Could not satisfy the read() from the queue. - // We need to request data from upstream so we can satisfy the read() in channelRead() or - // channelReadComplete() if it is going to be an empty read. - ctx.read(); - } + boolean didSatisfyARead = dequeue(ctx); + boolean isAutoRead = config.isAutoRead(); + if (!didSatisfyARead || isAutoRead) { + assert unsatisfiedReads > 0 || isAutoRead; + // We either could not satisfy the read or auto-read is on. + // In both cases we need to delegate the read upstream. + ctx.read(); + } else if (unsatisfiedReads == 0 && !dequeuing) { + // Auto-read is off, and we have satisfied all reads. + // As such, we can complete the current read cycle. && !dequeueing makes sure we are completing the + // read cycle only once in the top-most read() call. + ctx.fireChannelReadComplete(); + } else { + // Auto-read is off, and either reads are still unsatisfied or we are nested in a dequeue. + // Wait for the outermost call, an upstream channelRead() or a channelReadComplete(). } } @@ -184,12 +193,8 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception queue.offer(msg); - if (config.isAutoRead()) { - dequeueAll(ctx); - } else if (unsatisfiedReads > 0) { - dequeueOne(ctx); - - if (unsatisfiedReads == 0) { + if (dequeue(ctx)) { + if (!config.isAutoRead() && unsatisfiedReads == 0 && !dequeuing) { ctx.fireChannelReadComplete(); } } @@ -205,45 +210,44 @@ public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { } } - private boolean dequeueOne(ChannelHandlerContext ctx) { - return dequeue(ctx, 1) > 0; - } - - private int dequeueAll(ChannelHandlerContext ctx) { - return dequeue(ctx, -1); - } - /** - * Dequeues up to {@code maxConsume} messages, fires them downstream and - * updates {@code unsatisfiedReads} accordingly. If {@code maxConsume} is negative, - * there is no upper limit on the number of messages to dequeue and fire downstream. + * Dequeues messages while auto-read is enabled or downstream reads are unsatisfied, and updates + * {@code unsatisfiedReads} accordingly. * * @see #read(ChannelHandlerContext) * @see #channelRead(ChannelHandlerContext, Object) */ - private int dequeue(ChannelHandlerContext ctx, int maxConsume) { - int consumed = 0; - - // fireChannelRead(...) may call ctx.read() and so this method may be re-entered. Because of that - // we need to check if queue was set to null in the meantime and, if so, break out of the loop. - while (queue != null && (consumed < maxConsume || maxConsume < 0)) { - Object msg = queue.poll(); - if (msg == null) { - break; - } + private boolean dequeue(ChannelHandlerContext ctx) { + boolean didSatisfyARead = false; + + boolean wasDequeuing = dequeuing; + dequeuing = true; + try { + // fireChannelRead(...) may call ctx.read() and so this method may be re-entered. Because of that + // we need to check if queue was set to null in the meantime and, if so, break out of the loop. + while (queue != null && (config.isAutoRead() || unsatisfiedReads > 0)) { + Object msg = queue.poll(); + if (msg == null) { + break; + } - ++consumed; - ctx.fireChannelRead(msg); - } + if (unsatisfiedReads > 0) { + unsatisfiedReads--; + } + ctx.fireChannelRead(msg); - if (queue != null && queue.isEmpty()) { - queue.recycle(); - queue = null; - } + didSatisfyARead = true; + } - unsatisfiedReads = Math.max(unsatisfiedReads - consumed, 0); + if (queue != null && queue.isEmpty()) { + queue.recycle(); + queue = null; + } - return consumed; + return didSatisfyARead; + } finally { + dequeuing = wasDequeuing; + } } /** diff --git a/handler/src/test/java/io/netty/handler/flow/FlowControlHandlerTest.java b/handler/src/test/java/io/netty/handler/flow/FlowControlHandlerTest.java index 16d6d003d0a..a4bb179fd2d 100644 --- a/handler/src/test/java/io/netty/handler/flow/FlowControlHandlerTest.java +++ b/handler/src/test/java/io/netty/handler/flow/FlowControlHandlerTest.java @@ -42,6 +42,8 @@ import org.junit.jupiter.api.Test; import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Queue; import java.util.concurrent.Callable; @@ -665,10 +667,12 @@ public Boolean call() { @Test public void testCompletingReadWithNonEmptyQueue() throws Exception { + final UpstreamReadCounter upstream = new UpstreamReadCounter(); final AtomicInteger reads = new AtomicInteger(); final AtomicInteger readCompletes = new AtomicInteger(); final EmbeddedChannel channel = new EmbeddedChannel( false, false, + upstream, new FlowControlHandler(), new ChannelInboundHandlerAdapter() { @Override @@ -697,6 +701,9 @@ public void channelReadComplete(ChannelHandlerContext ctx) { assertEquals(2, reads.get()); assertEquals(2, readCompletes.get()); + // Every read() was satisfied straight from the queue, so none was forwarded upstream. + assertEquals(0, upstream.reads.get()); + assertFalse(channel.finishAndReleaseAll()); } @@ -753,10 +760,12 @@ public void channelReadComplete(ChannelHandlerContext ctx) { @Test public void testEmptyRead() throws Exception { + final UpstreamReadCounter upstream = new UpstreamReadCounter(); final AtomicInteger reads = new AtomicInteger(); final AtomicInteger readCompletes = new AtomicInteger(); final EmbeddedChannel channel = new EmbeddedChannel( false, false, + upstream, new FlowControlHandler(), new ChannelInboundHandlerAdapter() { @Override @@ -781,6 +790,9 @@ public void channelReadComplete(ChannelHandlerContext ctx) { assertEquals(0, reads.get()); assertEquals(1, readCompletes.get()); + // The empty read() could not be satisfied from the queue, so it was forwarded upstream exactly once. + assertEquals(1, upstream.reads.get()); + assertFalse(channel.finishAndReleaseAll()); } @@ -962,6 +974,222 @@ public void channelReadComplete(ChannelHandlerContext ctx) { assertFalse(channel.finishAndReleaseAll()); } + @Test + public void testAutoReadDisabledDuringDequeueStopsDelivery() throws Exception { + final UpstreamReadCounter upstream = new UpstreamReadCounter(); + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger readCompletes = new AtomicInteger(); + final EmbeddedChannel channel = new EmbeddedChannel(false, false, + upstream, + new FlowControlHandler(), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reads.incrementAndGet(); + ctx.channel().config().setAutoRead(false); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + channel.config().setAutoRead(false); + channel.register(); + + // Begin with auto-read on while the queue is empty: that forwards a single read upstream and delivers + // nothing. + channel.config().setAutoRead(true); + assertEquals(0, reads.get()); + assertEquals(1, upstream.reads.get()); + + // Messages now arrive with auto-read on. The handler disables auto-read while processing the first, so + // messages 2..5 stay queued: delivery stops after one and the cycle completes once (from channelRead). + channel.writeInbound("1", "2", "3", "4", "5"); + assertEquals(1, reads.get()); + assertEquals(1, readCompletes.get()); + assertEquals(1, upstream.reads.get()); + + // A trailing upstream channelReadComplete (auto-read off, no read outstanding) is dropped. + channel.flushInbound(); + assertEquals(1, readCompletes.get()); + + // Resume: re-enabling auto-read starts draining the four queued messages, but the handler disables it + // again while processing the first. The dequeue must observe the renewed setAutoRead(false), release + // exactly one more, and complete once (from read()). The message was served from the queue, so nothing + // is read further upstream. + channel.config().setAutoRead(true); + assertEquals(2, reads.get()); + assertEquals(2, readCompletes.get()); + assertEquals(1, upstream.reads.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + + @Test + public void testReentrantReadIsSatisfiedFromQueue() throws Exception { + final UpstreamReadCounter upstream = new UpstreamReadCounter(); + final AtomicInteger reads = new AtomicInteger(); + final AtomicInteger readCompletes = new AtomicInteger(); + EmbeddedChannel channel = new EmbeddedChannel(false, false, + upstream, + new FlowControlHandler(), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (reads.incrementAndGet() == 1) { + ctx.read(); + } + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + channel.config().setAutoRead(false); + channel.register(); + channel.writeInbound("1", "2"); + + channel.read(); + + assertEquals(2, reads.get()); + assertEquals(0, upstream.reads.get()); + assertEquals(1, readCompletes.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + + @Test + public void testAutoReadEnabledDuringDequeueDrainsRemaining() throws Exception { + final UpstreamReadCounter upstream = new UpstreamReadCounter(); + final AtomicInteger reads = new AtomicInteger(); + final EmbeddedChannel channel = new EmbeddedChannel(false, false, + upstream, + new FlowControlHandler(), + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + reads.incrementAndGet(); + // Resume the connection while "processing" each released message. + ctx.channel().config().setAutoRead(true); + } + }); + channel.config().setAutoRead(false); + channel.register(); + + // Auto-read off: all five messages are held in the queue, nothing read upstream. + channel.writeInbound("1", "2", "3", "4", "5"); + assertEquals(0, reads.get()); + assertEquals(0, upstream.reads.get()); + + // A single read() delivers the first message; the handler re-enables auto-read from inside channelRead, + // which drains the whole remaining queue. With the queue empty and auto-read on, FlowControlHandler + // then reads further upstream twice: once for the transparent resume, once for the read() that + // consumed the queued message. + channel.read(); + assertEquals(5, reads.get()); + assertEquals(2, upstream.reads.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + + @Test + public void testHandlerRemovedFlushesQueuedMessages() throws Exception { + final UpstreamReadCounter upstream = new UpstreamReadCounter(); + final List received = new ArrayList(); + final AtomicInteger readCompletes = new AtomicInteger(); + final FlowControlHandler flow = new FlowControlHandler(); + final EmbeddedChannel channel = new EmbeddedChannel(false, false, + upstream, + flow, + new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + received.add(msg); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + readCompletes.incrementAndGet(); + } + }); + channel.config().setAutoRead(false); + channel.register(); + + // With auto-read off and no read(), all five messages stay queued in the handler. + channel.writeInbound("1", "2", "3", "4", "5"); + assertEquals(0, received.size()); + assertEquals(0, readCompletes.get()); + + // Removing the handler flushes the whole queue downstream, in order, then completes the batch once. + channel.pipeline().remove(flow); + assertEquals(Arrays.asList("1", "2", "3", "4", "5"), received); + assertEquals(1, readCompletes.get()); + assertTrue(flow.isQueueEmpty()); + + // The flush happens locally on removal; nothing is read upstream. + assertEquals(0, upstream.reads.get()); + + assertFalse(channel.finishAndReleaseAll()); + } + + @Test + public void testChannelInactiveReleasesQueuedMessages() throws Exception { + final UpstreamReadCounter upstream = new UpstreamReadCounter(); + final FlowControlHandler flow = new FlowControlHandler(); + final EmbeddedChannel channel = new EmbeddedChannel(false, false, + upstream, flow, new ChannelInboundHandlerAdapter()); + channel.config().setAutoRead(false); + channel.register(); + + ByteBuf msg1 = Unpooled.buffer().writeByte(1); + ByteBuf msg2 = Unpooled.buffer().writeByte(2); + + // Auto-read off: the buffers are held in the handler's queue, not delivered downstream. + channel.writeInbound(msg1, msg2); + assertFalse(flow.isQueueEmpty()); + assertEquals(1, msg1.refCnt()); + assertEquals(1, msg2.refCnt()); + + // Closing fires channelInactive, which destroys the queue and releases the held buffers. + channel.close().syncUninterruptibly(); + assertEquals(0, msg1.refCnt()); + assertEquals(0, msg2.refCnt()); + assertTrue(flow.isQueueEmpty()); + + // Nothing was ever read upstream. + assertEquals(0, upstream.reads.get()); + } + + @Test + public void testReleaseMessagesFalseDoesNotReleaseQueuedMessages() throws Exception { + final UpstreamReadCounter upstream = new UpstreamReadCounter(); + final FlowControlHandler flow = new FlowControlHandler(false); + final EmbeddedChannel channel = new EmbeddedChannel(false, false, + upstream, flow, new ChannelInboundHandlerAdapter()); + channel.config().setAutoRead(false); + channel.register(); + + ByteBuf msg1 = Unpooled.buffer().writeByte(1); + ByteBuf msg2 = Unpooled.buffer().writeByte(2); + + channel.writeInbound(msg1, msg2); + assertFalse(flow.isQueueEmpty()); + + // releaseMessages == false: destroy() discards the queue but must not release the buffers. + channel.close().syncUninterruptibly(); + assertEquals(1, msg1.refCnt()); + assertEquals(1, msg2.refCnt()); + assertTrue(flow.isQueueEmpty()); + + // Nothing was ever read upstream. + assertEquals(0, upstream.reads.get()); + + msg1.release(); + msg2.release(); + } + /** * This is a fictional message decoder. It decodes each {@code byte} * into three strings. @@ -977,4 +1205,18 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { in.readerIndex(in.readableBytes()); } } + + /** + * Counts the {@code read()} events {@link FlowControlHandler} forwards upstream. Placed at the head side + * of the handler. + */ + private static final class UpstreamReadCounter extends ChannelDuplexHandler { + final AtomicInteger reads = new AtomicInteger(); + + @Override + public void read(ChannelHandlerContext ctx) throws Exception { + reads.incrementAndGet(); + super.read(ctx); + } + } } From 4e7dc11d90df1a2cd179a5f416d9104dce100d04 Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Wed, 24 Jun 2026 19:13:21 -0700 Subject: [PATCH 20/64] IdleStateHandler: reset firstWriter/ReaderIdleEvent in resetWriteTimeout/resetReadTimeout (#16982) (#16989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation `IdleStateHandler `exposes two programmatic reset methods — `resetWriteTimeout()` and `resetReadTimeout()` — intended to let callers defer idle detection without performing an actual write or read. Their documented contract is to restart the idle timer from the point of the call. Both methods update the relevant timestamp but do not reset the corresponding "first event" flags (firstWriterIdleEvent / firstReaderIdleEvent). As a result, if a non-first idle event has already fired before the programmatic reset, the next idle event after the reset is incorrectly reported as `WRITER_IDLE_STATE_EVENT`/ `READER_IDLE_STATE_EVENT` (first=false) instead of `FIRST_WRITER_IDLE_STATE_EVENT`/ `FIRST_READER_IDLE_STATE_EVENT`. This is inconsistent with the writeListener path, which resets both the timestamp and the first-event flags together. ## Modifications - resetWriteTimeout(): add `firstWriterIdleEvent = firstAllIdleEvent = true` - resetReadTimeout(): add `firstReaderIdleEvent = firstAllIdleEvent = true` - Add IdleStateHandlerResetFlagTest with two tests that exercise the post-non-first-event reset sequence for both methods ## Result After calling `resetWriteTimeout()` or `resetReadTimeout()`, the next idle event correctly fires as `FIRST_WRITER_IDLE_STATE_EVENT `or `FIRST_READER_IDLE_STATE_EVENT`,consistent with what a real write or read activity would produce. --- Note: the same issue exists in the 4.1 branch. Happy to provide a backport if the team considers it in scope. Signed-off-by: husseinvr97 (cherry picked from commit 512ba9d4838ad1b39c8517ff6f8f5f96e2bb9130) Co-authored-by: husseinvr97 --- .../handler/timeout/IdleStateHandler.java | 2 + .../IdleStateHandlerResetFlagTest.java | 173 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 handler/src/test/java/io/netty/handler/timeout/IdleStateHandlerResetFlagTest.java diff --git a/handler/src/main/java/io/netty/handler/timeout/IdleStateHandler.java b/handler/src/main/java/io/netty/handler/timeout/IdleStateHandler.java index 15710b6002a..1d67c3e8ab0 100644 --- a/handler/src/main/java/io/netty/handler/timeout/IdleStateHandler.java +++ b/handler/src/main/java/io/netty/handler/timeout/IdleStateHandler.java @@ -315,6 +315,7 @@ public void resetReadTimeout() { if (readerIdleTimeNanos > 0 || allIdleTimeNanos > 0) { lastReadTime = ticksInNanos(); reading = false; + firstReaderIdleEvent = firstAllIdleEvent = true; } } @@ -324,6 +325,7 @@ public void resetReadTimeout() { public void resetWriteTimeout() { if (writerIdleTimeNanos > 0 || allIdleTimeNanos > 0) { lastWriteTime = ticksInNanos(); + firstWriterIdleEvent = firstAllIdleEvent = true; } } diff --git a/handler/src/test/java/io/netty/handler/timeout/IdleStateHandlerResetFlagTest.java b/handler/src/test/java/io/netty/handler/timeout/IdleStateHandlerResetFlagTest.java new file mode 100644 index 00000000000..bf44bbd561e --- /dev/null +++ b/handler/src/test/java/io/netty/handler/timeout/IdleStateHandlerResetFlagTest.java @@ -0,0 +1,173 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project 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: + * + * https://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 io.netty.handler.timeout; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.EventLoop; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.util.internal.ReflectionUtil; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Tests that {@link IdleStateHandler#resetWriteTimeout()} and + * {@link IdleStateHandler#resetReadTimeout()} correctly restore the + * "first idle" semantics after a non-first event has already fired. + * + * The reset methods update the timestamp but don't touch the firstWriter/ReaderIdleEvent + * flags, so the handler keeps reporting non-first events even after a reset. + * The writeListener path doesn't have this problem because it resets both. + */ +public class IdleStateHandlerResetFlagTest { + static class TimeableEmbeddedChannel extends EmbeddedChannel { + long getCurrentEventLoopTimeNanos() { + EventLoop eventLoop = eventLoop(); + try { + Method getCurrentTimeNanos = eventLoop.getClass().getDeclaredMethod("getCurrentTimeNanos"); + Throwable throwable = ReflectionUtil.trySetAccessible(getCurrentTimeNanos, false); + if (throwable != null) { + throw new RuntimeException(throwable); + } + return (Long) getCurrentTimeNanos.invoke(eventLoop); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + /** + * If a WRITER_IDLE event has already fired as non-first and then + * resetWriteTimeout() is called, the next event should be first again — + * same as if an actual write had happened. + */ + @Test + public void testResetWriteTimeoutResetsFirstEventFlag() throws Exception { + final TimeableEmbeddedChannel channel = new TimeableEmbeddedChannel(); + final IdleStateHandler idleStateHandler = new IdleStateHandler( + false, 0L, 1L, 0L, TimeUnit.SECONDS) { + @Override + long ticksInNanos() { + return channel.getCurrentEventLoopTimeNanos(); + } + }; + + final List events = new ArrayList(); + channel.pipeline().addLast(idleStateHandler, + new ChannelInboundHandlerAdapter() { + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { + if (evt instanceof IdleStateEvent) { + events.add((IdleStateEvent) evt); + } + } + }); + channel.freezeTime(); + + try { + // first idle — expected + channel.advanceTimeBy(1100L, TimeUnit.MILLISECONDS); + channel.runPendingTasks(); + assertEquals(1, events.size()); + assertSame(IdleStateEvent.FIRST_WRITER_IDLE_STATE_EVENT, events.get(0), + "First idle after connect should be FIRST_WRITER_IDLE_STATE_EVENT"); + + // second idle, no activity in between — non-first + channel.advanceTimeBy(1100L, TimeUnit.MILLISECONDS); + channel.runPendingTasks(); + assertEquals(2, events.size()); + assertSame(IdleStateEvent.WRITER_IDLE_STATE_EVENT, events.get(1), + "Second idle without reset should be WRITER_IDLE_STATE_EVENT (first=false)"); + + // reset: tells the handler to treat this moment as a fresh start + idleStateHandler.resetWriteTimeout(); + + // should fire as first again, but currently doesn't because + // resetWriteTimeout() only updates lastWriteTime, not firstWriterIdleEvent + channel.advanceTimeBy(1100L, TimeUnit.MILLISECONDS); + channel.runPendingTasks(); + assertEquals(3, events.size()); + assertSame(IdleStateEvent.FIRST_WRITER_IDLE_STATE_EVENT, events.get(2), + "After resetWriteTimeout(), next idle MUST be FIRST_WRITER_IDLE_STATE_EVENT. " + + "Bug: firstWriterIdleEvent is not reset by resetWriteTimeout()."); + } finally { + channel.finishAndReleaseAll(); + } + } + + /** + * Same issue on the read side: resetReadTimeout() resets the clock but + * leaves firstReaderIdleEvent as-is, so the next event stays non-first. + */ + @Test + public void testResetReadTimeoutResetsFirstEventFlag() throws Exception { + final TimeableEmbeddedChannel channel = new TimeableEmbeddedChannel(); + final IdleStateHandler idleStateHandler = new IdleStateHandler( + false, 1L, 0L, 0L, TimeUnit.SECONDS) { + @Override + long ticksInNanos() { + return channel.getCurrentEventLoopTimeNanos(); + } + }; + + final List events = new ArrayList(); + channel.pipeline().addLast(idleStateHandler, + new ChannelInboundHandlerAdapter() { + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { + if (evt instanceof IdleStateEvent) { + events.add((IdleStateEvent) evt); + } + } + }); + channel.freezeTime(); + + try { + // first idle + channel.advanceTimeBy(1100L, TimeUnit.MILLISECONDS); + channel.runPendingTasks(); + assertEquals(1, events.size()); + assertSame(IdleStateEvent.FIRST_READER_IDLE_STATE_EVENT, events.get(0), + "First idle after connect should be FIRST_READER_IDLE_STATE_EVENT"); + + // non-first idle + channel.advanceTimeBy(1100L, TimeUnit.MILLISECONDS); + channel.runPendingTasks(); + assertEquals(2, events.size()); + assertSame(IdleStateEvent.READER_IDLE_STATE_EVENT, events.get(1), + "Second idle without reset should be READER_IDLE_STATE_EVENT (first=false)"); + + idleStateHandler.resetReadTimeout(); + + // should be first again after the reset + channel.advanceTimeBy(1100L, TimeUnit.MILLISECONDS); + channel.runPendingTasks(); + assertEquals(3, events.size()); + assertSame(IdleStateEvent.FIRST_READER_IDLE_STATE_EVENT, events.get(2), + "After resetReadTimeout(), next idle MUST be FIRST_READER_IDLE_STATE_EVENT. " + + "Bug: firstReaderIdleEvent is not reset by resetReadTimeout()."); + } finally { + channel.finishAndReleaseAll(); + } + } +} From a362ee1c7e7439aa5b476981d820d90319550729 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:00:58 +0200 Subject: [PATCH 21/64] Auto-port 4.1: Fix typo in AbstractSniHandler Javadoc (#16995) Auto-port of #16988 to 4.1 Cherry-picked commit: 02bbf71545a4ea162fc16f3977ea1cb9b1192515 --- Motivation: There is a typo in the Javadoc tag for the maxClientHelloLength parameter. Modification: Replace @paramm with @param. Result: Javadoc uses the correct parameter tag. Co-authored-by: CoderBruis <37364336+coderbruis@users.noreply.github.com> --- .../src/main/java/io/netty/handler/ssl/AbstractSniHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handler/src/main/java/io/netty/handler/ssl/AbstractSniHandler.java b/handler/src/main/java/io/netty/handler/ssl/AbstractSniHandler.java index 3b8f0e0713d..ac91aba7122 100644 --- a/handler/src/main/java/io/netty/handler/ssl/AbstractSniHandler.java +++ b/handler/src/main/java/io/netty/handler/ssl/AbstractSniHandler.java @@ -134,7 +134,7 @@ protected AbstractSniHandler(long handshakeTimeoutMillis) { } /** - * @paramm maxClientHelloLength the maximum length of the client hello message. + * @param maxClientHelloLength the maximum length of the client hello message. * @param handshakeTimeoutMillis the handshake timeout in milliseconds */ protected AbstractSniHandler(int maxClientHelloLength, long handshakeTimeoutMillis) { From 26bb273a2c6ea6c0cf79bf0cdae022bf7d886d4d Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:31:17 +0200 Subject: [PATCH 22/64] Auto-port 4.1: Reconcile `AbstractCoalescingBufferQueue` readableBytes when it drains, and fail stuck HTTP/2 streams instead of spinning empty DATA frames (#16997) Auto-port of #16947 to 4.1 Cherry-picked commit: a5d4f289e914c3d6b5b78ac31583e6f95312d8f4 --- **Motivation:** On a proxy doing HTTP/2 egress we OOMed when the remote flow controller spun writing empty DATA frames (~134M) into an already-unwritable channel. This is the OOM from #11959, still reachable on `4.2.12.Final` despite #14220 - that fix only closed the exception-in-`remove` route and left two gaps: * `CoalescingBufferQueue.remove(...)` can leave `readableBytes > 0` with an empty deque -> it decrements by a buffer's *live* readable bytes, and its empty-queue early return asserts rather than reconciles * Also `writeAllocatedBytes` re-writes a frame that makes no progress without ever checking channel writability. Full investigation and heap dump in #16946. **Modification:** * `AbstractCoalescingBufferQueue` - add a `reconcileReadableBytes()` (called at the `remove(...)` early return and after the drain loop) so an empty queue always reports 0 readable bytes. * `DefaultHttp2RemoteFlowController.writeAllocatedBytes` - if the head frame is given a positive budget but is neither removed nor shrinks across two consecutive iterations, fail the stream via cancel path instead. * Tests cover the queue desync, the stuck-frame spin (failing the stream), and a single no-progress pass (tolerated). **Result:** The queue can't report bytes it can't produce, and the flow controller can't emit empty DATA frames unboundedly - a wedged stream fails cleanly instead of OOMing the connection. No public API change. Fixes #16946 Co-authored-by: Gavin Bunney <409207+gavinbunney@users.noreply.github.com> Co-authored-by: Norman Maurer --- .../http2/DefaultHttp2ConnectionEncoder.java | 16 ++++ .../DefaultHttp2ConnectionEncoderTest.java | 25 ++++++ .../AbstractCoalescingBufferQueue.java | 22 ++++- .../channel/CoalescingBufferQueueTest.java | 87 +++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoder.java index d82224d9fd8..b5382ea3bc8 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoder.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoder.java @@ -22,6 +22,7 @@ import io.netty.channel.CoalescingBufferQueue; import io.netty.handler.codec.http.HttpStatusClass; import io.netty.handler.codec.http2.Http2CodecUtil.SimpleChannelPromiseAggregator; +import io.netty.util.ReferenceCountUtil; import java.util.ArrayDeque; import java.util.Queue; @@ -30,6 +31,7 @@ import static io.netty.handler.codec.http2.Http2Error.INTERNAL_ERROR; import static io.netty.handler.codec.http2.Http2Error.PROTOCOL_ERROR; import static io.netty.handler.codec.http2.Http2Exception.connectionError; +import static io.netty.handler.codec.http2.Http2Exception.streamError; import static io.netty.util.internal.ObjectUtil.checkNotNull; import static io.netty.util.internal.ObjectUtil.checkPositiveOrZero; import static java.lang.Integer.MAX_VALUE; @@ -501,6 +503,20 @@ public void write(ChannelHandlerContext ctx, int allowedBytes) { ByteBuf toWrite = queue.remove(writableData, writePromise); dataSize = queue.readableBytes(); + // The queue reported writableData readable bytes but produced fewer: a queued buffer was released or + // consumed while still referenced by the queue, so the stream's data is corrupted. Fail the stream. + int producedBytes = toWrite.readableBytes(); + if (producedBytes < writableData) { + ReferenceCountUtil.safeRelease(toWrite); + // Set dataSize and padding to 0 to signal that the whole frame was consumed, so it is removed and + // its bytes are returned to flow control (matching the error path above). + padding = dataSize = 0; + writePromise.tryFailure(streamError(stream.id(), INTERNAL_ERROR, + "Stream %d flow-controlled queue produced %d bytes but reported %d", + stream.id(), producedBytes, writableData)); + return; + } + // Determine how much padding to write. int writablePadding = min(allowedBytes - writableData, padding); padding -= writablePadding; diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoderTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoderTest.java index 60ba05c0693..6ac4891dc1a 100644 --- a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoderTest.java +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoderTest.java @@ -388,6 +388,31 @@ private void assertSplitPaddingOnEmptyBuffer(ByteBuf data) throws Exception { assertTrue(p.isSuccess()); } + @Test + public void writeDataFailsStreamWhenFlowControlledQueueUnderDelivers() throws Exception { + createStream(STREAM_ID, false); + ByteBuf data = wrappedBuffer(new byte[10]); + ChannelPromise promise = newPromise(); + // Include padding so we also cover that dataSize and padding are reset on failure. + encoder.writeData(ctx, STREAM_ID, data, 10, false, promise); + FlowControlled fc = payloadCaptor.getValue(); + assertEquals(20, fc.size()); + + // Simulate a queued buffer being consumed out from under the queue + data.skipBytes(data.readableBytes()); + + fc.write(ctx, 20); + + // Expect the stream to fail rather than emitting any frames + assertFalse(promise.isSuccess()); + assertInstanceOf(Http2Exception.class, promise.cause()); + assertEquals(Http2Error.INTERNAL_ERROR, ((Http2Exception) promise.cause()).error()); + assertTrue(writtenData.isEmpty()); + // The frame reports as fully consumed so it is removed and its bytes return to flow control. + assertEquals(0, fc.size()); + assertEquals(0, data.refCnt()); + } + @Test public void headersWriteForUnknownStreamShouldCreateStream() throws Exception { writeAllFlowControlledFrames(); diff --git a/transport/src/main/java/io/netty/channel/AbstractCoalescingBufferQueue.java b/transport/src/main/java/io/netty/channel/AbstractCoalescingBufferQueue.java index 474f19cf1ac..c2e8e55c667 100644 --- a/transport/src/main/java/io/netty/channel/AbstractCoalescingBufferQueue.java +++ b/transport/src/main/java/io/netty/channel/AbstractCoalescingBufferQueue.java @@ -126,6 +126,7 @@ public final ByteBuf removeFirst(ChannelPromise aggregatePromise) { aggregatePromise.addListener((ChannelFutureListener) entry); bufAndListenerPairs.poll(); } + reconcileReadableBytes(); return result; } @@ -146,7 +147,7 @@ public final ByteBuf remove(ByteBufAllocator alloc, int bytes, ChannelPromise ag // Use isEmpty rather than readableBytes==0 as we may have a promise associated with an empty buffer. if (bufAndListenerPairs.isEmpty()) { - assert readableBytes == 0; + reconcileReadableBytes(); return removeEmptyValue(); } bytes = Math.min(bytes, readableBytes); @@ -215,6 +216,7 @@ public final ByteBuf remove(ByteBufAllocator alloc, int bytes, ChannelPromise ag throwException(cause); } decrementReadableBytes(originalBytes - bytes); + reconcileReadableBytes(); return toReturn; } @@ -289,6 +291,7 @@ public final void writeAndRemoveAll(ChannelHandlerContext ctx) { } } } + reconcileReadableBytes(); if (pending != null) { throw new IllegalStateException(pending); } @@ -402,6 +405,7 @@ private void releaseAndCompleteAll(ChannelFuture future) { } } } + reconcileReadableBytes(); if (pending != null) { throw new IllegalStateException(pending); } @@ -426,6 +430,22 @@ private void decrementReadableBytes(int decrement) { } } + /** + * Resets readableBytes to 0 when the queue is empty. They can only diverge if a queued buffer was released + * or consumed while still referenced by the queue (similar to a reference-counting bug) after it was added, + * which would otherwise make remove(...) return empty buffers forever. Logged at error level because it + * always indicates a bug that needs to be found. + * See https://github.com/netty/netty/issues/16946 + */ + private void reconcileReadableBytes() { + if (readableBytes != 0 && bufAndListenerPairs.isEmpty()) { + logger.error("readableBytes is {} but the queue is empty: a queued buffer was released or consumed " + + "while still referenced by the queue. This indicates a bug in the code that produced the " + + "buffer. Resetting readableBytes to 0.", readableBytes); + decrementReadableBytes(readableBytes); + } + } + private static ChannelFutureListener toChannelFutureListener(ChannelPromise promise) { return promise.isVoid() ? null : new DelegatingChannelPromiseNotifier(promise); } diff --git a/transport/src/test/java/io/netty/channel/CoalescingBufferQueueTest.java b/transport/src/test/java/io/netty/channel/CoalescingBufferQueueTest.java index 278d4c551b1..5001fa6b12a 100644 --- a/transport/src/test/java/io/netty/channel/CoalescingBufferQueueTest.java +++ b/transport/src/test/java/io/netty/channel/CoalescingBufferQueueTest.java @@ -236,6 +236,93 @@ public void testEmptyBuffersAreCoalesced() { assertEquals(0, empty.refCnt()); } + // If a queued buffer's readable bytes shrink after enqueue (released or consumed out from under the queue), + // the queue must still report 0 readable bytes once drained. + // See https://github.com/netty/netty/issues/16946 + @Test + public void testReadableBytesResetWhenQueuedBufferConsumedExternally() { + // Not used in this test. + cat.release(); + mouse.release(); + + ByteBuf buffer = Unpooled.buffer().writeZero(292); + writeQueue.add(buffer); + assertEquals(292, writeQueue.readableBytes()); + + // Consume the buffer out from under the queue (e.g. a pooled buffer released and reused). + buffer.skipBytes(buffer.readableBytes()); + + ByteBuf removed = writeQueue.remove(Integer.MAX_VALUE, newPromise()); + assertFalse(removed.isReadable()); + removed.release(); + assertQueueSize(0, true); + + // A subsequent remove must not resurrect phantom readable bytes. + ByteBuf removedAgain = writeQueue.remove(Integer.MAX_VALUE, newPromise()); + assertFalse(removedAgain.isReadable()); + removedAgain.release(); + assertQueueSize(0, true); + } + + @Test + public void testReadableBytesResetWhenReleaseAndFailAllSeesConsumedBuffer() { + cat.release(); + mouse.release(); + + ByteBuf buffer = Unpooled.buffer().writeZero(292); + writeQueue.add(buffer); + assertEquals(292, writeQueue.readableBytes()); + + buffer.skipBytes(buffer.readableBytes()); + writeQueue.releaseAndFailAll(new RuntimeException()); + + assertQueueSize(0, true); + assertEquals(0, buffer.refCnt()); + } + + @Test + public void testReadableBytesResetWhenWriteAndRemoveAllSeesConsumedBuffer() { + cat.release(); + mouse.release(); + + EmbeddedChannel ch = new EmbeddedChannel(new ChannelOutboundHandlerAdapter() { + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + ReferenceCountUtil.release(msg); + promise.setSuccess(); + } + }, new ChannelHandlerAdapter() { }); + CoalescingBufferQueue queue = new CoalescingBufferQueue(ch); + + ByteBuf buffer = Unpooled.buffer().writeZero(292); + queue.add(buffer); + assertEquals(292, queue.readableBytes()); + + buffer.skipBytes(buffer.readableBytes()); + queue.writeAndRemoveAll(ch.pipeline().lastContext()); + + assertTrue(queue.isEmpty()); + assertEquals(0, queue.readableBytes()); + assertFalse(ch.finish()); + } + + @Test + public void testReadableBytesResetWhenRemoveFirstSeesConsumedBuffer() { + cat.release(); + mouse.release(); + + ByteBuf buffer = Unpooled.buffer().writeZero(292); + writeQueue.add(buffer); + assertEquals(292, writeQueue.readableBytes()); + + buffer.skipBytes(buffer.readableBytes()); + ByteBuf removed = writeQueue.removeFirst(newPromise()); + assertSame(buffer, removed); + removed.release(); + + assertQueueSize(0, true); + } + @Test public void testMerge() { writeQueue.add(cat, catPromise); From df54c37308f2d12f04e0f596f024143dd4625d40 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Fri, 26 Jun 2026 15:31:31 +0200 Subject: [PATCH 23/64] Reject control characters at the boundary of the HTTP version token (#16971) (#16986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: `HttpRequestDecoder` accepts a request whose HTTP-version token carries a boundary control byte (`NUL`, `CR`, `LF`, `VT`, `FF`, …): ```bash printf 'GET / \x00HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc ``` `HttpVersion.valueOf(String, boolean)` ran `text.trim()` before matching the version, and `String.trim()` removes every character with code point `<= 0x20`. The boundary control byte was therefore silently stripped and the surviving `"HTTP/1.1"` matched the cached constant, so the malformed request decoded as a clean `HTTP/1.1` one. The method token on the same request line already rejects such a byte since #16723 (issue #15047), so the two tokens were inconsistent — this is the same boundary-control-character / request-smuggling class, left open for the version token. Modification: - `HttpVersion`: drop the `trim()` in `valueOf` and in the `HttpVersion(String, boolean, boolean)` constructor. The existing strict format check (`length == 8 && startsWith("HTTP/") && charAt(6) == '.'`) now rejects a token padded with a control byte, and the non-strict path rejects control/whitespace in the protocol name via `hasControlOrWhitespace`. Without the `trim()`, `SP`/`HT` padding is rejected too, mirroring the method-token behaviour from #16723. - `RtspVersions.valueOf`: drop the `trim()` the same way. `HttpRequestDecoder` already turns `createMessage` exceptions into a decoder failure, so the wire effect is `decoderResult().isSuccess() == false` instead of a phantom `HTTP/1.1`. Result: Before this change `"GET / \x00HTTP/1.1\r\n…"` decoded successfully (`decoderResult().isSuccess() == true`, `protocolVersion() == HTTP_1_1`); after it the decoder reports a failure, matching the method-token behaviour. A clean `HTTP/1.1` request still decodes as before. ``` Test set: io.netty.handler.codec.http.HttpVersionParsingTest Tests run: 53, Failures: 0, Errors: 0, Skipped: 0 Test set: io.netty.handler.codec.http.HttpRequestDecoderTest Tests run: 85, Failures: 0, Errors: 0, Skipped: 0 Test set: io.netty.handler.codec.rtsp.RtspDecoderTest Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 Test set: io.netty.handler.codec.rtsp.RtspEncoderTest Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 ``` Fixes #16970 --------- Co-authored-by: Bryce Anderson --------- Co-authored-by: HwangRock <157935545+HwangRock@users.noreply.github.com> Co-authored-by: Bryce Anderson --- .../netty/handler/codec/http/HttpVersion.java | 57 +++++++++++----- .../handler/codec/rtsp/RtspVersions.java | 9 ++- .../codec/http/HttpRequestDecoderTest.java | 27 ++++++++ .../codec/http/HttpVersionParsingTest.java | 65 +++++++++++++++++++ 4 files changed, 139 insertions(+), 19 deletions(-) diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpVersion.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpVersion.java index aa41143b566..9c013ca3eb2 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpVersion.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpVersion.java @@ -16,16 +16,15 @@ package io.netty.handler.codec.http; import static io.netty.util.internal.ObjectUtil.checkPositiveOrZero; -import static io.netty.util.internal.ObjectUtil.checkNonEmptyAfterTrim; import io.netty.buffer.ByteBuf; import io.netty.util.CharsetUtil; import io.netty.util.internal.ObjectUtil; -import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.Locale; + /** * The version of HTTP or its derived protocols, such as * RTSP and @@ -71,8 +70,6 @@ static HttpVersion valueOf(String text, boolean strict) { return HTTP_1_0; } - text = text.trim(); - if (text.isEmpty()) { throw new IllegalArgumentException("text is empty (possibly HTTP/0.9)"); } @@ -128,7 +125,12 @@ public HttpVersion(String text, boolean keepAliveDefault) { // toUpperCase() without an explicit Locale uses the JVM default. In Turkish locale // (tr_TR) 'i' uppercases to 'İ' (U+0130), which would corrupt protocol strings such // as "icap/1.0" or any custom HTTP-derived scheme that contains a lowercase 'i'. - text = checkNonEmptyAfterTrim(text, "text").toUpperCase(Locale.US); + // Control characters or whitespace at the token boundary must fail the checks below. + ObjectUtil.checkNotNull(text, "text"); + if (text.isEmpty()) { + throw new IllegalArgumentException("text must not be empty"); + } + text = text.toUpperCase(Locale.US); if (strict) { // Only single digit major / minor version is allowed. @@ -142,14 +144,17 @@ public HttpVersion(String text, boolean keepAliveDefault) { majorVersion = toDecimal(text.charAt(5)); minorVersion = toDecimal(text.charAt(7)); } else { - Matcher m = VERSION_PATTERN.matcher(text); - if (!m.matches()) { + int slashIndex = text.indexOf('/'); + int dotIndex = text.indexOf('.', slashIndex + 1); + + if (slashIndex <= 0 || dotIndex <= slashIndex + 1 + || dotIndex >= text.length() - 1 || hasControlOrWhitespace(text, slashIndex)) { throw new IllegalArgumentException("invalid version format: " + text); } - protocolName = m.group(1); - majorVersion = Integer.parseInt(m.group(2)); - minorVersion = Integer.parseInt(m.group(3)); + protocolName = text.substring(0, slashIndex); + majorVersion = parseInt(text, slashIndex + 1, dotIndex); + minorVersion = parseInt(text, dotIndex + 1, text.length()); } this.text = protocolName + '/' + majorVersion + '.' + minorVersion; @@ -157,6 +162,25 @@ public HttpVersion(String text, boolean keepAliveDefault) { bytes = null; } + private static boolean hasControlOrWhitespace(String s, int end) { + for (int i = 0; i < end; i++) { + char c = s.charAt(i); + if (Character.isISOControl(c) || Character.isWhitespace(c)) { + return true; + } + } + return false; + } + + private static int parseInt(String text, int start, int end) { + int result = 0; + for (int i = start; i < end; i++) { + char ch = text.charAt(i); + result = result * 10 + toDecimal(ch); + } + return result; + } + private static int toDecimal(final int value) { if (value < '0' || value > '9') { throw new IllegalArgumentException("Invalid version number, only 0-9 (0x30-0x39) allowed," + @@ -187,13 +211,14 @@ private HttpVersion( boolean keepAliveDefault, boolean bytes) { // See the comment in the (text, strict, keepAliveDefault) constructor for why this needs // an explicit Locale.US: avoids the Turkish-locale 'i' -> 'İ' corruption. - protocolName = checkNonEmptyAfterTrim(protocolName, "protocolName").toUpperCase(Locale.US); + ObjectUtil.checkNotNull(protocolName, "protocolName"); + if (protocolName.isEmpty()) { + throw new IllegalArgumentException("protocolName must not be empty"); + } + protocolName = protocolName.toUpperCase(Locale.US); - for (int i = 0; i < protocolName.length(); i ++) { - if (Character.isISOControl(protocolName.charAt(i)) || - Character.isWhitespace(protocolName.charAt(i))) { - throw new IllegalArgumentException("invalid character in protocolName"); - } + if (hasControlOrWhitespace(protocolName, protocolName.length())) { + throw new IllegalArgumentException("invalid character in protocolName"); } checkPositiveOrZero(majorVersion, "majorVersion"); diff --git a/codec-http/src/main/java/io/netty/handler/codec/rtsp/RtspVersions.java b/codec-http/src/main/java/io/netty/handler/codec/rtsp/RtspVersions.java index 9789ac310a2..9941c3bcb0f 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/rtsp/RtspVersions.java +++ b/codec-http/src/main/java/io/netty/handler/codec/rtsp/RtspVersions.java @@ -39,14 +39,17 @@ public final class RtspVersions { public static HttpVersion valueOf(String text) { ObjectUtil.checkNotNull(text, "text"); + if (text.isEmpty()) { + throw new IllegalArgumentException("text must not be empty"); + } // toUpperCase() must specify Locale.US so the comparison against "RTSP/1.0" is not // affected by the JVM default locale (e.g. Turkish, where 'i' uppercases to 'İ'). - text = text.trim().toUpperCase(Locale.US); - if ("RTSP/1.0".equals(text)) { + String upper = text.toUpperCase(Locale.US); + if ("RTSP/1.0".equals(upper)) { return RTSP_1_0; } - return new HttpVersion(text, true); + return new HttpVersion(upper, true); } private RtspVersions() { diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/HttpRequestDecoderTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/HttpRequestDecoderTest.java index 8d411015890..16422427cca 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/HttpRequestDecoderTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/HttpRequestDecoderTest.java @@ -1265,4 +1265,31 @@ private static void testInvalidHeaders0(ByteBuf requestBuffer) { assertTrue(request.decoderResult().isFailure()); assertFalse(channel.finish()); } + + @Test + public void testNulInVersionTokenIsRejected() { + // A NUL right before the version token must be rejected. + testInvalidHeaders0("GET / " + (char) 0 + "HTTP/1.1\r\nHost: whatever\r\n\r\n"); + } + + @Test + public void testNulInMethodTokenIsRejected() { + // Control case: a NUL inside the method token is also rejected. + testInvalidHeaders0("GET" + (char) 0 + " / HTTP/1.1\r\nHost: whatever\r\n\r\n"); + } + + @Test + public void testNormalRequestStillDecodes() { + EmbeddedChannel channel = new EmbeddedChannel(new HttpRequestDecoder()); + assertTrue(channel.writeInbound(Unpooled.copiedBuffer("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", + CharsetUtil.US_ASCII))); + HttpRequest req = channel.readInbound(); + assertNotNull(req); + assertTrue(req.decoderResult().isSuccess()); + assertEquals(HttpVersion.HTTP_1_1, req.protocolVersion()); + LastHttpContent last = channel.readInbound(); + assertNotNull(last); + last.release(); + assertFalse(channel.finish()); + } } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/HttpVersionParsingTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/HttpVersionParsingTest.java index d2971ac4726..4c4f7b4031c 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/HttpVersionParsingTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/HttpVersionParsingTest.java @@ -119,6 +119,71 @@ public void execute() throws Throwable { }); } + @Test + void testLeadingNulInStrictValueOfIsRejected() { + final String text = (char) 0x00 + "HTTP/1.1"; + assertThrows(IllegalArgumentException.class, new Executable() { + @Override + public void execute() throws Throwable { + HttpVersion.valueOf(text, true); + } + }, "leading NUL must be rejected"); + } + + @Test + void testTrailingNulInStrictValueOfIsRejected() { + final String text = "HTTP/1.1" + (char) 0x00; + assertThrows(IllegalArgumentException.class, new Executable() { + @Override + public void execute() throws Throwable { + HttpVersion.valueOf(text, true); + } + }, "trailing NUL must be rejected"); + } + + @Test + void testLeadingControlCharInStrictValueOfIsRejected() { + // CR, LF, VT and FF must all be rejected in the version token. + for (int control : new int[] {0x0D, 0x0A, 0x0B, 0x0C}) { + final String text = (char) control + "HTTP/1.1"; + assertThrows(IllegalArgumentException.class, new Executable() { + @Override + public void execute() throws Throwable { + HttpVersion.valueOf(text, true); + } + }, "control char 0x" + Integer.toHexString(control) + " must be rejected"); + } + } + + @Test + void testLeadingSpaceInStrictValueOfIsRejected() { + // A leading space in the version token must be rejected. + assertThrows(IllegalArgumentException.class, new Executable() { + @Override + public void execute() throws Throwable { + HttpVersion.valueOf(" HTTP/1.1", true); + } + }, "leading space must be rejected"); + } + + @Test + void testLeadingNulInNonStrictValueOfIsRejected() { + final String text = (char) 0x00 + "HTTP/1.1"; + assertThrows(IllegalArgumentException.class, new Executable() { + @Override + public void execute() throws Throwable { + HttpVersion.valueOf(text); + } + }, "leading NUL must be rejected in non-strict mode too"); + } + + @Test + void testValidVersionsResolveCorrectly() { + assertSame(HttpVersion.HTTP_1_1, HttpVersion.valueOf("HTTP/1.1", true)); + assertSame(HttpVersion.HTTP_1_0, HttpVersion.valueOf("HTTP/1.0")); + assertEquals("ICAP", HttpVersion.valueOf("icap/1.0").protocolName()); + } + @ParameterizedTest @ValueSource(strings = { "HTTP ", From 666c3b2cf19fa2f80d1ba8c7d5becb500371c948 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:02:26 +0200 Subject: [PATCH 24/64] Auto-port 4.1: Reset UTF-8 decode state on CR in StompSubframeDecoder (#17003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-port of #16991 to 4.1 Cherry-picked commit: fc86cc65bf2c66295989c658da7ea912c4a66922 --- Motivation: When a CR (`0x0D`) byte appears in the middle of a multi-byte UTF-8 sequence within a STOMP header line, `Utf8LineParser.process` takes the CR branch and returns early **without clearing its partial UTF-8 decode state** (`interim` / `nextRead`). The next byte is then combined with the stale state and decoded incorrectly, corrupting the decoded value. For example, a header value made of the bytes `0xC3 0x0D 0x41` (`0xC3` starts a 2-byte sequence, `CR`, then `A`) decodes to `Á` instead of `A`. Modifications: Clear `interim` and `nextRead` in the CR branch of `Utf8LineParser.process`, so that a CR resets the UTF-8 decode state. CR is an ASCII control byte and is never a valid UTF-8 lead or continuation byte, so if it interrupts a multi-byte sequence that sequence is malformed and the partial state should be discarded. Result: Bytes following a CR are decoded correctly. Adds a regression test (`StompSubframeDecoderTest#testCRResetsUtf8DecodeState`) that fails before this change (`expected: but was: <Á>`) and passes after it. The full `StompSubframeDecoderTest` suite continues to pass. Co-authored-by: Vasiliy Mikhailov --- .../codec/stomp/StompSubframeDecoder.java | 2 + .../codec/stomp/StompSubframeDecoderTest.java | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeDecoder.java b/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeDecoder.java index 5c3ec0d78cb..ba53c2bbcc4 100644 --- a/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeDecoder.java +++ b/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeDecoder.java @@ -288,6 +288,8 @@ AppendableCharSequence charSequence() { @Override public boolean process(byte nextByte) throws Exception { if (nextByte == StompConstants.CR) { + interim = 0; + nextRead = false; ++lineLength; return true; } diff --git a/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeDecoderTest.java b/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeDecoderTest.java index 7c01faf3b10..17763319939 100644 --- a/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeDecoderTest.java +++ b/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeDecoderTest.java @@ -443,4 +443,57 @@ void testInvalidEscapeHeadersSequence() { assertEquals("received an invalid escape header sequence 'custom_invalid\\t'", headersSubFrame.decoderResult().cause().getMessage()); } + + @Test + public void testCRResetsUtf8DecodeState() { + // When a CR byte appears during a multi-byte UTF-8 sequence, the parser's interim state + // should be cleared so that subsequent bytes are decoded correctly. + // Bug: CR is skipped without resetting interim/nextRead, so the next byte gets + // incorrectly combined with dirty UTF-8 state, producing garbage characters. + // + // Craft a header value where: + // - 0xC3 starts a 2-byte UTF-8 sequence (sets interim) + // - 0x0D (CR) is skipped but should clear interim state + // - 0x41 ('A') should be decoded as plain ASCII 'A', not combined with dirty interim + channel = new EmbeddedChannel(new StompSubframeDecoder()); + + ByteBuf incoming = Unpooled.buffer(); + // CONNECT command line + incoming.writeBytes("CONNECT\r\n".getBytes(UTF_8)); + // header with multi-byte UTF-8 start byte (0xC3), then CR, then ASCII 'A' + incoming.writeByte((byte) 'h'); + incoming.writeByte((byte) 'e'); + incoming.writeByte((byte) 'a'); + incoming.writeByte((byte) 'd'); + incoming.writeByte((byte) 'e'); + incoming.writeByte((byte) 'r'); + incoming.writeByte((byte) ':'); + // 0xC3 starts a 2-byte UTF-8 sequence - sets interim state + incoming.writeByte((byte) 0xC3); + // CR (0x0D) - should be skipped AND clear the interim UTF-8 state + incoming.writeByte((byte) 0x0D); + // 'A' - should be decoded as plain 'A' since CR should have reset state + incoming.writeByte((byte) 'A'); + // end of header line + incoming.writeByte((byte) '\n'); + // empty line to end headers + incoming.writeByte((byte) '\n'); + // null byte to end frame + incoming.writeByte((byte) '\0'); + + assertTrue(channel.writeInbound(incoming)); + + StompHeadersSubframe frame = channel.readInbound(); + assertNotNull(frame); + assertEquals(StompCommand.CONNECT, frame.command()); + // The header value should be just "A" (CR is skipped, UTF-8 state reset) + // With the bug, it would contain corrupted UTF-8 combining 0xC3 with 'A' + assertEquals("A", frame.headers().get("header")); + + StompContentSubframe content = channel.readInbound(); + assertSame(LastStompContentSubframe.EMPTY_LAST_CONTENT, content); + content.release(); + + assertNull(channel.readInbound()); + } } From 42e71039a50b2442df38f34e05c6389c210675c9 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:40:36 +0200 Subject: [PATCH 25/64] Auto-port 4.1: HTTP2: Pass the correct number of arguments when logging goaway (#17017) Auto-port of #16392 to 4.1 Cherry-picked commit: c40a34477faea9fd8bf7d6921640367dcfd3876c --- Motivation: We did not use the correct number of arguments as future.cause() is null. Modifications: Remove future.cause() as argument Result: Fixes https://github.com/netty/netty/issues/16391 Co-authored-by: Norman Maurer --- .../io/netty/handler/codec/http2/Http2ConnectionHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ConnectionHandler.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ConnectionHandler.java index ca494b8de24..524aa4f6bb1 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ConnectionHandler.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2ConnectionHandler.java @@ -963,7 +963,7 @@ private static void processGoAwayWriteResult(final ChannelHandlerContext ctx, fi if (logger.isDebugEnabled()) { logger.debug("{} Sent GOAWAY: lastStreamId '{}', errorCode '{}', " + "debugData '{}'. Forcing shutdown of the connection.", - ctx.channel(), lastStreamId, errorCode, debugData.toString(UTF_8), future.cause()); + ctx.channel(), lastStreamId, errorCode, debugData.toString(UTF_8)); } ctx.close(); } From 7fd5cc7db54e5ec8b21a386334b606df79e8851d Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Mon, 29 Jun 2026 18:41:35 -0700 Subject: [PATCH 26/64] FastLz: Guard decompression against truncated input (#17000) (#17015) Motivation: Malformed FastLZ compressed blocks can declare input that is empty or ends in the middle of match metadata. FastLz.decompress should treat these blocks as corrupted input instead of reading past the supplied chunk length. Modification: Return 0 for empty compressed input and check the input length before reading optional match length and distance bytes. Add FastLzFrameDecoder regression coverage for empty compressed payloads and truncated match metadata. Result: Malformed FastLZ compressed blocks now fail through the decoder as DecompressionException instead of triggering ByteBuf bounds exceptions. Security impact: This is not considered a security issue. Malformed compressed input can already cause the decoder to fail the channel, and the previous behavior was a Java `ByteBuf` bounds exception rather than native memory corruption, data disclosure, or privilege boundary bypass. The change makes the failure mode consistent by treating truncated FastLZ blocks as corrupted input and surfacing the existing `DecompressionException` path. --------- Co-authored-by: multicode (cherry picked from commit 775ad710da8fcd712e46490429c8595dd66ee2e9) Co-authored-by: Jonas Konrad --- .../handler/codec/compression/FastLz.java | 19 ++++++ .../compression/FastLzFrameDecoderTest.java | 61 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 codec/src/test/java/io/netty/handler/codec/compression/FastLzFrameDecoderTest.java diff --git a/codec/src/main/java/io/netty/handler/codec/compression/FastLz.java b/codec/src/main/java/io/netty/handler/codec/compression/FastLz.java index bfb4f00aca0..6292803389c 100644 --- a/codec/src/main/java/io/netty/handler/codec/compression/FastLz.java +++ b/codec/src/main/java/io/netty/handler/codec/compression/FastLz.java @@ -408,6 +408,10 @@ static int compress(final ByteBuf input, final int inOffset, final int inLength, */ static int decompress(final ByteBuf input, final int inOffset, final int inLength, final ByteBuf output, final int outOffset, final int outLength) { + if (inLength == 0) { + return 0; + } + //int level = ((*(const flzuint8*)input) >> 5) + 1; final int level = (input.getByte(inOffset) >> 5) + 1; if (level != LEVEL_1 && level != LEVEL_2) { @@ -440,19 +444,31 @@ static int decompress(final ByteBuf input, final int inOffset, final int inLengt int code; if (len == 6) { if (level == LEVEL_1) { + if (ip >= inLength) { + return 0; + } // len += *ip++; len += input.getUnsignedByte(inOffset + ip++); } else { do { + if (ip >= inLength) { + return 0; + } code = input.getUnsignedByte(inOffset + ip++); len += code; } while (code == 255); } } if (level == LEVEL_1) { + if (ip >= inLength) { + return 0; + } // ref -= *ip++; ref -= input.getUnsignedByte(inOffset + ip++); } else { + if (ip >= inLength) { + return 0; + } code = input.getUnsignedByte(inOffset + ip++); ref -= code; @@ -460,6 +476,9 @@ static int decompress(final ByteBuf input, final int inOffset, final int inLengt // if(FASTLZ_UNEXPECT_CONDITIONAL(code==255)) // if(FASTLZ_EXPECT_CONDITIONAL(ofs==(31 << 8))) if (code == 255 && ofs == 31 << 8) { + if (ip + 2 > inLength) { + return 0; + } ofs = input.getUnsignedByte(inOffset + ip++) << 8; ofs += input.getUnsignedByte(inOffset + ip++); diff --git a/codec/src/test/java/io/netty/handler/codec/compression/FastLzFrameDecoderTest.java b/codec/src/test/java/io/netty/handler/codec/compression/FastLzFrameDecoderTest.java new file mode 100644 index 00000000000..70fa10ae140 --- /dev/null +++ b/codec/src/test/java/io/netty/handler/codec/compression/FastLzFrameDecoderTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project 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: + * + * https://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 io.netty.handler.codec.compression; + +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class FastLzFrameDecoderTest { + + @Test + public void testCompressedBlockWithEmptyPayload() { + assertDecompressionException(new byte[] { + 'F', 'L', 'Z', + 0x01, + 0x00, 0x00, + 0x00, 0x01 + }); + } + + @Test + public void testCompressedBlockWithTruncatedMatch() { + assertDecompressionException(new byte[] { + 'F', 'L', 'Z', + 0x01, + 0x00, 0x03, + 0x00, 0x04, + 0x00, 'A', 0x20 + }); + } + + private static void assertDecompressionException(final byte[] input) { + final EmbeddedChannel channel = new EmbeddedChannel(new FastLzFrameDecoder()); + try { + assertThrows(DecompressionException.class, new Executable() { + @Override + public void execute() throws Throwable { + channel.writeInbound(Unpooled.wrappedBuffer(input)); + } + }); + } finally { + channel.finishAndReleaseAll(); + } + } +} From b12bd1161479013628711fc0cf07ebb8697dd9f3 Mon Sep 17 00:00:00 2001 From: skyguard1 Date: Tue, 30 Jun 2026 12:37:25 +0800 Subject: [PATCH 27/64] Backport 4.1 Fix propagation of startTls for client SslContext handler (#17020) --- .../handler/ssl/OpenSslClientContext.java | 18 ++++++++++++++-- .../ReferenceCountedOpenSslClientContext.java | 16 +++++++++++++- .../ssl/ReferenceCountedOpenSslContext.java | 4 ++-- .../java/io/netty/handler/ssl/SslContext.java | 21 +++++++++++++++++-- .../netty/handler/ssl/SslContextBuilder.java | 2 +- 5 files changed, 53 insertions(+), 8 deletions(-) diff --git a/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java b/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java index 57152de1fbc..697dc0ae8ec 100644 --- a/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java @@ -178,7 +178,7 @@ public OpenSslClientContext(File trustCertCollectionFile, TrustManagerFactory tr this(toX509CertificatesInternal(trustCertCollectionFile), trustManagerFactory, toX509CertificatesInternal(keyCertChainFile), toPrivateKeyInternal(keyFile, keyPassword), keyPassword, keyManagerFactory, ciphers, cipherFilter, apn, null, sessionCacheSize, - sessionTimeout, false, KeyStore.getDefaultType(), null, null); + sessionTimeout, false, false, KeyStore.getDefaultType(), null, null); } OpenSslClientContext(X509Certificate[] trustCertCollection, TrustManagerFactory trustManagerFactory, @@ -189,7 +189,21 @@ public OpenSslClientContext(File trustCertCollectionFile, TrustManagerFactory tr String endpointIdentificationAlgorithm, ResumptionController resumptionController, Map.Entry, Object>... options) throws SSLException { - super(ciphers, cipherFilter, apn, SSL.SSL_MODE_CLIENT, keyCertChain, ClientAuth.NONE, protocols, false, + this(trustCertCollection, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, ciphers, + cipherFilter, apn, protocols, sessionCacheSize, sessionTimeout, false, enableOcsp, keyStore, + endpointIdentificationAlgorithm, resumptionController, options); + } + + OpenSslClientContext(X509Certificate[] trustCertCollection, TrustManagerFactory trustManagerFactory, + X509Certificate[] keyCertChain, PrivateKey key, String keyPassword, + KeyManagerFactory keyManagerFactory, Iterable ciphers, + CipherSuiteFilter cipherFilter, ApplicationProtocolConfig apn, String[] protocols, + long sessionCacheSize, long sessionTimeout, boolean startTls, boolean enableOcsp, + String keyStore, String endpointIdentificationAlgorithm, + ResumptionController resumptionController, + Map.Entry, Object>... options) + throws SSLException { + super(ciphers, cipherFilter, apn, SSL.SSL_MODE_CLIENT, keyCertChain, ClientAuth.NONE, protocols, startTls, endpointIdentificationAlgorithm, enableOcsp, resumptionController, options); boolean success = false; try { diff --git a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java index 6d4b21b7ce7..038aa2f14e7 100644 --- a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java @@ -66,8 +66,22 @@ public final class ReferenceCountedOpenSslClientContext extends ReferenceCounted boolean enableOcsp, String keyStore, String endpointIdentificationAlgorithm, ResumptionController resumptionController, Map.Entry, Object>... options) throws SSLException { + this(trustCertCollection, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, ciphers, + cipherFilter, apn, protocols, sessionCacheSize, sessionTimeout, false, enableOcsp, keyStore, + endpointIdentificationAlgorithm, resumptionController, options); + } + + ReferenceCountedOpenSslClientContext(X509Certificate[] trustCertCollection, TrustManagerFactory trustManagerFactory, + X509Certificate[] keyCertChain, PrivateKey key, String keyPassword, + KeyManagerFactory keyManagerFactory, Iterable ciphers, + CipherSuiteFilter cipherFilter, ApplicationProtocolConfig apn, + String[] protocols, long sessionCacheSize, long sessionTimeout, + boolean startTls, boolean enableOcsp, String keyStore, + String endpointIdentificationAlgorithm, + ResumptionController resumptionController, + Map.Entry, Object>... options) throws SSLException { super(ciphers, cipherFilter, toNegotiator(apn), SSL.SSL_MODE_CLIENT, keyCertChain, - ClientAuth.NONE, protocols, false, endpointIdentificationAlgorithm, enableOcsp, true, + ClientAuth.NONE, protocols, startTls, endpointIdentificationAlgorithm, enableOcsp, true, resumptionController, options); boolean success = false; try { diff --git a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslContext.java b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslContext.java index 9886543f97e..1546234a191 100644 --- a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslContext.java @@ -114,7 +114,7 @@ public abstract class ReferenceCountedOpenSslContext extends SslContext implemen static final boolean SERVER_ENABLE_SESSION_TICKET = SystemPropertyUtil.getBoolean("jdk.tls.server.enableSessionTicketExtension", false); - static final boolean SERVER_ENABLE_SESSION_TICKET_TLSV13 = + static final boolean SERVER_ENABLE_SESSION_TICKET_TLSV13 = SystemPropertyUtil.getBoolean("jdk.tls.server.enableSessionTicketExtension", true); static final boolean SERVER_ENABLE_SESSION_CACHE = @@ -527,7 +527,7 @@ protected SslHandler newHandler(ByteBufAllocator alloc, boolean startTls, Execut @Override protected SslHandler newHandler(ByteBufAllocator alloc, String peerHost, int peerPort, boolean startTls, Executor executor) { - return new SslHandler(newEngine0(alloc, peerHost, peerPort, false), false, executor, resumptionController); + return new SslHandler(newEngine0(alloc, peerHost, peerPort, false), startTls, executor, resumptionController); } SSLEngine newEngine0(ByteBufAllocator alloc, String peerHost, int peerPort, boolean jdkCompatibilityMode) { diff --git a/handler/src/main/java/io/netty/handler/ssl/SslContext.java b/handler/src/main/java/io/netty/handler/ssl/SslContext.java index af13a4d9114..e5ec0bcc1ad 100644 --- a/handler/src/main/java/io/netty/handler/ssl/SslContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/SslContext.java @@ -826,6 +826,21 @@ static SslContext newClientContextInternal( long sessionCacheSize, long sessionTimeout, boolean enableOcsp, SecureRandom secureRandom, String keyStoreType, String endpointIdentificationAlgorithm, Map.Entry, Object>... options) throws SSLException { + return newClientContextInternal(provider, sslContextProvider, trustCert, trustManagerFactory, keyCertChain, key, + keyPassword, keyManagerFactory, ciphers, cipherFilter, apn, protocols, sessionCacheSize, + sessionTimeout, false, enableOcsp, secureRandom, keyStoreType, endpointIdentificationAlgorithm, + options); + } + + static SslContext newClientContextInternal( + SslProvider provider, + Provider sslContextProvider, + X509Certificate[] trustCert, TrustManagerFactory trustManagerFactory, + X509Certificate[] keyCertChain, PrivateKey key, String keyPassword, KeyManagerFactory keyManagerFactory, + Iterable ciphers, CipherSuiteFilter cipherFilter, ApplicationProtocolConfig apn, String[] protocols, + long sessionCacheSize, long sessionTimeout, boolean startTls, boolean enableOcsp, + SecureRandom secureRandom, String keyStoreType, String endpointIdentificationAlgorithm, + Map.Entry, Object>... options) throws SSLException { if (provider == null) { provider = defaultClientProvider(); } @@ -848,14 +863,16 @@ static SslContext newClientContextInternal( return new OpenSslClientContext( trustCert, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, ciphers, cipherFilter, apn, protocols, sessionCacheSize, sessionTimeout, - enableOcsp, keyStoreType, endpointIdentificationAlgorithm, resumptionController, options); + startTls, enableOcsp, keyStoreType, endpointIdentificationAlgorithm, + resumptionController, options); case OPENSSL_REFCNT: verifyNullSslContextProvider(provider, sslContextProvider); OpenSsl.ensureAvailability(); return new ReferenceCountedOpenSslClientContext( trustCert, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, ciphers, cipherFilter, apn, protocols, sessionCacheSize, sessionTimeout, - enableOcsp, keyStoreType, endpointIdentificationAlgorithm, resumptionController, options); + startTls, enableOcsp, keyStoreType, endpointIdentificationAlgorithm, + resumptionController, options); default: throw new Error(provider.toString()); } diff --git a/handler/src/main/java/io/netty/handler/ssl/SslContextBuilder.java b/handler/src/main/java/io/netty/handler/ssl/SslContextBuilder.java index 2238881a68d..7080e3ca0b5 100644 --- a/handler/src/main/java/io/netty/handler/ssl/SslContextBuilder.java +++ b/handler/src/main/java/io/netty/handler/ssl/SslContextBuilder.java @@ -648,7 +648,7 @@ public SslContext build() throws SSLException { return SslContext.newClientContextInternal(provider, sslContextProvider, trustCertCollection, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, ciphers, cipherFilter, apn, protocols, sessionCacheSize, - sessionTimeout, enableOcsp, secureRandom, keyStoreType, endpointIdentificationAlgorithm, + sessionTimeout, startTls, enableOcsp, secureRandom, keyStoreType, endpointIdentificationAlgorithm, toArray(options.entrySet(), EMPTY_ENTRIES)); } } From d7dcf6c11a9767ba90edc1006a9226674c9fbf76 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:05:03 +0200 Subject: [PATCH 28/64] Auto-port 4.1: Reject non-token characters in HTTP/2 header names (#17022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-port of #16762 to 4.1 Cherry-picked commit: 6661ee4122a028fc8ee1ed4e740da1bd34f128c0 --- ## Problem `DefaultHttp2Headers` accepts header names that contain bytes outside the HTTP token grammar — non-ASCII (e.g. `0xF0`), control characters (NUL/CR/LF/VT/FF/HTAB/...), SP, DEL, and the RFC 7230 separators (`"(),/:;<=>?@[\\]{}\""`). The current `HTTP2_NAME_VALIDATOR` only rejects upper-case ASCII and lets everything else through, so something like `headers.add(new AsciiString(new byte[]{(byte)0xF0}), "v")` succeeds and is then encoded onto the wire. This was reported in #11975 with maintainer agreement on direction: - @idelpivnitskiy: confirmed the field-name grammar - @ejona86: "good and appropriate to restrict keys to the values permitted in HTTP/1, with little risk" ## Root Cause `DefaultHttp2Headers.HTTP2_NAME_VALIDATOR_PROCESSOR` returns `!isUpperCase(value)` and the non-`AsciiString` fallback only loops on `isUpperCase(charAt(i))`. Both paths therefore enforce the lower-case rule but skip the rest of RFC 9113 §8.2.1, which inherits the RFC 7230 token grammar. ## Fix In `codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2Headers.java`: - After the empty/null and pseudo-header checks, run `HttpHeaderValidationUtil.validateToken(name)` and throw `connectionError(PROTOCOL_ERROR, ...)` when it returns a non-`-1` index. - Keep the existing upper-case `ByteProcessor` / fallback loop. The token check is additive: pseudo-headers still short-circuit, valid lower-case tokens still pass, upper-case ASCII still gets rejected (now at the upper-case check, not the token check). Pseudo-headers (`:method`, `:path`, ...) deliberately bypass the token check via the existing `hasPseudoHeaderFormat` early return, since `:` is not a valid token character but is required for pseudo-headers. ## Tests Added | Change point | Test | |--------------|------| | Reject non-ASCII via the `AsciiString` byte path | `rejectNonAsciiHeaderNameAsciiString` (uses the U+1F631 / `0xF0 0x9F 0x98 0xB1` reproducer from #11975) | | Reject non-ASCII via the `CharSequence` `charAt` path | `rejectNonAsciiHeaderNameCharSequence`, `rejectHighBitHeaderNameCharSequence` (covers char ≤ 0xFF and char > 0xFF) | | Reject every C0 control byte and every RFC 7230 separator | `rejectNonTokenCharactersInHeaderName` (parameterized — 28 cases: NUL, SOH, BEL, BS, HTAB, LF, VT, FF, CR, US, DEL, SP, and `, ; : / = ? @ ( ) [ ] { } < > \ "`) | | Regression: upper-case rejection unchanged | `uppercaseHeaderNameStillRejected` | | Regression: full RFC 7230 lower-case token still accepted | `acceptValidLowercaseTokenHeaderName` (covers `x-custom-header`, `2name`, and a name using every special token char `!#$%&'*+-.^_\`|~`) | | Regression: pseudo-headers still accepted | `acceptPseudoHeaderName` | | Existing wire-level test now exercises the validator | `InboundHttp2ToHttpAdapterTest.clientRequestSingleHeaderNonAsciiShouldThrow` updated — the rejection now fires at `headers.add(...)` instead of at the HPACK encoder | `mvn -pl codec-http2 test -Drevapi.skip=true` runs 1512 tests with 0 failures locally; `codec-http` regression run is also clean (8891 tests, 0 failures). ## Impact - **API**: `new DefaultHttp2Headers().add(name, value)` now throws `Http2Exception(PROTOCOL_ERROR, ...)` for any name that is not a valid lower-case RFC 7230 token. Code that was relying on the previous lax behaviour (passing non-ASCII or separator bytes) needs to either (a) clean up the name before adding, or (b) construct the headers with `new DefaultHttp2Headers(false)` to opt out of validation, which already exists. - **Wire effect**: Inbound HPACK-decoded headers with malformed names now produce an `Http2Exception` at validation time and are surfaced via the existing decoder failure path. The previous behaviour silently accepted them and could allow malformed bytes to reach the application as parsed `Http2Headers`. - **Hot path**: Standard requests are unaffected — `DefaultHttp2Headers` validation only runs when adding non-pseudo headers, and the new `validateToken` call is a single forward scan that returns `-1` on the first invalid byte for clean tokens. Fixes #11975 --------- Co-authored-by: Guimu <30684111+daguimu@users.noreply.github.com> Co-authored-by: Norman Maurer Co-authored-by: Chris Vest --- .../codec/http2/DefaultHttp2Headers.java | 9 ++ .../codec/http2/DefaultHttp2HeadersTest.java | 114 ++++++++++++++++++ .../http2/InboundHttp2ToHttpAdapterTest.java | 5 +- 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2Headers.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2Headers.java index 6bfd0c14b96..92d450fedc9 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2Headers.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2Headers.java @@ -54,6 +54,15 @@ public void validateName(CharSequence name) { return; } + // RFC 9113 Section 8.2.1: HTTP/2 field names are valid HTTP/1.1 tokens (RFC 7230 Section 3.2.6) + // with the additional constraint that they MUST be lowercase. Reject anything outside the token + // grammar (non-ASCII, control characters, SP/HTAB, separators) before the lowercase check. + int tokenIndex = HttpHeaderValidationUtil.validateToken(name); + if (tokenIndex != -1) { + PlatformDependent.throwException(connectionError(PROTOCOL_ERROR, + "invalid header name [%s]", name)); + } + if (name instanceof AsciiString) { final int index; try { diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2HeadersTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2HeadersTest.java index 612f1d828f7..b9f4d540f74 100644 --- a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2HeadersTest.java +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2HeadersTest.java @@ -23,7 +23,10 @@ import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import java.util.Arrays; +import java.util.List; import java.util.Map.Entry; import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH; @@ -158,6 +161,117 @@ public void execute() throws Throwable { }); } + @Test + public void rejectNonAsciiHeaderNameAsciiString() { + // U+1F631 ("😱") encoded as F0 9F 98 B1 — bytes outside the token grammar that previously + // slipped past the upper-case-only validator. See issue #11975. + final byte[] buf = {(byte) 0xF0, (byte) 0x9F, (byte) 0x98, (byte) 0xB1}; + final Http2Headers headers = new DefaultHttp2Headers(); + assertThrows(Http2Exception.class, new Executable() { + @Override + public void execute() throws Throwable { + headers.add(new AsciiString(buf), of("test")); + } + }); + } + + @Test + public void rejectNonAsciiHeaderNameCharSequence() { + final Http2Headers headers = new DefaultHttp2Headers(); + assertThrows(Http2Exception.class, new Executable() { + @Override + public void execute() throws Throwable { + // Non-ASCII char via the CharSequence path. + headers.add("naÿme", "test"); + } + }); + } + + @Test + public void rejectHighBitHeaderNameCharSequence() { + final Http2Headers headers = new DefaultHttp2Headers(); + assertThrows(Http2Exception.class, new Executable() { + @Override + public void execute() throws Throwable { + // U+0100 — above 0xFF, must be rejected by the CharSequence path. + headers.add("naĀme", "test"); + } + }); + } + + @ParameterizedTest(name = "{displayName} [{index}] byte=0x{0}") + @MethodSource("nonTokenBytesInHeaderName") + void rejectNonTokenCharactersInHeaderName(int illegalByte) { + final String name = "n" + (char) illegalByte + "ame"; + final Http2Headers headers = new DefaultHttp2Headers(); + assertThrows(Http2Exception.class, new Executable() { + @Override + public void execute() throws Throwable { + headers.add(name, "test"); + } + }); + } + + static List nonTokenBytesInHeaderName() { + return Arrays.asList( + // Control characters: every byte in the C0 range that the previous validator silently let through. + 0x00, // NUL + 0x01, // SOH + 0x07, // BEL + 0x08, // BS + 0x09, // HTAB + 0x0A, // LF + 0x0B, // VT + 0x0C, // FF + 0x0D, // CR + 0x1F, // US + 0x7F, // DEL + // Whitespace and RFC 7230 separators that are not valid token characters. + 0x20, // SP + (int) ',', + (int) ';', + (int) ':', + (int) '/', + (int) '=', + (int) '?', + (int) '@', + (int) '(', + (int) ')', + (int) '[', + (int) ']', + (int) '{', + (int) '}', + (int) '<', + (int) '>', + (int) '\\', + (int) '"' + ); + } + + @Test + public void acceptValidLowercaseTokenHeaderName() { + // Regression: valid RFC 7230 token (lower-case ALPHA, DIGIT, "!#$%&'*+-.^_`|~") must be accepted. + Http2Headers headers = new DefaultHttp2Headers(); + headers.add(of("x-custom-header"), of("v")); + headers.add(of("2name"), of("v")); + headers.add(of("a!#$%&'*+-.^_`|~b"), of("v")); + assertTrue(headers.contains("x-custom-header")); + assertTrue(headers.contains("2name")); + assertTrue(headers.contains("a!#$%&'*+-.^_`|~b")); + } + + @Test + public void acceptPseudoHeaderName() { + // Regression: leading-colon pseudo-header names must remain accepted even though ":" is not a token char. + Http2Headers headers = new DefaultHttp2Headers(); + headers.method(of("GET")); + headers.path(of("/")); + headers.scheme(of("https")); + headers.authority(of("example.com")); + assertEquals(of("GET"), headers.method()); + assertEquals(of("/"), headers.path()); + } + @Test public void testClearResetsPseudoHeaderDivision() { DefaultHttp2Headers http2Headers = new DefaultHttp2Headers(); diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapterTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapterTest.java index dc9626c3c7b..2ba2f63719c 100644 --- a/codec-http2/src/test/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapterTest.java +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapterTest.java @@ -239,7 +239,10 @@ public void run() throws Http2Exception { @Test public void clientRequestSingleHeaderNonAsciiShouldThrow() throws Exception { boostrapEnv(1, 1, 1); - final Http2Headers http2Headers = new DefaultHttp2Headers() + // Disable validation on the client side so the non-ASCII header name reaches the wire; the + // server-side validation (RFC 9113 §8.2.1 requires field names to be valid HTTP/1.1 tokens) + // then rejects it, which surfaces as a stream error. + final Http2Headers http2Headers = new DefaultHttp2Headers(false) .method(new AsciiString("GET")) .scheme(new AsciiString("https")) .authority(new AsciiString("example.org")) From 50e6e1dc57b1ececabccddd44d30a96c1e108145 Mon Sep 17 00:00:00 2001 From: Jonas Konrad Date: Mon, 6 Jul 2026 18:49:30 +0200 Subject: [PATCH 29/64] Update lz4-java to 1.11.1 (#17060) --- pom.xml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d1bcc35790c..2762b55a723 100644 --- a/pom.xml +++ b/pom.xml @@ -1063,7 +1063,7 @@ at.yawk.lz4 lz4-java - 1.10.1 + 1.11.1 com.github.jponge @@ -1426,6 +1426,18 @@ io\.netty\..* They're not "external classes" if they're from a Netty package. + + true + java.class.externalClassExposedInAPI + class net.jpountz.lz4.LZ4JNIFastResetCompressor + The optional lz4-java dependency exposes this class through LZ4Factory, which is part of Netty's public API to allow users to customize LZ4 implementations. + + + true + java.class.externalClassExposedInAPI + class net.jpountz.lz4.LZ4JNIHCFastResetCompressor + The optional lz4-java dependency exposes this class through LZ4Factory, which is part of Netty's public API to allow users to customize LZ4 implementations. + true java.field.removed From 7b44dc9a1b6554c9d288b45bdd995063881992dd Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Wed, 8 Jul 2026 01:25:33 +0200 Subject: [PATCH 30/64] Pin github actions to reduce risk (#17043) (#17044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: An attacker who compromises any of three upstream GitHub Actions repositories can force-push a malicious commit to a mutable version tag, causing the next Netty release run to exfiltrate SSH deploy keys, GPG signing keys, and Maven Central credentials — enabling publication of backdoored `io.netty:*` artifacts to Maven Central. Modifications: Pin github actions to sha Result: Reduce risk --------- Co-authored-by: Chris Vest Co-authored-by: Chris Vest --- .github/workflows/ci-deploy.yml | 49 ++++++---- .github/workflows/ci-release-4.2.yml | 128 ++++++++++++++++++--------- .github/workflows/ci-release-5.yml | 58 +++++++----- .github/workflows/ci-release.yml | 88 +++++++++++------- 4 files changed, 211 insertions(+), 112 deletions(-) diff --git a/.github/workflows/ci-deploy.yml b/.github/workflows/ci-deploy.yml index 7a8e2abf467..52b57740981 100644 --- a/.github/workflows/ci-deploy.yml +++ b/.github/workflows/ci-deploy.yml @@ -54,11 +54,13 @@ jobs: name: stage-snapshot-${{ matrix.setup }} steps: - - uses: actions/checkout@v4 + # Pinned to v4.3.1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -76,8 +78,9 @@ jobs: - name: Stage snapshots to local staging directory run: docker compose ${{ matrix.docker-compose-run }} + # Pinned to v4.6.2 - name: Upload local staging directory - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ matrix.setup }}-local-staging path: ~/local-staging @@ -98,17 +101,20 @@ jobs: name: ${{ matrix.setup }} build steps: - - uses: actions/checkout@v4 + # Pinned to v4.3.1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + # Pinned to v4.8.0 - name: Set up JDK 8 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '8' # Cache .m2/repository - # Caching of maven dependencies - - uses: actions/cache@v4 + # Pinned to v4.3.0 + - name: Cache local Maven repository + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -126,8 +132,9 @@ jobs: - name: Stage snapshots to local staging directory run: ./mvnw -B -ntp clean package org.sonatype.plugins:nexus-staging-maven-plugin:deploy -DaltStagingDirectory=$HOME/local-staging -DskipRemoteStaging=true -DskipTests=true + # Pinned to v4.6.2 - name: Upload local staging directory - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ matrix.setup }}-local-staging path: ~/local-staging @@ -139,17 +146,17 @@ jobs: # Wait until we have staged everything needs: [ stage-snapshot-linux, stage-snapshot-macos ] steps: - - uses: actions/checkout@v4 - + # Pinned to v4.8.0 - name: Set up JDK 8 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '8' # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -158,7 +165,8 @@ jobs: cache-maven-${{ hashFiles('**/pom.xml') }} cache-maven- - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -174,32 +182,37 @@ jobs: # Hardcode the staging artifacts that need to be downloaded. # These must match the matrix setups. There is currently no way to pull this out of the config. + # Pinned to v4.3.0 - name: Download macos-aarch64-java8 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: macos-aarch64-java8-local-staging path: ~/macos-aarch64-java8-local-staging + # Pinned to v4.3.0 - name: Download macos-x86_64-java8 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: macos-x86_64-java8-local-staging path: ~/macos-x86_64-java8-local-staging + # Pinned to v4.3.0 - name: Download linux-aarch64 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-aarch64-local-staging path: ~/linux-aarch64-local-staging + # Pinned to v4.3.0 - name: Download linux-riscv64 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-riscv64-local-staging path: ~/linux-riscv64-local-staging + # Pinned to v4.3.0 - name: Download linux-x86_64-java8 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-x86_64-java8-local-staging path: ~/linux-x86_64-java8-local-staging diff --git a/.github/workflows/ci-release-4.2.yml b/.github/workflows/ci-release-4.2.yml index 51709846987..533391fc523 100644 --- a/.github/workflows/ci-release-4.2.yml +++ b/.github/workflows/ci-release-4.2.yml @@ -35,12 +35,14 @@ jobs: prepare-release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Pinned to v4.3.1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: 4.2 + # Pinned to v4.8.0 - name: Set up JDK 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '11' @@ -50,15 +52,17 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -75,8 +79,9 @@ jobs: - name: Checkout tag run: ./.github/scripts/release_checkout_tag.sh release.properties + # Pinned to v4.6.2 - name: Upload workspace - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: prepare-release-workspace path: | @@ -103,8 +108,9 @@ jobs: name: stage-release-${{ matrix.setup }} steps: + # Pinned to v4.3.0 - name: Download release-workspace - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: prepare-release-workspace path: ./prepare-release-workspace/ @@ -112,8 +118,9 @@ jobs: - name: Adjust mvnw permissions run: chmod 755 ./prepare-release-workspace/mvnw - - name: Set up JDK 11 - uses: actions/setup-java@v4 + # Pinned to v4.3.0 + - name: Download release-workspace + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: distribution: 'zulu' java-version: '11' @@ -123,13 +130,15 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -139,8 +148,9 @@ jobs: }] # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -164,8 +174,9 @@ jobs: GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: docker compose ${{ matrix.docker-compose-run }} - - name: Upload local staging directory - uses: actions/upload-artifact@v4 + # Pinned to v4.6.2 + - name: Upload workspace + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ matrix.setup }}-local-staging path: ./prepare-release-workspace/target/central-staging @@ -193,8 +204,9 @@ jobs: name: stage-release-${{ matrix.setup }} steps: + # Pinned to v4.3.0 - name: Download release-workspace - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: prepare-release-workspace path: ./prepare-release-workspace/ @@ -202,15 +214,17 @@ jobs: - name: Adjust mvnw permissions run: chmod 755 ./prepare-release-workspace/mvnw + # Pinned to v4.8.0 - name: Set up JDK 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '11' + # Pinned to v6.3.0 - name: Import GPG key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} @@ -220,13 +234,15 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -236,8 +252,9 @@ jobs: }] # Cache .m2/repository - # Caching of maven dependencies - - uses: actions/cache@v4 + # Pinned to v4.3.0 + - name: Cache local Maven repository + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -257,8 +274,9 @@ jobs: working-directory: ./prepare-release-workspace/ run: ./mvnw -B -ntp clean javadoc:jar package gpg:sign org.sonatype.central:central-publishing-maven-plugin:publish -DskipTests=true + # Pinned to v4.6.2 - name: Upload local staging directory - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ matrix.setup }}-local-staging path: ./prepare-release-workspace/target/central-staging @@ -280,15 +298,17 @@ jobs: # failures sometimes due the fact that not enough memory could be reserved. RUSTUP_UNPACK_RAM: 134217728 # Use 128 MiB steps: + # Pinned to v4.3.0 - name: Download release-workspace - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: prepare-release-workspace path: ${{ github.workspace }}/prepare-release-workspace + # Pinned to v6.3.0 - name: Import GPG key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} @@ -298,39 +318,46 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} + # Pinned to v4.8.0 - name: Set up JDK 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: 11 + # Pinned to 1.71.0 - name: Install stable rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@d36bfec312c4a8e1f20b70b99cdd6f73b7366bd0 with: targets: x86_64-pc-windows-msvc + # Pinned to v2 - name: Add msbuild to PATH - uses: microsoft/setup-msbuild@v2 + uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce + # Pinned to v1.13.0 - name: Configuring Developer Command Prompt - uses: ilammy/msvc-dev-cmd@v1 + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 with: arch: x86_amd64 + # Pinned to v3.4.0 - name: Install tools - uses: crazy-max/ghaction-chocolatey@v3 + uses: crazy-max/ghaction-chocolatey@2526f467ccbd337d307fe179959cabbeca0bc8c0 with: args: install ninja nasm # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -339,7 +366,8 @@ jobs: cache-windows-maven-${{ hashFiles('**/pom.xml') }} cache-windows-maven- - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -352,8 +380,9 @@ jobs: working-directory: ${{ github.workspace }}/prepare-release-workspace run: ./mvnw.cmd -B -ntp --file pom.xml clean javadoc:jar package gpg:sign org.sonatype.central:central-publishing-maven-plugin:publish -DskipTests=true -D'checkstyle.skip=true' + # Pinned to v4.6.2 - name: Upload local staging directory - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: windows-x86_64-local-staging path: ./prepare-release-workspace/target/central-staging @@ -372,7 +401,7 @@ jobs: needs: [ stage-release-linux, stage-release-macos, stage-release-windows] steps: - name: Download release-workspace - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: prepare-release-workspace path: ./prepare-release-workspace/ @@ -380,15 +409,17 @@ jobs: - name: Adjust mvnw permissions run: chmod 755 ./prepare-release-workspace/mvnw + # Pinned to v4.3.0 - name: Set up JDK 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '11' + # Pinned to v6.3.0 - name: Import GPG key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} @@ -398,13 +429,15 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -414,8 +447,9 @@ jobs: }] # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -426,38 +460,44 @@ jobs: # Hardcode the staging artifacts that need to be downloaded. # These must match the matrix setups. There is currently no way to pull this out of the config. + # Pinned to v4.3.0 - name: Download windows_x86_64 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: windows-x86_64-local-staging path: ~/windows-x86_64-local-staging + # Pinned to v4.3.0 - name: Download macos-aarch64-java11 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: macos-aarch64-java11-local-staging path: ~/macos-aarch64-java11-local-staging + # Pinned to v4.3.0 - name: Download macos-x86_64-java11 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: macos-x86_64-java11-local-staging path: ~/macos-x86_64-java11-local-staging + # Pinned to v4.3.0 - name: Download linux-aarch64 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-aarch64-local-staging path: ~/linux-aarch64-local-staging + # Pinned to v4.3.0 - name: Download linux-riscv64 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-riscv64-local-staging path: ~/linux-riscv64-local-staging + # Pinned to v4.3.0 - name: Download linux-x86_64-java11 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-x86_64-java11-local-staging path: ~/linux-x86_64-java11-local-staging diff --git a/.github/workflows/ci-release-5.yml b/.github/workflows/ci-release-5.yml index 9d741f15804..8ee4fd915c7 100644 --- a/.github/workflows/ci-release-5.yml +++ b/.github/workflows/ci-release-5.yml @@ -35,12 +35,14 @@ jobs: prepare-release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Pinned to v4.3.1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: main + # Pinned to v4.8.0 - name: Set up JDK 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '11' @@ -50,15 +52,17 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -75,8 +79,9 @@ jobs: - name: Checkout tag run: ./.github/scripts/release_checkout_tag.sh release.properties + # Pinned to v4.6.2 - name: Upload workspace - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: prepare-release-workspace path: | @@ -103,8 +108,9 @@ jobs: name: stage-release-${{ matrix.setup }} steps: + # Pinned to v4.3.0 - name: Download release-workspace - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: prepare-release-workspace path: ./prepare-release-workspace/ @@ -112,8 +118,9 @@ jobs: - name: Adjust mvnw permissions run: chmod 755 ./prepare-release-workspace/mvnw - - name: Set up JDK 11 - uses: actions/setup-java@v4 + # Pinned to v4.3.0 + - name: Download release-workspace + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: distribution: 'zulu' java-version: '11' @@ -123,13 +130,15 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -139,8 +148,9 @@ jobs: }] # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -164,8 +174,9 @@ jobs: GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: docker compose ${{ matrix.docker-compose-run }} - - name: Upload local staging directory - uses: actions/upload-artifact@v4 + # Pinned to v4.6.2 + - name: Upload workspace + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ matrix.setup }}-local-staging path: ~/local-staging @@ -192,8 +203,9 @@ jobs: - name: Adjust mvnw permissions run: chmod 755 ./prepare-release-workspace/mvnw + # Pinned to v4.8.0 - name: Set up JDK 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '11' @@ -203,28 +215,32 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} # Hardcode the staging artifacts that need to be downloaded. # These must match the matrix setups. There is currently no way to pull this out of the config. + # Pinned to v4.3.0 - name: Download linux-aarch64 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-aarch64-local-staging path: ~/linux-aarch64-local-staging + # Pinned to v4.3.0 - name: Download linux-riscv64 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-riscv64-local-staging path: ~/linux-riscv64-local-staging + # Pinned to v4.3.0 - name: Download linux-x86_64-java11 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-x86_64-java11-local-staging path: ~/linux-x86_64-java11-local-staging @@ -235,7 +251,8 @@ jobs: working-directory: ./prepare-release-workspace/ run: bash ./.github/scripts/merge_local_staging.sh /home/runner/local-staging/staging ~/linux-aarch64-local-staging/staging ~/linux-riscv64-local-staging/staging ~/linux-x86_64-java11-local-staging/staging - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -245,8 +262,9 @@ jobs: }] # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index c797a64f127..e33d2d376fd 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -35,12 +35,14 @@ jobs: prepare-release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Pinned to v4.3.1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: 4.1 + # Pinned to v4.8.0 - name: Set up JDK 8 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '8' @@ -50,15 +52,17 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -75,8 +79,9 @@ jobs: - name: Checkout tag run: ./.github/scripts/release_checkout_tag.sh release.properties + # Pinned to v4.6.2 - name: Upload workspace - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: prepare-release-workspace path: | @@ -103,8 +108,9 @@ jobs: name: stage-release-${{ matrix.setup }} steps: + # Pinned to v4.3.0 - name: Download release-workspace - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: prepare-release-workspace path: ./prepare-release-workspace/ @@ -112,8 +118,9 @@ jobs: - name: Adjust mvnw permissions run: chmod 755 ./prepare-release-workspace/mvnw + # Pinned to v4.8.0 - name: Set up JDK 8 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '8' @@ -123,13 +130,15 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -139,8 +148,9 @@ jobs: }] # Cache .m2/repository + # Pinned to v4.3.0 - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -164,8 +174,9 @@ jobs: GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: docker compose ${{ matrix.docker-compose-run }} - - name: Upload local staging directory - uses: actions/upload-artifact@v4 + # Pinned to v4.6.2 + - name: Upload workspace + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ matrix.setup }}-local-staging path: ./prepare-release-workspace/target/central-staging @@ -192,8 +203,9 @@ jobs: name: stage-release-${{ matrix.setup }} steps: + # Pinned to v4.3.0 - name: Download release-workspace - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: prepare-release-workspace path: ./prepare-release-workspace/ @@ -201,15 +213,17 @@ jobs: - name: Adjust mvnw permissions run: chmod 755 ./prepare-release-workspace/mvnw + # Pinned to v4.8.0 - name: Set up JDK 8 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '8' + # Pinned to v6.3.0 - name: Import GPG key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} @@ -219,13 +233,15 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -235,8 +251,9 @@ jobs: }] # Cache .m2/repository - # Caching of maven dependencies - - uses: actions/cache@v4 + # Pinned to v4.3.0 + - name: Cache local Maven repository + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 continue-on-error: true with: path: ~/.m2/repository @@ -256,8 +273,9 @@ jobs: working-directory: ./prepare-release-workspace/ run: ./mvnw -B -ntp clean javadoc:jar package gpg:sign org.sonatype.central:central-publishing-maven-plugin:publish -DskipTests=true + # Pinned to v4.6.2 - name: Upload local staging directory - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ matrix.setup }}-local-staging path: ./prepare-release-workspace/target/central-staging @@ -275,8 +293,9 @@ jobs: # Wait until we have staged everything needs: [ stage-release-linux, stage-release-macos ] steps: + # Pinned to v4.3.0 - name: Download release-workspace - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: prepare-release-workspace path: ./prepare-release-workspace/ @@ -284,15 +303,17 @@ jobs: - name: Adjust mvnw permissions run: chmod 755 ./prepare-release-workspace/mvnw + # Pinned to v4.8.0 - name: Set up JDK 8 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 with: distribution: 'zulu' java-version: '8' + # Pinned to v6.3.0 - name: Import GPG key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} @@ -302,13 +323,15 @@ jobs: git config --global user.email "netty-project-bot@users.noreply.github.com" git config --global user.name "Netty Project Bot" + # Pinned to v2.8.1 - name: Install SSH key - uses: shimataro/ssh-key-action@v2 + uses: shimataro/ssh-key-action@87a8f067114a8ce263df83e9ed5c849953548bc3 with: key: ${{ secrets.SSH_PRIVATE_KEY_PEM }} known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - - uses: s4u/maven-settings-action@v3.0.0 + # Pinned to v3.0.0 + - uses: s4u/maven-settings-action@7802f6aec16c9098b4798ad1f1d8ac75198194bd with: servers: | [{ @@ -319,32 +342,37 @@ jobs: # Hardcode the staging artifacts that need to be downloaded. # These must match the matrix setups. There is currently no way to pull this out of the config. + # Pinned to v4.3.0 - name: Download macos-aarch64-java8 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: macos-aarch64-java8-local-staging path: ~/macos-aarch64-java8-local-staging + # Pinned to v4.3.0 - name: Download macos-x86_64-java8 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: macos-x86_64-java8-local-staging path: ~/macos-x86_64-java8-local-staging + # Pinned to v4.3.0 - name: Download linux-aarch64 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-aarch64-local-staging path: ~/linux-aarch64-local-staging + # Pinned to v4.3.0 - name: Download linux-riscv64 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-riscv64-local-staging path: ~/linux-riscv64-local-staging + # Pinned to v4.3.0 - name: Download linux-x86_64-java8 staging directory - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: linux-x86_64-java8-local-staging path: ~/linux-x86_64-java8-local-staging From bb2ff68a1fb71cb4b0eb9a9e17b66c52aff680c6 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Wed, 8 Jul 2026 06:30:47 +0200 Subject: [PATCH 31/64] Merge branches from forks (#17063) (#17065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Stomp: Limit headers per frame 83ce0a8 Motivation: We need to enforce a lmit of headers per frame as otherwise a remote peer can flood us. Modifications: - Enforce limit of number of headers - Add unit test Result: Guard against high memory usage caused by frames with unbound number of headers --- DNS: Don't leak buffer in DNS Record Decoder via Malformed Domain Names 50649d7 Motivation: We missed to release allocated buffers in case of maformed domain names. Modifications: Correctly release buffers in all cases Result: No more memory leak on malformed domain names --- SPDY: Limit max number of settings per settings frame 0b0b899 Motivation: There was no real limit of how many settings a settings frame could include and so could result in very high memory usage on the receiver side. Modifications: - Add a default limit of 64 - Add unit test Result: Guard against high memory usage by default --- HaProxy: Correctly treat version as unsigned byte 94b04f7 Motivation: We did not correctly tread the version as unsigned byte which could cause us to buffer received bytes forever and so cause an OOME Modifications: - Correctly treat version as unsigned byte - Add unit test Result: Correctly detect invalid version and not buffer forever --- SPDY: Correctly release message once removed from internal storage 4ff0898 Motivation: We missed to release the stored FullHttpMessage in some cases which could result in a memory leak Modifications: - Add missing release calls - Also release when handler is removed before channel becomes inactive Result: No more leaks --- CORS: Correctly handle origin afca14b Motivation: Netty's CorsHandler provides a shortCircuit() configuration designed to reject unauthorized cross-origin requests immediately, acting as a security control before requests reach the application. However, due to a logical operator error in the origin evaluation process, this protection can be entirely bypassed. An attacker can bypass the short-circuit mechanism by sending a request with an Origin: null header. This failure forwards unauthorized requests to the backend application, bypassing intended access controls. Modifications: - Replace || with && Result: Fix bypass --- XML: Disable risky features when decoding XML ee10fe7 Motivation: We did not diable external entities, DTD and replacing of entity references when creating the AsyncXMLInputFactory, which means we were in the mercy of the defaults of aalto-xml. Modifications: - Set properties to disable risky features and so reduce risk when decoding XML via the network Result: Minimize risk when decoding XML from untrusted sources --- HTTP2: Don't leak buffer when using compression and the stream is clo… e9cec3f …sed while still decompress data Motivation: We need to ensure we not leak the buffer if the used EmbeddedChannel is already closed when trying to decompress data. This can happen if we receive an END_STREAM in between. Modifications: - Explicit check if the EmbeddedChannel is open before retain the buffer Result: No more leak --- Redis: Clear partial state and release messages before throwing excep… 5f5c65b …tion Motivation: We did not always clear the partial state and release nested message before throwing exception which could lead to unnessary memory usage until the channel is finally closed Modifications: Always clear partial state and release messages before throwing Result: Clear state and release memory in a timely manner when exception accours --- OCSP: Correctly validate cert id matches dec6f8e Motivation: We did miss to also validate the cert id and so could be affected by replay attacks Modifications: - Add validation of cert id - Add unit test Result: Correctly validate cert --- OCSP: TOCTOU in OcspServerCertificateValidator 1c7bf68 Motivation: Netty's OcspServerCertificateValidator forwards the SslHandshakeCompletionEvent before the asynchronous OCSP validation completes. This allows the client's downstream handlers to send sensitive application data (e.g., HTTP requests) to a revoked server before the channel is closed by the OCSP check. Modifications: - Correctly only fire the event after we did the ocsp check - Enforce some sort of timeout for the OCSP query - Buffer bytes until we were able to process the OCSP query - Add missing early return Result: Only fire event / forward data after the OCSP processing is done --- Improve OCSP response validity-window handling in OcspServerCertifica… e091e0f …teValidator Motivation: The thisUpdate / nextUpdate fields of an OCSP response are optional, and the freshness check did not always stop processing a response that failed the window check. Modifications: Handle absent thisUpdate / nextUpdate and return once an out-of-date response is detected. Result: More robust and consistent OCSP response validity-window handling. --- Validate STOMP CONNECT/CONNECTED command headers 536d64a Motivation: The CONNECT/CONNECTED command headers do not support escaping, but we still need to validate that they contain no illegal characters. Additionally, the NUL ascii character has no escape sequence and is always illegal. The only normally-escaped character that CONNECT/CONNECTED commands pass through raw is the backslash. Modification: - Update the test to match the specification exactly. - Add validation to CONNECT/CONNECTED command headers. - Add a throws case in `escape` for the NUL character. Result: It's no longer possible to inject headers or smuggle commands through STOMP CONNECT/CONNECTED headers. --- Add multipart filename validation 15c62b0 Motivation: The rules for encoding filenames in `multipart/form-data` means we have to avoid certain characters to prevent parser-desync problems, and to ensure spec compliance. Modification: - The DiskFileUpload and MemoryFileUpload constructor and setFilename methods now validate that the given filename is safe to be included verbatim into the multipart filename field, and will throw an exception if not. - The HttpPostMultipartRequestDecoder now nerfs all illegal characters - even those delivered through percent encoding - to prevent round-trip validation errors. Result: Parser-desync and smuggling through the multipart/form-data filename field is no longer possible. --- HAProxy: Reject illegal characters in UNIX socket files 409621c Motivation: The PROXY V1 protocol for HAProxy uses CR LF bytes as header delimiters, and space as header field delimiters. This means these characters are not allowed within the fields, and must be rejected. The V2 protocol has no such problem because it uses a TLV binary encoding. Modification: Add checks and throw an exception from the HAProxyMessage constructor if AF_UNIX socket addresses contain CR, LF, or space, and the protocol version is V1. Add tests to verify the exception is thrown on V1, and not on V2. Result: PROXY field injection through AF_UNIX socket filenames is prevented. --- Bzip2: Correctly detect malformated stream and not infinite loop 0b7fb9b Motivation: Due a bug we could end up in an infinite loop while read from the bzip2 stream Modifications: - Correctly detect malformated stream and throw exception - Add unit tests Result: Correctly throw exception when malformated stream is detected --- HTTP/2: Lack of Host Header Deduplication in HTTP/2→HTTP/1.x Translat… 0ff87c6 …ion Leads to Request Routing Bypass Motivation: Netty's HTTP/2-to-HTTP/1.x translation layer (`Http2StreamFrameToHttpObjectCodec` and `InboundHttp2ToHttpAdapter`) fails to deduplicate or validate `Host` headers when an HTTP/2 client supplies both the `:authority` pseudo-header and a literal `host` header in a single HEADERS frame. The translator maps `:authority` to `Host` and separately copies the literal `host` header, producing an `HttpRequest` object containing two `Host` headers with attacker-controlled differing values. Modifications: - Detect duplicated host headers and if detected throw stream error - add unit test Result: Correctly detect invalid headers during translation --- HTTP: Enforce pipeline limit in HttpContentEncoder 1d5cdac Motivation: We did not enforce any pipeline limit and so it was possible for a remote peer to cause A DOS Modifications: - Add constructor that allows to set a limit (use default of 128). - Add unit test Result: Guard against DOS caused by concurrent pipelined requests --- SPDY: Limit the maximum number of bytes that can be decompressed per … 69562fa …header block. Motivation: We should limit the number of bytes that can be dcompressed per header block to guard against DOS attacks Modifications: - Add some limit - Add unit tests Result: Limit the number of bytes for compressed header blocks --- Avoid repeated closing-tag scans in XmlFrameDecoder efb0044 Motivation: XmlFrameDecoder scanned forward from every closing tag start to find a terminating '>'. Repeated malformed closing tags could therefore force repeated scans over the cumulated buffer. Modification: Track a pending closing tag in the main decode loop, reject nested '<' before the closing tag terminator, and cover malformed repeated closing tags plus valid split closing tags in tests. Result: Malformed repeated closing tags fail fast while valid split closing tags continue to frame correctly. --- WebSockets: V07/V08 handshaker missing Connection/Upgrade validation 52addff Motivation: An attacker can force WebSocket upgrade via the lax V07 (or V08) handshaker by sending Sec-WebSocket-Version: 7 and omitting Connection: Upgrade / Upgrade: websocket headers, completing a protocol switch that a proxy would not recognize as an Upgrade request and enabling HTTP request smuggling / protocol-confusion attacks. Modifications: - Add header checks - Add unit testing Result: Correctly validate headers for V07 and V08 --------- Co-authored-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Co-authored-by: yawkat --- .../codec/dns/DefaultDnsRecordDecoder.java | 38 ++++- .../netty/handler/codec/dns/DnsCodecUtil.java | 8 +- .../handler/codec/haproxy/HAProxyMessage.java | 22 ++- .../codec/haproxy/HAProxyMessageDecoder.java | 2 +- .../haproxy/HAProxyMessageDecoderTest.java | 13 ++ .../haproxy/HaProxyMessageEncoderTest.java | 40 +++++ .../handler/codec/http/HttpConstants.java | 10 ++ .../codec/http/HttpContentEncoder.java | 13 ++ .../handler/codec/http/cors/CorsHandler.java | 2 +- .../codec/http/multipart/DiskFileUpload.java | 2 +- .../codec/http/multipart/FileUpload.java | 5 +- .../codec/http/multipart/FileUploadUtil.java | 23 +++ .../HttpPostMultipartRequestDecoder.java | 9 +- .../http/multipart/MemoryFileUpload.java | 2 +- .../WebSocketServerHandshaker07.java | 12 +- .../WebSocketServerHandshaker08.java | 11 +- .../WebSocketServerHandshaker13.java | 4 +- .../handler/codec/spdy/SpdyFrameDecoder.java | 15 ++ .../spdy/SpdyHeaderBlockZlibDecoder.java | 21 +++ .../handler/codec/spdy/SpdyHttpDecoder.java | 70 ++++++-- .../codec/http/HttpContentEncoderTest.java | 25 +++ .../codec/http/cors/CorsHandlerTest.java | 18 ++ .../http/multipart/DiskFileUploadTest.java | 31 ++++ .../HttpPostMultiPartRequestDecoderTest.java | 44 +++++ .../http/multipart/MemoryFileUploadTest.java | 31 ++++ .../WebSocketServerHandshaker00Test.java | 15 ++ .../WebSocketServerHandshaker13Test.java | 81 --------- .../WebSocketServerHandshakerTest.java | 78 +++++++++ .../codec/spdy/SpdyFrameDecoderTest.java | 26 +++ .../spdy/SpdyHeaderBlockZlibDecoderTest.java | 70 ++++++++ .../DelegatingDecompressorFrameListener.java | 11 ++ .../codec/http2/HttpConversionUtil.java | 18 ++ .../codec/http2/HttpConversionUtilTest.java | 32 ++++ .../codec/redis/RedisArrayAggregator.java | 12 +- .../codec/stomp/StompSubframeDecoder.java | 25 ++- .../codec/stomp/StompSubframeEncoder.java | 46 ++++- .../codec/stomp/StompSubframeDecoderTest.java | 34 ++++ .../codec/stomp/StompSubframeEncoderTest.java | 119 ++++++++++++- codec-xml/pom.xml | 1 - .../netty/handler/codec/xml/XmlDecoder.java | 13 +- .../compression/Bzip2BlockDecompressor.java | 3 + .../handler/codec/xml/XmlFrameDecoder.java | 42 +++-- .../codec/compression/Bzip2DecoderTest.java | 93 +++++++++++ .../codec/xml/XmlFrameDecoderTest.java | 84 ++++++++++ .../io/netty/handler/ssl/ocsp/OcspClient.java | 65 ++++++-- .../handler/ssl/ocsp/OcspHttpHandler.java | 58 ++++++- .../ocsp/OcspServerCertificateValidator.java | 157 +++++++++++++----- .../handler/ssl/ocsp/OcspClientTest.java | 5 +- pom.xml | 53 ++++++ 49 files changed, 1391 insertions(+), 221 deletions(-) diff --git a/codec-dns/src/main/java/io/netty/handler/codec/dns/DefaultDnsRecordDecoder.java b/codec-dns/src/main/java/io/netty/handler/codec/dns/DefaultDnsRecordDecoder.java index 80cf862ab6a..d0fe6bc6296 100644 --- a/codec-dns/src/main/java/io/netty/handler/codec/dns/DefaultDnsRecordDecoder.java +++ b/codec-dns/src/main/java/io/netty/handler/codec/dns/DefaultDnsRecordDecoder.java @@ -16,7 +16,6 @@ package io.netty.handler.codec.dns; import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; import io.netty.handler.codec.CorruptedFrameException; /** @@ -97,9 +96,17 @@ protected DnsRecord decodeRecord( name, dnsClass, timeToLive, decodeName0(in.duplicate().setIndex(offset, offset + length))); } if (type == DnsRecordType.CNAME || type == DnsRecordType.NS) { - return new DefaultDnsRawRecord(name, type, dnsClass, timeToLive, - DnsCodecUtil.decompressDomainName( - in.duplicate().setIndex(offset, offset + length))); + ByteBuf decompressed = DnsCodecUtil.decompressDomainName( + in.duplicate().setIndex(offset, offset + length)); + try { + DnsRecord record = new DefaultDnsRawRecord(name, type, dnsClass, timeToLive, decompressed); + decompressed = null; + return record; + } finally { + if (decompressed != null) { + decompressed.release(); + } + } } if (type == DnsRecordType.MX) { // MX RDATA: 16-bit preference + exchange (domain name, possibly compressed) @@ -108,25 +115,40 @@ protected DnsRecord decodeRecord( } final int pref = in.getUnsignedShort(offset); ByteBuf exchange = null; + ByteBuf out = null; try { exchange = DnsCodecUtil.decompressDomainName( in.duplicate().setIndex(offset + 2, offset + length)); // Build decompressed RDATA = [preference][expanded exchange name] - final ByteBuf out = in.alloc().buffer(2 + exchange.readableBytes()); + out = in.alloc().buffer(2 + exchange.readableBytes()); out.writeShort(pref); out.writeBytes(exchange); - return new DefaultDnsRawRecord(name, type, dnsClass, timeToLive, out); + DnsRecord record = new DefaultDnsRawRecord(name, type, dnsClass, timeToLive, out); + out = null; + return record; } finally { if (exchange != null) { exchange.release(); } + if (out != null) { + out.release(); + } } } - return new DefaultDnsRawRecord( - name, type, dnsClass, timeToLive, in.retainedDuplicate().setIndex(offset, offset + length)); + ByteBuf content = in.retainedDuplicate(); + try { + content.setIndex(offset, offset + length); + DnsRecord record = new DefaultDnsRawRecord(name, type, dnsClass, timeToLive, content); + content = null; + return record; + } finally { + if (content != null) { + content.release(); + } + } } /** diff --git a/codec-dns/src/main/java/io/netty/handler/codec/dns/DnsCodecUtil.java b/codec-dns/src/main/java/io/netty/handler/codec/dns/DnsCodecUtil.java index 3e1d6b1a868..fa8fd191b04 100644 --- a/codec-dns/src/main/java/io/netty/handler/codec/dns/DnsCodecUtil.java +++ b/codec-dns/src/main/java/io/netty/handler/codec/dns/DnsCodecUtil.java @@ -21,6 +21,7 @@ import io.netty.handler.codec.CorruptedFrameException; import io.netty.handler.codec.TooLongFrameException; import io.netty.util.CharsetUtil; +import io.netty.util.internal.PlatformDependent; import static io.netty.handler.codec.dns.DefaultDnsRecordDecoder.*; @@ -153,7 +154,12 @@ static String decodeDomainName(ByteBuf in) { static ByteBuf decompressDomainName(ByteBuf compression) { String domainName = decodeDomainName(compression); ByteBuf result = compression.alloc().buffer(domainName.length() << 1); - encodeDomainName(domainName, result); + try { + encodeDomainName(domainName, result); + } catch (Throwable cause) { + result.release(); + PlatformDependent.throwException(cause); + } return result; } } diff --git a/codec-haproxy/src/main/java/io/netty/handler/codec/haproxy/HAProxyMessage.java b/codec-haproxy/src/main/java/io/netty/handler/codec/haproxy/HAProxyMessage.java index b6c47663fbb..fbd0ef2ba22 100644 --- a/codec-haproxy/src/main/java/io/netty/handler/codec/haproxy/HAProxyMessage.java +++ b/codec-haproxy/src/main/java/io/netty/handler/codec/haproxy/HAProxyMessage.java @@ -101,8 +101,8 @@ public HAProxyMessage( ObjectUtil.checkNotNull(tlvs, "tlvs"); AddressFamily addrFamily = proxiedProtocol.addressFamily(); - checkAddress(sourceAddress, addrFamily); - checkAddress(destinationAddress, addrFamily); + checkAddress(sourceAddress, addrFamily, protocolVersion); + checkAddress(destinationAddress, addrFamily, protocolVersion); checkPort(sourcePort, addrFamily); checkPort(destinationPort, addrFamily); @@ -470,11 +470,12 @@ private static int portStringToInt(String value) { /** * Validate an address (IPv4, IPv6, Unix Socket) * - * @param address human-readable address - * @param addrFamily the {@link AddressFamily} to check the address against - * @throws IllegalArgumentException if the address is invalid + * @param address human-readable address + * @param addrFamily the {@link AddressFamily} to check the address against + * @param version the protocol version + * @throws IllegalArgumentException if the address is invalid */ - private static void checkAddress(String address, AddressFamily addrFamily) { + private static void checkAddress(String address, AddressFamily addrFamily, HAProxyProtocolVersion version) { ObjectUtil.checkNotNull(addrFamily, "addrFamily"); switch (addrFamily) { @@ -488,6 +489,15 @@ private static void checkAddress(String address, AddressFamily addrFamily) { if (address.getBytes(CharsetUtil.US_ASCII).length > 108) { throw new IllegalArgumentException("invalid AF_UNIX address: " + address); } + if (version == HAProxyProtocolVersion.V1) { + // V1 is text-based and uses CR LF as header delimiters, and space as field delimiter. + for (int i = 0, len = address.length(); i < len; i++) { + char c = address.charAt(i); + if (c == '\r' || c == '\n' || c == ' ') { + throw new IllegalArgumentException("invalid AF_UNIX address: " + address); + } + } + } return; } diff --git a/codec-haproxy/src/main/java/io/netty/handler/codec/haproxy/HAProxyMessageDecoder.java b/codec-haproxy/src/main/java/io/netty/handler/codec/haproxy/HAProxyMessageDecoder.java index e6ee6b318f5..6a329802797 100644 --- a/codec-haproxy/src/main/java/io/netty/handler/codec/haproxy/HAProxyMessageDecoder.java +++ b/codec-haproxy/src/main/java/io/netty/handler/codec/haproxy/HAProxyMessageDecoder.java @@ -173,7 +173,7 @@ private static int findVersion(final ByteBuf buffer) { } int idx = buffer.readerIndex(); - return match(BINARY_PREFIX, buffer, idx) ? buffer.getByte(idx + BINARY_PREFIX_LENGTH) : 1; + return match(BINARY_PREFIX, buffer, idx) ? buffer.getUnsignedByte(idx + BINARY_PREFIX_LENGTH) : 1; } /** diff --git a/codec-haproxy/src/test/java/io/netty/handler/codec/haproxy/HAProxyMessageDecoderTest.java b/codec-haproxy/src/test/java/io/netty/handler/codec/haproxy/HAProxyMessageDecoderTest.java index fd47d416b53..92d66200f4e 100644 --- a/codec-haproxy/src/test/java/io/netty/handler/codec/haproxy/HAProxyMessageDecoderTest.java +++ b/codec-haproxy/src/test/java/io/netty/handler/codec/haproxy/HAProxyMessageDecoderTest.java @@ -1291,6 +1291,19 @@ public void testDetectProtocol() { incompleteHeader.release(); } + @Test + public void testInvalidProtocolUnsigned() { + final ByteBuf invalidData = buffer().writeBytes( + new byte[] { 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A, (byte) 0xFF }); + invalidData.writeZero(64); + assertThrows(HAProxyProtocolException.class, new Executable() { + @Override + public void execute() throws Throwable { + ch.writeInbound(invalidData); + } + }); + } + @Test public void testNestedTLV() throws Exception { ByteArrayOutputStream headerWriter = new ByteArrayOutputStream(); diff --git a/codec-haproxy/src/test/java/io/netty/handler/codec/haproxy/HaProxyMessageEncoderTest.java b/codec-haproxy/src/test/java/io/netty/handler/codec/haproxy/HaProxyMessageEncoderTest.java index 674a49cc8c6..1d83abad354 100644 --- a/codec-haproxy/src/test/java/io/netty/handler/codec/haproxy/HaProxyMessageEncoderTest.java +++ b/codec-haproxy/src/test/java/io/netty/handler/codec/haproxy/HaProxyMessageEncoderTest.java @@ -25,6 +25,8 @@ import io.netty.util.CharsetUtil; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.util.ArrayList; import java.util.Collections; @@ -400,6 +402,44 @@ public void execute() { }); } + @ParameterizedTest + @ValueSource(chars = {'\r', '\n', ' '}) + public void testIllegalCharacterInV1UnixAddress(char illegal) { + final String invalidUnixAddress = "/var/run/dst" + illegal + ".sock"; + final String fineUnixAddress = "/var/run/dst.sock"; + assertThrows(IllegalArgumentException.class, new Executable() { + @Override + public void execute() { + new HAProxyMessage( + HAProxyProtocolVersion.V1, HAProxyCommand.PROXY, HAProxyProxiedProtocol.UNIX_STREAM, + invalidUnixAddress, fineUnixAddress, 0, 0); + } + }); + assertThrows(IllegalArgumentException.class, new Executable() { + @Override + public void execute() { + new HAProxyMessage( + HAProxyProtocolVersion.V1, HAProxyCommand.PROXY, HAProxyProxiedProtocol.UNIX_STREAM, + fineUnixAddress, invalidUnixAddress, 0, 0); + } + }); + } + + @ParameterizedTest + @ValueSource(chars = {'\r', '\n', ' '}) + public void testIllegalV1UnixCharactersAreFineInV2(char illegal) { + final String suspectUnixAddress = "/var/run/dst" + illegal + ".sock"; + final String fineUnixAddress = "/var/run/dst.sock"; + new HAProxyMessage( + HAProxyProtocolVersion.V2, HAProxyCommand.PROXY, HAProxyProxiedProtocol.UNIX_STREAM, + suspectUnixAddress, fineUnixAddress, 0, 0) + .release(); + new HAProxyMessage( + HAProxyProtocolVersion.V2, HAProxyCommand.PROXY, HAProxyProxiedProtocol.UNIX_STREAM, + fineUnixAddress, suspectUnixAddress, 0, 0) + .release(); + } + @Test public void testNullUnixAddress() { assertThrows(NullPointerException.class, new Executable() { diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpConstants.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpConstants.java index 9bb1f70554f..5e8a04ee881 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpConstants.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpConstants.java @@ -66,6 +66,16 @@ public final class HttpConstants { */ public static final byte DOUBLE_QUOTE = '"'; + /** + * Backslash '\' + */ + public static final byte BACKSLASH = '\\'; + + /** + * ASCII DEL character code + */ + public static final byte DEL = 0x7F; + /** * Default character set (UTF-8) */ diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentEncoder.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentEncoder.java index 3899c81da42..a7b8f6d5c96 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentEncoder.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentEncoder.java @@ -65,10 +65,20 @@ private enum State { private static final CharSequence ZERO_LENGTH_HEAD = "HEAD"; private static final CharSequence ZERO_LENGTH_CONNECT = "CONNECT"; + private final int maxPipelineDepth; private final Queue acceptEncodingQueue = new ArrayDeque(); private EmbeddedChannel encoder; private State state = State.AWAIT_HEADERS; + public HttpContentEncoder() { + this(128); + } + + public HttpContentEncoder(int maxPipelineDepth) { + super(HttpRequest.class, HttpObject.class); + this.maxPipelineDepth = ObjectUtil.checkPositive(maxPipelineDepth, "maxPipelineDepth"); + } + @Override public boolean acceptOutboundMessage(Object msg) throws Exception { return msg instanceof HttpContent || msg instanceof HttpResponse; @@ -76,6 +86,9 @@ public boolean acceptOutboundMessage(Object msg) throws Exception { @Override protected void decode(ChannelHandlerContext ctx, HttpRequest msg, List out) throws Exception { + if (maxPipelineDepth <= acceptEncodingQueue.size()) { + throw new IllegalStateException("maxPipelineDepth exceeded: " + maxPipelineDepth); + } CharSequence acceptEncoding; List acceptEncodingHeaders = msg.headers().getAll(ACCEPT_ENCODING); switch (acceptEncodingHeaders.size()) { diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java index 45d5e0ffb13..6972647a071 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java @@ -155,7 +155,7 @@ private CorsConfig getForOrigin(String requestOrigin) { if (corsConfig.origins().contains(requestOrigin)) { return corsConfig; } - if (corsConfig.isNullOriginAllowed() || NULL_ORIGIN.equals(requestOrigin)) { + if (corsConfig.isNullOriginAllowed() && NULL_ORIGIN.equals(requestOrigin)) { return corsConfig; } } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/DiskFileUpload.java b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/DiskFileUpload.java index 0740a922d2d..a6db7f2a63c 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/DiskFileUpload.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/DiskFileUpload.java @@ -75,7 +75,7 @@ public String getFilename() { @Override public void setFilename(String filename) { - this.filename = ObjectUtil.checkNotNull(filename, "filename"); + this.filename = FileUploadUtil.validateFileNameForMultiPart(filename); } @Override diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/FileUpload.java b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/FileUpload.java index 35b97411f91..bebbf97a76a 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/FileUpload.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/FileUpload.java @@ -31,7 +31,10 @@ public interface FileUpload extends HttpData { String getFilename(); /** - * Set the original filename + * Set the original filename. + *

+ * Note: This method validates that the filename is safe for including in an HTTP request, + * and will throw an exception if that's not the case. */ void setFilename(String filename); diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/FileUploadUtil.java b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/FileUploadUtil.java index 6fa8131f607..87bd71108ee 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/FileUploadUtil.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/FileUploadUtil.java @@ -15,6 +15,9 @@ */ package io.netty.handler.codec.http.multipart; +import io.netty.handler.codec.http.HttpConstants; +import io.netty.util.internal.ObjectUtil; + final class FileUploadUtil { private FileUploadUtil() { } @@ -30,4 +33,24 @@ static boolean equals(FileUpload upload1, FileUpload upload2) { static int compareTo(FileUpload upload1, FileUpload upload2) { return upload1.getName().compareToIgnoreCase(upload2.getName()); } + + /** + * Control characters, the DEL character, double-quote, and backslash are either disallowed or strongly discouraged, + * depending on which {@code multipart/form-data} specification you read. + * This method conservatively rejects all of them, and is used for outbound (encoding) filenames. + * @param filename The filename to check. + * @return The validated filename, unchanged. + */ + static String validateFileNameForMultiPart(String filename) { + int length = ObjectUtil.checkNotNull(filename, "filename").length(); + for (int i = 0; i < length; i++) { + char c = filename.charAt(i); + if (c < HttpConstants.SP /*control character block*/ || c == HttpConstants.DEL || + c == HttpConstants.DOUBLE_QUOTE || c == HttpConstants.BACKSLASH) { + throw new IllegalArgumentException( + String.format("Illegal filename character 0x%02x at index %d", (int) c, i)); + } + } + return filename; + } } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/HttpPostMultipartRequestDecoder.java b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/HttpPostMultipartRequestDecoder.java index 14962c7cd96..70d4cd76b6a 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/HttpPostMultipartRequestDecoder.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/HttpPostMultipartRequestDecoder.java @@ -1309,10 +1309,15 @@ private static String cleanString(String field) { sb.append(HttpConstants.SP_CHAR); break; case HttpConstants.DOUBLE_QUOTE: - // nothing added, just removes it + case HttpConstants.BACKSLASH: + // nothing added, just removes double quote and backslash break; default: - sb.append(nextChar); + if (nextChar < HttpConstants.SP || nextChar == HttpConstants.DEL) { + sb.append(HttpConstants.SP_CHAR); + } else { + sb.append(nextChar); + } break; } } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/MemoryFileUpload.java b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/MemoryFileUpload.java index de1cbe25d0c..c711b109c88 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/multipart/MemoryFileUpload.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/multipart/MemoryFileUpload.java @@ -57,7 +57,7 @@ public String getFilename() { @Override public void setFilename(String filename) { - this.filename = ObjectUtil.checkNotNull(filename, "filename"); + this.filename = FileUploadUtil.validateFileNameForMultiPart(filename); } @Override diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker07.java b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker07.java index eee7636ecfa..8c1168186a3 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker07.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker07.java @@ -134,8 +134,16 @@ protected FullHttpResponse newHandshakeResponse(FullHttpRequest req, HttpHeaders if (!GET.equals(method)) { throw new WebSocketServerHandshakeException("Invalid WebSocket handshake method: " + method, req); } - - CharSequence key = req.headers().get(HttpHeaderNames.SEC_WEBSOCKET_KEY); + HttpHeaders reqHeaders = req.headers(); + if (!reqHeaders.containsValue(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE, true)) { + throw new WebSocketServerHandshakeException( + "not a WebSocket request: a |Connection| header must include a token 'Upgrade'", req); + } + if (!reqHeaders.contains(HttpHeaderNames.UPGRADE, HttpHeaderValues.WEBSOCKET, true)) { + throw new WebSocketServerHandshakeException( + "not a WebSocket request: an |Upgrade| header must containing the value 'websocket'", req); + } + CharSequence key = reqHeaders.get(HttpHeaderNames.SEC_WEBSOCKET_KEY); if (key == null) { throw new WebSocketServerHandshakeException("not a WebSocket request: missing key", req); } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker08.java b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker08.java index 3c2a9bdece7..cd798671e5e 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker08.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker08.java @@ -142,7 +142,16 @@ protected FullHttpResponse newHandshakeResponse(FullHttpRequest req, HttpHeaders throw new WebSocketServerHandshakeException("Invalid WebSocket handshake method: " + method, req); } - CharSequence key = req.headers().get(HttpHeaderNames.SEC_WEBSOCKET_KEY); + HttpHeaders reqHeaders = req.headers(); + if (!reqHeaders.containsValue(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE, true)) { + throw new WebSocketServerHandshakeException( + "not a WebSocket request: a |Connection| header must include a token 'Upgrade'", req); + } + if (!reqHeaders.contains(HttpHeaderNames.UPGRADE, HttpHeaderValues.WEBSOCKET, true)) { + throw new WebSocketServerHandshakeException( + "not a WebSocket request: an |Upgrade| header must containing the value 'websocket'", req); + } + CharSequence key = reqHeaders.get(HttpHeaderNames.SEC_WEBSOCKET_KEY); if (key == null) { throw new WebSocketServerHandshakeException("not a WebSocket request: missing key", req); } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker13.java b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker13.java index 153bc649206..0f76e4a46d9 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker13.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker13.java @@ -145,12 +145,12 @@ protected FullHttpResponse newHandshakeResponse(FullHttpRequest req, HttpHeaders if (!reqHeaders.contains(HttpHeaderNames.CONNECTION) || !reqHeaders.containsValue(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE, true)) { throw new WebSocketServerHandshakeException( - "not a WebSocket request: a |Connection| header must includes a token 'Upgrade'", req); + "not a WebSocket request: a |Connection| header must include a token 'Upgrade'", req); } if (!reqHeaders.contains(HttpHeaderNames.UPGRADE, HttpHeaderValues.WEBSOCKET, true)) { throw new WebSocketServerHandshakeException( - "not a WebSocket request: a |Upgrade| header must containing the value 'websocket'", req); + "not a WebSocket request: an |Upgrade| header must containing the value 'websocket'", req); } CharSequence key = reqHeaders.get(HttpHeaderNames.SEC_WEBSOCKET_KEY); diff --git a/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyFrameDecoder.java b/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyFrameDecoder.java index 25a16c2330e..bbb4ebf9878 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyFrameDecoder.java +++ b/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyFrameDecoder.java @@ -50,7 +50,9 @@ public class SpdyFrameDecoder { protected final SpdyFrameDecoderDelegate delegate; protected final int spdyVersion; + static final int DEFAULT_MAX_NUM_SETTINGS = 64; private final int maxChunkSize; + private final int maxNumSettings; private int frameType; private State state; @@ -92,9 +94,18 @@ public SpdyFrameDecoder(SpdyVersion spdyVersion, SpdyFrameDecoderDelegate delega * Creates a new instance with the specified parameters. */ public SpdyFrameDecoder(SpdyVersion spdyVersion, SpdyFrameDecoderDelegate delegate, int maxChunkSize) { + this(spdyVersion, delegate, maxChunkSize, DEFAULT_MAX_NUM_SETTINGS); + } + + /** + * Creates a new instance with the specified parameters. + */ + public SpdyFrameDecoder(SpdyVersion spdyVersion, SpdyFrameDecoderDelegate delegate, + int maxChunkSize, int maxNumSettings) { this.spdyVersion = ObjectUtil.checkNotNull(spdyVersion, "spdyVersion").version(); this.delegate = ObjectUtil.checkNotNull(delegate, "delegate"); this.maxChunkSize = ObjectUtil.checkPositive(maxChunkSize, "maxChunkSize"); + this.maxNumSettings = ObjectUtil.checkPositive(maxNumSettings, "maxNumSettings"); state = State.READ_COMMON_HEADER; } @@ -249,6 +260,10 @@ public void decode(ByteBuf buffer) { if ((length & 0x07) != 0 || length >> 3 != numSettings) { state = State.FRAME_ERROR; delegate.readFrameError("Invalid SETTINGS Frame"); + } else if (numSettings > maxNumSettings) { + state = State.FRAME_ERROR; + delegate.readFrameError("Invalid SETTINGS Frame (allowed number of settings exceeded: " + + numSettings + " > " + maxNumSettings + ')'); } else { state = State.READ_SETTING; delegate.readSettingsFrame(clear); diff --git a/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyHeaderBlockZlibDecoder.java b/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyHeaderBlockZlibDecoder.java index 2db45ed7630..ad59f464de4 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyHeaderBlockZlibDecoder.java +++ b/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyHeaderBlockZlibDecoder.java @@ -29,12 +29,27 @@ final class SpdyHeaderBlockZlibDecoder extends SpdyHeaderBlockRawDecoder { private static final SpdyProtocolException INVALID_HEADER_BLOCK = new SpdyProtocolException("Invalid Header Block"); + // Legitimate header blocks are bounded by maxHeaderSize, but framing overhead (the header + // count and the per-header name/value length prefixes) means the decompressed size of a + // wire-legitimate header block can exceed maxHeaderSize itself. A generous multiplier is used + // instead of an equal cap so this never rejects well-formed header blocks, while still + // preventing a maliciously compressible header block from forcing an effectively unbounded + // number of Inflater#inflate() calls on the calling (event-loop) thread. + private static final int MAX_DECOMPRESSED_SIZE_MULTIPLIER = 16; + private final Inflater decompressor = new Inflater(); + private final long maxDecompressedSize; private ByteBuf decompressed; + private long totalInflated; SpdyHeaderBlockZlibDecoder(SpdyVersion spdyVersion, int maxHeaderSize) { super(spdyVersion, maxHeaderSize); + maxDecompressedSize = calculateMaxDecompressedSizePerBlock(maxHeaderSize); + } + + static long calculateMaxDecompressedSizePerBlock(int maxHeaderSize) { + return (long) maxHeaderSize * MAX_DECOMPRESSED_SIZE_MULTIPLIER; } @Override @@ -44,6 +59,11 @@ void decode(ByteBufAllocator alloc, ByteBuf headerBlock, SpdyHeadersFrame frame) int numBytes; do { numBytes = decompress(alloc, frame); + totalInflated += numBytes; + if (totalInflated > maxDecompressedSize) { + throw new SpdyProtocolException( + "Decompressed header block exceeds " + maxDecompressedSize + " bytes"); + } } while (numBytes > 0); // z_stream has an internal 64-bit hold buffer @@ -107,6 +127,7 @@ private void ensureBuffer(ByteBufAllocator alloc) { void endHeaderBlock(SpdyHeadersFrame frame) throws Exception { super.endHeaderBlock(frame); releaseBuffer(); + totalInflated = 0; } @Override diff --git a/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyHttpDecoder.java b/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyHttpDecoder.java index 31c37df0788..cacdf7c39ce 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyHttpDecoder.java +++ b/codec-http/src/main/java/io/netty/handler/codec/spdy/SpdyHttpDecoder.java @@ -139,13 +139,13 @@ protected SpdyHttpDecoder(SpdyVersion version, int maxContentLength, Map entry : messageMap.entrySet()) { ReferenceCountUtil.safeRelease(entry.getValue()); } messageMap.clear(); - super.channelInactive(ctx); + super.handlerRemoved(ctx); } protected FullHttpMessage putMessage(int streamId, FullHttpMessage message) { @@ -201,8 +201,9 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out return; } + FullHttpRequest httpRequestWithEntity = null; try { - FullHttpRequest httpRequestWithEntity = createHttpRequest(spdySynStreamFrame, ctx.alloc()); + httpRequestWithEntity = createHttpRequest(spdySynStreamFrame, ctx.alloc()); // Set the Stream-ID, Associated-To-Stream-ID, and Priority as headers httpRequestWithEntity.headers().setInt(Names.STREAM_ID, streamId); @@ -210,8 +211,11 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out httpRequestWithEntity.headers().setInt(Names.PRIORITY, spdySynStreamFrame.priority()); out.add(httpRequestWithEntity); - + httpRequestWithEntity = null; } catch (Throwable ignored) { + if (httpRequestWithEntity != null) { + httpRequestWithEntity.release(); + } SpdyRstStreamFrame spdyRstStreamFrame = new DefaultSpdyRstStreamFrame(streamId, SpdyStreamStatus.PROTOCOL_ERROR); ctx.writeAndFlush(spdyRstStreamFrame); @@ -231,19 +235,28 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out return; } + FullHttpRequest httpRequestWithEntity = null; try { - FullHttpRequest httpRequestWithEntity = createHttpRequest(spdySynStreamFrame, ctx.alloc()); + httpRequestWithEntity = createHttpRequest(spdySynStreamFrame, ctx.alloc()); // Set the Stream-ID as a header httpRequestWithEntity.headers().setInt(Names.STREAM_ID, streamId); if (spdySynStreamFrame.isLast()) { out.add(httpRequestWithEntity); + httpRequestWithEntity = null; } else { // Request body will follow in a series of Data Frames - putMessage(streamId, httpRequestWithEntity); + FullHttpMessage old = putMessage(streamId, httpRequestWithEntity); + httpRequestWithEntity = null; + if (old != null) { + old.release(); + } } } catch (Throwable t) { + if (httpRequestWithEntity != null) { + httpRequestWithEntity.release(); + } // If a client sends a SYN_STREAM without all of the getMethod, url (host and path), // scheme, and version headers the server must reply with an HTTP 400 BAD REQUEST reply. // Also sends HTTP 400 BAD REQUEST reply if header name/value pairs are invalid @@ -270,9 +283,9 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out return; } + FullHttpResponse httpResponseWithEntity = null; try { - FullHttpResponse httpResponseWithEntity = - createHttpResponse(spdySynReplyFrame, ctx.alloc()); + httpResponseWithEntity = createHttpResponse(spdySynReplyFrame, ctx.alloc()); // Set the Stream-ID as a header httpResponseWithEntity.headers().setInt(Names.STREAM_ID, streamId); @@ -280,11 +293,19 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out if (spdySynReplyFrame.isLast()) { HttpUtil.setContentLength(httpResponseWithEntity, 0); out.add(httpResponseWithEntity); + httpResponseWithEntity = null; } else { // Response body will follow in a series of Data Frames - putMessage(streamId, httpResponseWithEntity); + FullHttpMessage old = putMessage(streamId, httpResponseWithEntity); + httpResponseWithEntity = null; + if (old != null) { + old.release(); + } } } catch (Throwable t) { + if (httpResponseWithEntity != null) { + httpResponseWithEntity.release(); + } // If a client receives a SYN_REPLY without valid getStatus and version headers // the client must reply with a RST_STREAM frame indicating a PROTOCOL_ERROR SpdyRstStreamFrame spdyRstStreamFrame = @@ -320,11 +341,19 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out if (spdyHeadersFrame.isLast()) { HttpUtil.setContentLength(fullHttpMessage, 0); out.add(fullHttpMessage); + fullHttpMessage = null; } else { // Response body will follow in a series of Data Frames - putMessage(streamId, fullHttpMessage); + FullHttpMessage old = putMessage(streamId, fullHttpMessage); + fullHttpMessage = null; + if (old != null) { + old.release(); + } } } catch (Throwable t) { + if (fullHttpMessage != null) { + fullHttpMessage.release(); + } // If a client receives a SYN_REPLY without valid getStatus and version headers // the client must reply with a RST_STREAM frame indicating a PROTOCOL_ERROR SpdyRstStreamFrame spdyRstStreamFrame = @@ -344,7 +373,10 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out if (spdyHeadersFrame.isLast()) { HttpUtil.setContentLength(fullHttpMessage, fullHttpMessage.content().readableBytes()); - removeMessage(streamId); + FullHttpMessage removed = removeMessage(streamId); + if (removed != null && removed != fullHttpMessage) { + removed.release(); + } out.add(fullHttpMessage); } @@ -361,7 +393,11 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out ByteBuf content = fullHttpMessage.content(); if (content.readableBytes() > maxContentLength - spdyDataFrame.content().readableBytes()) { - removeMessage(streamId); + FullHttpMessage removed = removeMessage(streamId); + if (removed != null && removed != fullHttpMessage) { + removed.release(); + } + fullHttpMessage.release(); throw new TooLongFrameException( "HTTP content length exceeded " + maxContentLength + " bytes: " + spdyDataFrame.content().readableBytes()); @@ -373,7 +409,10 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out if (spdyDataFrame.isLast()) { HttpUtil.setContentLength(fullHttpMessage, content.readableBytes()); - removeMessage(streamId); + FullHttpMessage removed = removeMessage(streamId); + if (removed != null && removed != fullHttpMessage) { + removed.release(); + } out.add(fullHttpMessage); } @@ -381,7 +420,10 @@ protected void decode(ChannelHandlerContext ctx, SpdyFrame msg, List out SpdyRstStreamFrame spdyRstStreamFrame = (SpdyRstStreamFrame) msg; int streamId = spdyRstStreamFrame.streamId(); - removeMessage(streamId); + FullHttpMessage removed = removeMessage(streamId); + if (removed != null) { + removed.release(); + } } } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentEncoderTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentEncoderTest.java index 1a3df8f0895..dc3bdde8965 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentEncoderTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentEncoderTest.java @@ -22,6 +22,7 @@ import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.CodecException; +import io.netty.handler.codec.DecoderException; import io.netty.handler.codec.DecoderResult; import io.netty.handler.codec.EncoderException; import io.netty.handler.codec.MessageToByteEncoder; @@ -44,6 +45,14 @@ public class HttpContentEncoderTest { private static final class TestEncoder extends HttpContentEncoder { + + TestEncoder() { + } + + TestEncoder(int maxPipelineDepth) { + super(maxPipelineDepth); + } + @Override protected Result beginEncode(HttpResponse httpResponse, String acceptEncoding) { return new Result("test", new EmbeddedChannel(new MessageToByteEncoder() { @@ -435,6 +444,22 @@ public void execute() { assertEquals(0, content.refCnt()); } + @Test + public void testPipelineDepthLimited() { + final EmbeddedChannel ch = new EmbeddedChannel(new TestEncoder(2)); + ch.writeInbound(new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/")); + ch.writeInbound(new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/")); + + assertThrows(DecoderException.class, new Executable() { + @Override + public void execute() throws Throwable { + ch.writeInbound(new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/")); + } + }); + + ch.finishAndReleaseAll(); + } + private static void assertEmptyResponse(EmbeddedChannel ch) { Object o = ch.readOutbound(); assertInstanceOf(HttpResponse.class, o); diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java index d76f8d3f04f..dc0d962b6a8 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java @@ -665,6 +665,24 @@ public void preflightEmptyLastDiscardedThenNewRequestForwarded() { assertFalse(ch.finish()); } + @Test + public void shortCircuitWithNullOriginNotAllowedShouldBeForbidden() { + final CorsConfig config = forOrigin("http://localhost:8080").shortCircuit().build(); + final HttpResponse response = simpleRequest(config, "null"); + assertEquals(FORBIDDEN, response.status()); + assertEquals("0", response.headers().get(CONTENT_LENGTH)); + assertTrue(ReferenceCountUtil.release(response)); + } + + @Test + public void shortCircuitWithNullOriginAllowedShouldSucceed() { + final CorsConfig config = forOrigin("http://localhost:8080").allowNullOrigin().shortCircuit().build(); + final HttpResponse response = simpleRequest(config, "null"); + assertEquals(OK, response.status()); + assertEquals("null", response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN)); + assertTrue(ReferenceCountUtil.release(response)); + } + private static HttpResponse simpleRequest(final CorsConfig config, final String origin) { return simpleRequest(config, origin, null); } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/multipart/DiskFileUploadTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/multipart/DiskFileUploadTest.java index 55089191bac..6821137cc5a 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/multipart/DiskFileUploadTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/multipart/DiskFileUploadTest.java @@ -19,10 +19,14 @@ import io.netty.buffer.ByteBufInputStream; import io.netty.buffer.ByteBufUtil; import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.HttpConstants; import io.netty.util.CharsetUtil; import io.netty.util.internal.PlatformDependent; +import org.assertj.core.api.ThrowableAssert; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.io.File; import java.io.FileInputStream; @@ -31,6 +35,7 @@ import java.io.InputStream; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -294,4 +299,30 @@ public void setSetContentFromFileExceptionally() throws Exception { f1.delete(); } } + + @ParameterizedTest + @ValueSource(bytes = { + 0x00, + HttpConstants.CR, + HttpConstants.LF, + 0x19, + HttpConstants.DEL, + HttpConstants.DOUBLE_QUOTE, + HttpConstants.BACKSLASH}) + void filenameCannotContainIllegalCharacters(byte illegal) { + assertIllegalFilename(((char) illegal) + "f"); + assertIllegalFilename("f" + ((char) illegal) + "f"); + assertIllegalFilename("f" + ((char) illegal)); + } + + private static void assertIllegalFilename(final String filename) { + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + new DiskFileUpload("f", filename, "plain/text", null, null, 0); + } + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Illegal filename character"); + } } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/multipart/HttpPostMultiPartRequestDecoderTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/multipart/HttpPostMultiPartRequestDecoderTest.java index 4961f4baefc..20a145695d7 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/multipart/HttpPostMultiPartRequestDecoderTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/multipart/HttpPostMultiPartRequestDecoderTest.java @@ -95,6 +95,50 @@ public void testDecodeFullHttpRequestWithInvalidPayloadReleaseBuffer() { } } + @Test + public void decodeMustCleanFilenameCharacters() { + String content = "\n--861fbeab-cd20-470c-9609-d40a0f704466\r\n" + + "content-disposition: form-data; " + + "name=\"file\"; filename=\" dir\\file\0\u007F÷.txt \"\r\n" + + "content-type: text/plain\r\n" + + "Content-Length: 1\r\n\r\n" + + "x\r\n--861fbeab-cd20-470c-9609-d40a0f704466--\r\n"; + FullHttpRequest req = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, "/upload", + Unpooled.copiedBuffer(content, CharsetUtil.UTF_8)); + req.headers().set("content-type", "multipart/form-data; boundary=861fbeab-cd20-470c-9609-d40a0f704466"); + req.headers().set("content-length", content.length()); + + HttpPostMultipartRequestDecoder test = new HttpPostMultipartRequestDecoder(req); + FileUpload httpData = (FileUpload) test.getBodyHttpDatas("file").get(0); + try { + assertEquals("dirfile ÷.txt", httpData.getFilename()); + } finally { + test.destroy(); + } + } + + @Test + public void decodeMustCleanFilenamePercentEncodedCharacters() { + String content = "\n--861fbeab-cd20-470c-9609-d40a0f704466\r\n" + + "content-disposition: form-data; " + + "name=\"file\"; filename*=UTF-8''\"%20dir\\file%00%13%7F÷.txt%20\"\r\n" + + "content-type: text/plain\r\n" + + "Content-Length: 1\r\n\r\n" + + "x\r\n--861fbeab-cd20-470c-9609-d40a0f704466--\r\n"; + FullHttpRequest req = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, "/upload", + Unpooled.copiedBuffer(content, CharsetUtil.UTF_8)); + req.headers().set("content-type", "multipart/form-data; boundary=861fbeab-cd20-470c-9609-d40a0f704466"); + req.headers().set("content-length", content.length()); + + HttpPostMultipartRequestDecoder test = new HttpPostMultipartRequestDecoder(req); + FileUpload httpData = (FileUpload) test.getBodyHttpDatas("file").get(0); + try { + assertEquals("dirfile ÷.txt", httpData.getFilename()); + } finally { + test.destroy(); + } + } + @Test public void testDelimiterExceedLeftSpaceInCurrentBuffer() { String delimiter = "--861fbeab-cd20-470c-9609-d40a0f704466"; diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/multipart/MemoryFileUploadTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/multipart/MemoryFileUploadTest.java index 167c8c3c209..f1fd7ea6f8c 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/multipart/MemoryFileUploadTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/multipart/MemoryFileUploadTest.java @@ -15,8 +15,13 @@ */ package io.netty.handler.codec.http.multipart; +import io.netty.handler.codec.http.HttpConstants; +import org.assertj.core.api.ThrowableAssert; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; public class MemoryFileUploadTest { @@ -27,4 +32,30 @@ public final void testMemoryFileUploadEquals() { new MemoryFileUpload("m1", "m1", "application/json", null, null, 100); assertEquals(f1, f1); } + + @ParameterizedTest + @ValueSource(bytes = { + 0x00, + HttpConstants.CR, + HttpConstants.LF, + 0x19, + HttpConstants.DEL, + HttpConstants.DOUBLE_QUOTE, + HttpConstants.BACKSLASH}) + void filenameCannotContainIllegalCharacters(byte illegal) { + assertIllegalFilename(((char) illegal) + "f"); + assertIllegalFilename("f" + ((char) illegal) + "f"); + assertIllegalFilename("f" + ((char) illegal)); + } + + private static void assertIllegalFilename(final String filename) { + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + new MemoryFileUpload("f", filename, "plain/text", null, null, 0); + } + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Illegal filename character"); + } } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker00Test.java b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker00Test.java index e6afcd5d1b2..01493a8dcaf 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker00Test.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker00Test.java @@ -128,4 +128,19 @@ private static void testPerformOpeningHandshake0(boolean subProtocol) { content.release(); req.release(); } + + @Override + public void testHandshakeExceptionWhenConnectionHeaderIsAbsent() { + // ignore + } + + @Override + public void testHandshakeExceptionWhenInvalidConnectionHeader() { + // ignore + } + + @Override + public void testHandshakeExceptionWhenInvalidUpgradeHeader() { + // ignore + } } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker13Test.java b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker13Test.java index 3c3ac2c72b0..3465dd4a4eb 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker13Test.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker13Test.java @@ -30,11 +30,9 @@ import io.netty.handler.codec.http.HttpResponseDecoder; import io.netty.handler.codec.http.HttpResponseEncoder; import io.netty.handler.codec.http.HttpServerCodec; -import io.netty.handler.codec.http.HttpVersion; import io.netty.util.ReferenceCountUtil; import io.netty.util.ReferenceCounted; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.function.Executable; import java.util.Iterator; @@ -43,8 +41,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; public class WebSocketServerHandshaker13Test extends WebSocketServerHandshakerTest { @@ -94,83 +90,6 @@ public void testCloseReasonWithCodec() { testCloseReason0(new HttpServerCodec()); } - @Test - public void testHandshakeExceptionWhenConnectionHeaderIsAbsent() { - final WebSocketServerHandshaker serverHandshaker = newHandshaker("ws://example.com/chat", - "chat", WebSocketDecoderConfig.DEFAULT); - final FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, - "ws://example.com/chat"); - request.headers() - .set(HttpHeaderNames.HOST, "server.example.com") - .set(HttpHeaderNames.UPGRADE, HttpHeaderValues.WEBSOCKET) - .set(HttpHeaderNames.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") - .set(HttpHeaderNames.SEC_WEBSOCKET_ORIGIN, "http://example.com") - .set(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, "chat, superchat") - .set(HttpHeaderNames.SEC_WEBSOCKET_VERSION, "13"); - Throwable exception = assertThrows(WebSocketServerHandshakeException.class, new Executable() { - @Override - public void execute() throws Throwable { - serverHandshaker.handshake(null, request, null, null); - } - }); - - assertEquals("not a WebSocket request: a |Connection| header must includes a token 'Upgrade'", - exception.getMessage()); - assertTrue(request.release()); - } - - @Test - public void testHandshakeExceptionWhenInvalidConnectionHeader() { - final WebSocketServerHandshaker serverHandshaker = newHandshaker("ws://example.com/chat", - "chat", WebSocketDecoderConfig.DEFAULT); - final FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, - "ws://example.com/chat"); - request.headers() - .set(HttpHeaderNames.HOST, "server.example.com") - .set(HttpHeaderNames.CONNECTION, "close") - .set(HttpHeaderNames.UPGRADE, HttpHeaderValues.WEBSOCKET) - .set(HttpHeaderNames.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") - .set(HttpHeaderNames.SEC_WEBSOCKET_ORIGIN, "http://example.com") - .set(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, "chat, superchat") - .set(HttpHeaderNames.SEC_WEBSOCKET_VERSION, "13"); - Throwable exception = assertThrows(WebSocketServerHandshakeException.class, new Executable() { - @Override - public void execute() throws Throwable { - serverHandshaker.handshake(null, request, null, null); - } - }); - - assertEquals("not a WebSocket request: a |Connection| header must includes a token 'Upgrade'", - exception.getMessage()); - assertTrue(request.release()); - } - - @Test - public void testHandshakeExceptionWhenInvalidUpgradeHeader() { - final WebSocketServerHandshaker serverHandshaker = newHandshaker("ws://example.com/chat", - "chat", WebSocketDecoderConfig.DEFAULT); - final FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, - "ws://example.com/chat"); - request.headers() - .set(HttpHeaderNames.HOST, "server.example.com") - .set(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE) - .set(HttpHeaderNames.UPGRADE, "my_websocket") - .set(HttpHeaderNames.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") - .set(HttpHeaderNames.SEC_WEBSOCKET_ORIGIN, "http://example.com") - .set(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, "chat, superchat") - .set(HttpHeaderNames.SEC_WEBSOCKET_VERSION, "13"); - Throwable exception = assertThrows(WebSocketServerHandshakeException.class, new Executable() { - @Override - public void execute() throws Throwable { - serverHandshaker.handshake(null, request, null, null); - } - }); - - assertEquals("not a WebSocket request: a |Upgrade| header must containing the value 'websocket'", - exception.getMessage()); - assertTrue(request.release()); - } - private static void testCloseReason0(ChannelHandler... handlers) { EmbeddedChannel ch = new EmbeddedChannel( new HttpObjectAggregator(42)); diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshakerTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshakerTest.java index 1d50da9acb6..4e182671d94 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshakerTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshakerTest.java @@ -38,6 +38,7 @@ import io.netty.handler.codec.http.LastHttpContent; import io.netty.util.CharsetUtil; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import static io.netty.handler.codec.http.HttpResponseStatus.*; import static org.junit.jupiter.api.Assertions.*; @@ -177,4 +178,81 @@ public void testHandshakeForHttpRequestWithoutAggregator() { assertFalse(channel.finish()); } + + @Test + public void testHandshakeExceptionWhenConnectionHeaderIsAbsent() { + final WebSocketServerHandshaker serverHandshaker = newHandshaker("ws://example.com/chat", + "chat", WebSocketDecoderConfig.DEFAULT); + final FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, + "ws://example.com/chat"); + request.headers() + .set(HttpHeaderNames.HOST, "server.example.com") + .set(HttpHeaderNames.UPGRADE, HttpHeaderValues.WEBSOCKET) + .set(HttpHeaderNames.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") + .set(HttpHeaderNames.SEC_WEBSOCKET_ORIGIN, "http://example.com") + .set(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, "chat, superchat") + .set(HttpHeaderNames.SEC_WEBSOCKET_VERSION, "13"); + Throwable exception = assertThrows(WebSocketServerHandshakeException.class, new Executable() { + @Override + public void execute() throws Throwable { + serverHandshaker.handshake(null, request, null, null); + } + }); + + assertEquals("not a WebSocket request: a |Connection| header must include a token 'Upgrade'", + exception.getMessage()); + assertTrue(request.release()); + } + + @Test + public void testHandshakeExceptionWhenInvalidConnectionHeader() { + final WebSocketServerHandshaker serverHandshaker = newHandshaker("ws://example.com/chat", + "chat", WebSocketDecoderConfig.DEFAULT); + final FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, + "ws://example.com/chat"); + request.headers() + .set(HttpHeaderNames.HOST, "server.example.com") + .set(HttpHeaderNames.CONNECTION, "close") + .set(HttpHeaderNames.UPGRADE, HttpHeaderValues.WEBSOCKET) + .set(HttpHeaderNames.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") + .set(HttpHeaderNames.SEC_WEBSOCKET_ORIGIN, "http://example.com") + .set(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, "chat, superchat") + .set(HttpHeaderNames.SEC_WEBSOCKET_VERSION, "13"); + Throwable exception = assertThrows(WebSocketServerHandshakeException.class, new Executable() { + @Override + public void execute() throws Throwable { + serverHandshaker.handshake(null, request, null, null); + } + }); + + assertEquals("not a WebSocket request: a |Connection| header must include a token 'Upgrade'", + exception.getMessage()); + assertTrue(request.release()); + } + + @Test + public void testHandshakeExceptionWhenInvalidUpgradeHeader() { + final WebSocketServerHandshaker serverHandshaker = newHandshaker("ws://example.com/chat", + "chat", WebSocketDecoderConfig.DEFAULT); + final FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, + "ws://example.com/chat"); + request.headers() + .set(HttpHeaderNames.HOST, "server.example.com") + .set(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE) + .set(HttpHeaderNames.UPGRADE, "my_websocket") + .set(HttpHeaderNames.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") + .set(HttpHeaderNames.SEC_WEBSOCKET_ORIGIN, "http://example.com") + .set(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, "chat, superchat") + .set(HttpHeaderNames.SEC_WEBSOCKET_VERSION, "13"); + Throwable exception = assertThrows(WebSocketServerHandshakeException.class, new Executable() { + @Override + public void execute() throws Throwable { + serverHandshaker.handshake(null, request, null, null); + } + }); + + assertEquals("not a WebSocket request: an |Upgrade| header must containing the value 'websocket'", + exception.getMessage()); + assertTrue(request.release()); + } } diff --git a/codec-http/src/test/java/io/netty/handler/codec/spdy/SpdyFrameDecoderTest.java b/codec-http/src/test/java/io/netty/handler/codec/spdy/SpdyFrameDecoderTest.java index e496e5ef136..c3e82f55e79 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/spdy/SpdyFrameDecoderTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/spdy/SpdyFrameDecoderTest.java @@ -25,6 +25,7 @@ import java.util.Random; import static io.netty.handler.codec.spdy.SpdyCodecUtil.SPDY_HEADER_SIZE; +import static io.netty.handler.codec.spdy.SpdyFrameDecoder.DEFAULT_MAX_NUM_SETTINGS; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -830,6 +831,31 @@ public void testInvalidSpdySettingsFrameNumSettings() throws Exception { buf.release(); } + @Test + public void testSpdySettingsFrameContainTooManySettings() throws Exception { + short type = 4; + byte flags = 0; + int numSettings = DEFAULT_MAX_NUM_SETTINGS * 2; + int length = 8 * numSettings + 4; + byte idFlags = 0; + int id = RANDOM.nextInt() & 0x00FFFFFF; + int value = RANDOM.nextInt(); + + ByteBuf buf = Unpooled.buffer(SPDY_HEADER_SIZE + length); + encodeControlFrameHeader(buf, type, flags, length); + buf.writeInt(numSettings); + for (int i = 0; i < numSettings; i++) { + buf.writeByte(idFlags); + buf.writeMedium(id); + buf.writeInt(value); + } + + decoder.decode(buf); + verify(delegate).readFrameError(anyString()); + assertFalse(buf.isReadable()); + buf.release(); + } + @Test public void testDiscardUnknownFrame() throws Exception { short type = 5; diff --git a/codec-http/src/test/java/io/netty/handler/codec/spdy/SpdyHeaderBlockZlibDecoderTest.java b/codec-http/src/test/java/io/netty/handler/codec/spdy/SpdyHeaderBlockZlibDecoderTest.java index 379ff7a5ec8..2e3bb82c37c 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/spdy/SpdyHeaderBlockZlibDecoderTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/spdy/SpdyHeaderBlockZlibDecoderTest.java @@ -23,6 +23,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; +import java.io.ByteArrayOutputStream; +import java.util.zip.Deflater; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -36,6 +39,9 @@ public class SpdyHeaderBlockZlibDecoderTest { private static final int maxHeaderSize = 8192; + private static final long MAX_DECOMPRESSED_SIZE = + SpdyHeaderBlockZlibDecoder.calculateMaxDecompressedSizePerBlock(maxHeaderSize); + private static final String name = "name"; private static final String value = "value"; private static final byte[] nameBytes = name.getBytes(); @@ -242,4 +248,68 @@ public void execute() throws Throwable { headerBlock.release(); } + + @Test + public void testHeaderBlockAtDecompressionCapIsAccepted() throws Exception { + long valueLength = MAX_DECOMPRESSED_SIZE - headerBlockOverhead(); + ByteBuf headerBlock = Unpooled.wrappedBuffer(deflate(buildHeaderBlock((int) valueLength))); + + decoder.decode(ByteBufAllocator.DEFAULT, headerBlock, frame); + decoder.endHeaderBlock(frame); + + assertFalse(frame.isInvalid()); + assertTrue(frame.isTruncated()); + + headerBlock.release(); + } + + @Test + public void testHeaderBlockExceedingDecompressionCapThrows() throws Exception { + long valueLength = MAX_DECOMPRESSED_SIZE - headerBlockOverhead() + 1; + final ByteBuf headerBlock = Unpooled.wrappedBuffer(deflate(buildHeaderBlock((int) valueLength))); + + assertThrows(SpdyProtocolException.class, new Executable() { + @Override + public void execute() throws Throwable { + decoder.decode(ByteBufAllocator.DEFAULT, headerBlock, frame); + } + }); + + headerBlock.release(); + } + + // 4 bytes for the header count, 4 bytes for the name length, the 1-byte name itself, + // and 4 bytes for the value length: everything but the value payload. + private static long headerBlockOverhead() { + return 4 + 4 + 1 + 4; + } + + private static byte[] buildHeaderBlock(int valueLength) { + ByteBuf buf = Unpooled.buffer((int) headerBlockOverhead() + valueLength); + buf.writeInt(1); // number of Name/Value pairs + buf.writeInt(1); // length of name + buf.writeByte('n'); + buf.writeInt(valueLength); + buf.writeZero(valueLength); + + byte[] bytes = new byte[buf.readableBytes()]; + buf.readBytes(bytes); + buf.release(); + return bytes; + } + + private static byte[] deflate(byte[] input) { + Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION); + deflater.setInput(input); + deflater.finish(); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + while (!deflater.finished()) { + int n = deflater.deflate(chunk); + out.write(chunk, 0, n); + } + deflater.end(); + return out.toByteArray(); + } } diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DelegatingDecompressorFrameListener.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DelegatingDecompressorFrameListener.java index c14502b94f9..6975c6f9e6a 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DelegatingDecompressorFrameListener.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DelegatingDecompressorFrameListener.java @@ -28,6 +28,8 @@ import io.netty.handler.codec.compression.ZlibWrapper; import io.netty.handler.codec.compression.SnappyFrameDecoder; +import java.nio.channels.ClosedChannelException; + import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_ENCODING; import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH; import static io.netty.handler.codec.http.HttpHeaderValues.BR; @@ -407,6 +409,15 @@ int decompress(ChannelHandlerContext ctx, Http2Stream stream, ByteBuf data, int this.dataDecompressed = false; this.targetCtx = ctx; + if (!decompressor.isOpen()) { + // Directly throw an exception in this case which we will handle in the catch block below. + // This is required as otherwise it will impossible for us to know if the used EmbeddedChannel + // did throw because it was closed already or because of other reasons. We need this knowledge + // to know if we need to call data.release() ourselves after it was retained or not. This is needed + // as EmbeddedChannel will not release the buffer if it throws because it was not open. + // This mimics what EmbeddedChannel will throw. + throw new ClosedChannelException(); + } // call retain here as it will call release after its written to the channel decompressor.writeInbound(data.retain()); if (endOfStream) { diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpConversionUtil.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpConversionUtil.java index cbf66603d31..d12137696c4 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpConversionUtil.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/HttpConversionUtil.java @@ -833,12 +833,16 @@ private static final class Http2ToHttpHeaderTranslator { void translateHeaders(Iterable> inputHeaders) throws Http2Exception { // lazily created as needed StringBuilder cookies = null; + boolean hostHeaderFound = false; for (Entry entry : inputHeaders) { final CharSequence name = entry.getKey(); final CharSequence value = entry.getValue(); AsciiString translatedName = translations.get(name); if (translatedName != null) { + if (translatedName.contentEqualsIgnoreCase(HttpHeaderNames.HOST)) { + hostHeaderFound = true; + } output.add(translatedName, AsciiString.of(value)); } else if (!Http2Headers.PseudoHeaderName.isPseudoHeader(name)) { // https://tools.ietf.org/html/rfc7540#section-8.1.2.3 @@ -856,6 +860,20 @@ void translateHeaders(Iterable> inputHeaders) cookies.append("; "); } cookies.append(value); + } else if (contentEqualsIgnoreCase(HttpHeaderNames.HOST, name)) { + // https://www.rfc-editor.org/rfc/rfc9113#section-8.3.1 requires that intermediaries + // translating to HTTP/1.x treat a literal 'host' header that conflicts with ':authority' + // as malformed, and RFC 9110 section 7.2 requires 'Host' be sent as a single field-value. + // Reject the request rather than emitting an HTTP/1.x message with duplicate Host headers. + if (hostHeaderFound) { + if (!contentEqualsIgnoreCase(output.get(HttpHeaderNames.HOST), value)) { + throw streamError(streamId, PROTOCOL_ERROR, + "Conflicting ':authority' and 'host' headers found"); + } + } else { + hostHeaderFound = true; + output.add(name, value); + } } else { output.add(name, value); } diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/HttpConversionUtilTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/HttpConversionUtilTest.java index 0619801945a..ba7edf0cecb 100644 --- a/codec-http2/src/test/java/io/netty/handler/codec/http2/HttpConversionUtilTest.java +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/HttpConversionUtilTest.java @@ -381,6 +381,38 @@ public void addHttp2ToHttpHeadersCombinesCookies() throws Http2Exception { assertEquals("foo=bar; bax=baz", outHeaders.get(COOKIE.toString())); } + @Test + public void addHttp2ToHttpHeadersDeduplicatesMatchingAuthorityAndHost() throws Http2Exception { + Http2Headers inHeaders = new DefaultHttp2Headers(); + inHeaders.authority("example.com"); + inHeaders.add(HOST, "example.com"); + + HttpHeaders outHeaders = new DefaultHttpHeaders(); + + HttpConversionUtil.addHttp2ToHttpHeaders(5, inHeaders, outHeaders, HttpVersion.HTTP_1_1, false, true); + assertEquals(1, outHeaders.getAll(HOST).size()); + assertEquals("example.com", outHeaders.get(HOST)); + } + + @Test + public void addHttp2ToHttpHeadersRejectsConflictingAuthorityAndHost() { + final Http2Headers inHeaders = new DefaultHttp2Headers(); + inHeaders.authority("public.example.com"); + inHeaders.add(HOST, "internal-admin.local"); + + final HttpHeaders outHeaders = new DefaultHttpHeaders(); + + Http2Exception exception = assertThrows(Http2Exception.class, new Executable() { + @Override + public void execute() throws Http2Exception { + HttpConversionUtil.addHttp2ToHttpHeaders(5, inHeaders, outHeaders, HttpVersion.HTTP_1_1, false, true); + } + }); + assertEquals(Http2Error.PROTOCOL_ERROR, exception.error()); + // No Host header should have leaked through in an inconsistent state. + assertFalse(outHeaders.contains(HOST) && outHeaders.getAll(HOST).size() > 1); + } + @Test public void connectionSpecificHeadersShouldBeRemoved() { HttpHeaders inHeaders = new DefaultHttpHeaders(); diff --git a/codec-redis/src/main/java/io/netty/handler/codec/redis/RedisArrayAggregator.java b/codec-redis/src/main/java/io/netty/handler/codec/redis/RedisArrayAggregator.java index 25df754d962..d3019007793 100644 --- a/codec-redis/src/main/java/io/netty/handler/codec/redis/RedisArrayAggregator.java +++ b/codec-redis/src/main/java/io/netty/handler/codec/redis/RedisArrayAggregator.java @@ -98,6 +98,11 @@ protected void decode(ChannelHandlerContext ctx, RedisMessage msg, List out.add(msg); } + private CodecException clearAndCreateException(String msg) { + releaseAndClearDepths(); + return new CodecException(msg); + } + private RedisMessage decodeRedisArrayHeader(ArrayHeaderRedisMessage header) { if (header.isNull()) { return ArrayRedisMessage.NULL_INSTANCE; @@ -106,18 +111,17 @@ private RedisMessage decodeRedisArrayHeader(ArrayHeaderRedisMessage header) { } else if (header.length() > 0L) { // Currently, this codec doesn't support `long` length for arrays because Java's List.size() is int. if (header.length() > maxElements) { - throw new CodecException("this codec doesn't support longer length than " + maxElements); + throw clearAndCreateException("this codec doesn't support longer length than " + maxElements); } if (depths.size() >= maxNestedArrayDepth) { - releaseAndClearDepths(); - throw new CodecException("max nested array depth exceeded: " + maxNestedArrayDepth); + throw clearAndCreateException("max nested array depth exceeded: " + maxNestedArrayDepth); } // start aggregating array depths.push(new AggregateState((int) header.length())); return null; } else { - throw new CodecException("bad length: " + header.length()); + throw clearAndCreateException("bad length: " + header.length()); } } diff --git a/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeDecoder.java b/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeDecoder.java index ba53c2bbcc4..365b495675b 100644 --- a/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeDecoder.java +++ b/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeDecoder.java @@ -42,6 +42,9 @@ * {@code maxChunkSize} The maximum length of the content or each chunk. If the content length (or the length of each * chunk) exceeds this value, the content or chunk ill be split into multiple {@link StompContentSubframe}s whose length * is {@code maxChunkSize} at maximum. + *
+ * {@code maxNumHeaders} The maximum number of headers per frame. + * If this limit exceeded a {@link TooLongFrameException} will be raised. * *

Chunked Content

*

@@ -54,6 +57,7 @@ public class StompSubframeDecoder extends ReplayingDecoder { private static final int DEFAULT_CHUNK_SIZE = 8132; private static final int DEFAULT_MAX_LINE_LENGTH = 1024; + private static final int DEFAULT_MAX_NUMBER_HEADERS = 128; /** * @deprecated this should never be used by an user! @@ -80,7 +84,7 @@ public StompSubframeDecoder() { } public StompSubframeDecoder(boolean validateHeaders) { - this(DEFAULT_MAX_LINE_LENGTH, DEFAULT_CHUNK_SIZE, validateHeaders); + this(DEFAULT_MAX_LINE_LENGTH, DEFAULT_CHUNK_SIZE, DEFAULT_MAX_NUMBER_HEADERS, validateHeaders); } public StompSubframeDecoder(int maxLineLength, int maxChunkSize) { @@ -88,12 +92,18 @@ public StompSubframeDecoder(int maxLineLength, int maxChunkSize) { } public StompSubframeDecoder(int maxLineLength, int maxChunkSize, boolean validateHeaders) { + this(maxLineLength, maxChunkSize, DEFAULT_MAX_NUMBER_HEADERS, validateHeaders); + } + + public StompSubframeDecoder(int maxLineLength, int maxChunkSize, int maxNumHeaders, boolean validateHeaders) { super(State.SKIP_CONTROL_CHARACTERS); checkPositive(maxLineLength, "maxLineLength"); checkPositive(maxChunkSize, "maxChunkSize"); + checkPositive(maxNumHeaders, "maxNumHeaders"); + this.maxChunkSize = maxChunkSize; commandParser = new Utf8LineParser(new AppendableCharSequence(16), maxLineLength); - headerParser = new HeaderParser(new AppendableCharSequence(128), maxLineLength, validateHeaders); + headerParser = new HeaderParser(new AppendableCharSequence(128), maxLineLength, maxNumHeaders, validateHeaders); } @Override @@ -342,25 +352,32 @@ protected void reset() { private static final class HeaderParser extends Utf8LineParser { private final boolean validateHeaders; - + private final int maxNumHeaders; + private int numHeaders; private String name; private boolean valid; private boolean shouldUnescape; private boolean unescapeInProgress; - HeaderParser(AppendableCharSequence charSeq, int maxLineLength, boolean validateHeaders) { + HeaderParser(AppendableCharSequence charSeq, int maxLineLength, int maxNumHeaders, boolean validateHeaders) { super(charSeq, maxLineLength); this.validateHeaders = validateHeaders; + this.maxNumHeaders = maxNumHeaders; } boolean parseHeader(StompHeadersSubframe headersSubframe, ByteBuf buf) { shouldUnescape = shouldUnescape(headersSubframe.command()); AppendableCharSequence value = super.parse(buf); if (value == null || (name == null && value.length() == 0)) { + numHeaders = 0; return false; } + numHeaders++; + if (maxNumHeaders < numHeaders) { + throw new TooLongFrameException("maximum number of headers exceeded: " + maxNumHeaders); + } if (valid) { headersSubframe.headers().add(name, value.toString()); } else if (validateHeaders) { diff --git a/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeEncoder.java b/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeEncoder.java index 6a4fbb4db36..e52e1995ac8 100644 --- a/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeEncoder.java +++ b/codec-stomp/src/main/java/io/netty/handler/codec/stomp/StompSubframeEncoder.java @@ -21,6 +21,7 @@ import io.netty.handler.codec.MessageToMessageEncoder; import io.netty.util.concurrent.FastThreadLocal; import io.netty.util.internal.AppendableCharSequence; +import io.netty.util.internal.PlatformDependent; import java.util.LinkedHashMap; import java.util.List; @@ -101,7 +102,12 @@ protected void encode(ChannelHandlerContext ctx, StompSubframe msg, List } else if (msg instanceof StompHeadersSubframe) { StompHeadersSubframe stompHeadersSubframe = (StompHeadersSubframe) msg; ByteBuf buf = ctx.alloc().buffer(headersSubFrameSize(stompHeadersSubframe)); - encodeHeaders(stompHeadersSubframe, buf); + try { + encodeHeaders(stompHeadersSubframe, buf); + } catch (Exception e) { + buf.release(); + PlatformDependent.throwException(e); + } out.add(convertHeadersSubFrame(stompHeadersSubframe, buf)); } else if (msg instanceof StompContentSubframe) { @@ -158,7 +164,12 @@ protected int headersSubFrameSize(StompHeadersSubframe headersSubframe) { private ByteBuf encodeFullFrame(StompFrame frame, ChannelHandlerContext ctx) { int contentReadableBytes = frame.content().readableBytes(); ByteBuf buf = ctx.alloc().buffer(headersSubFrameSize(frame) + contentReadableBytes); - encodeHeaders(frame, buf); + try { + encodeHeaders(frame, buf); + } catch (Exception e) { + buf.release(); + PlatformDependent.throwException(e); + } if (contentReadableBytes > 0) { buf.writeBytes(frame.content()); @@ -176,19 +187,27 @@ private static void encodeHeaders(StompHeadersSubframe frame, ByteBuf buf) { LinkedHashMap cache = ESCAPE_HEADER_KEY_CACHE.get(); for (Entry entry : frame.headers()) { CharSequence headerKey = entry.getKey(); + CharSequence headerValue = entry.getValue(); + if (headerKey.length() == 0) { + throw new IllegalArgumentException("STOMP " + command + " contains empty header name"); + } if (shouldEscape) { CharSequence cachedHeaderKey = cache.get(headerKey); if (cachedHeaderKey == null) { - cachedHeaderKey = escape(headerKey); + cachedHeaderKey = escape(command, "header name", headerKey); cache.put(headerKey, cachedHeaderKey); } headerKey = cachedHeaderKey; + headerValue = escape(command, "header value", headerValue); + } else { + // For CONNECT/CONNECTED: don't escape but REJECT illegal characters + validateNoIllegalCharacters(command, headerKey, "header name"); + validateNoIllegalCharacters(command, headerValue, "header value"); } ByteBufUtil.writeUtf8(buf, headerKey); buf.writeByte(StompConstants.COLON); - CharSequence headerValue = shouldEscape? escape(entry.getValue()) : entry.getValue(); ByteBufUtil.writeUtf8(buf, headerValue); buf.writeByte(StompConstants.LF); } @@ -211,7 +230,21 @@ private static boolean shouldEscape(StompCommand command) { return command != StompCommand.CONNECT && command != StompCommand.CONNECTED; } - private static CharSequence escape(CharSequence input) { + private static void validateNoIllegalCharacters(StompCommand command, CharSequence value, String type) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\n' || c == '\r' || c == ':' || c == '\0') { + throw newIllegalCharacterException(command, type, i); + } + } + } + + private static IllegalArgumentException newIllegalCharacterException(StompCommand command, String type, int index) { + return new IllegalArgumentException( + "STOMP " + command + " " + type + " contains illegal character at index " + index); + } + + private static CharSequence escape(StompCommand command, String type, CharSequence input) { AppendableCharSequence builder = null; for (int i = 0; i < input.length(); i++) { char chr = input.charAt(i); @@ -227,6 +260,9 @@ private static CharSequence escape(CharSequence input) { } else if (chr == '\r') { builder = escapeBuilder(builder, input, i); builder.append("\\r"); + } else if (chr == '\0') { + // The NUL character has no escape and is always illegal. + throw newIllegalCharacterException(command, type, i); } else if (builder != null) { builder.append(chr); } diff --git a/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeDecoderTest.java b/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeDecoderTest.java index 17763319939..58d08859feb 100644 --- a/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeDecoderTest.java +++ b/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeDecoderTest.java @@ -18,6 +18,7 @@ import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.TooLongFrameException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -26,6 +27,7 @@ import static io.netty.util.CharsetUtil.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -496,4 +498,36 @@ public void testCRResetsUtf8DecodeState() { assertNull(channel.readInbound()); } + + @Test + void testMaxNumHeadersEnforced() { + // limit to 1 header as CONNECT_FRAME has 2. + channel = new EmbeddedChannel(new StompSubframeDecoder(1024, 1024, 1, true)); + + ByteBuf incoming = Unpooled.wrappedBuffer(CONNECT_FRAME.getBytes(UTF_8)); + assertTrue(channel.writeInbound(incoming)); + + StompHeadersSubframe headersSubFrame = channel.readInbound(); + assertNotNull(headersSubFrame); + assertTrue(headersSubFrame.decoderResult().isFailure()); + + assertInstanceOf(TooLongFrameException.class, + headersSubFrame.decoderResult().cause()); + } + + @Test + void testMaxNumHeadersEnforcedForInvalidHeaders() { + // limit to 1 header as CONNECT_FRAME has 2. + channel = new EmbeddedChannel(new StompSubframeDecoder(1024, 1024, 2, false)); + + ByteBuf incoming = Unpooled.wrappedBuffer(FRAME_WITH_INVALID_HEADER.getBytes(UTF_8)); + assertTrue(channel.writeInbound(incoming)); + + StompHeadersSubframe headersSubFrame = channel.readInbound(); + assertNotNull(headersSubFrame); + assertTrue(headersSubFrame.decoderResult().isFailure()); + + assertInstanceOf(TooLongFrameException.class, + headersSubFrame.decoderResult().cause()); + } } diff --git a/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeEncoderTest.java b/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeEncoderTest.java index e20437e7b9f..01def48c9d2 100644 --- a/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeEncoderTest.java +++ b/codec-stomp/src/test/java/io/netty/handler/codec/stomp/StompSubframeEncoderTest.java @@ -18,15 +18,20 @@ import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.EncoderException; import io.netty.util.AsciiString; import io.netty.util.CharsetUtil; +import org.assertj.core.api.ThrowableAssert; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.nio.charset.StandardCharsets; import static io.netty.handler.codec.stomp.StompTestConstants.SEND_FRAME_UTF8; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -123,14 +128,60 @@ void testEscapeStompHeaders() { assertTrue(stompBuffer.release()); } + @Test + void mustRejectNulCharacterInHeaders() { + // The NUL character has no escape and is always rejected. + final StompFrame frame1 = new DefaultStompFrame(StompCommand.MESSAGE); + frame1.headers() + .add("header\0", "value"); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + channel.writeOutbound(frame1); + } + }) + .isInstanceOf(EncoderException.class) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + + final StompFrame frame2 = new DefaultStompFrame(StompCommand.CONNECT); + frame2.headers() + .add("header", "value\0"); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + channel.writeOutbound(frame2); + } + }) + .isInstanceOf(EncoderException.class) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + } + + @Test + void mustRejectEmptyHeaderNames() { + final StompFrame frame1 = new DefaultStompFrame(StompCommand.MESSAGE); + frame1.headers() + .add("", "value"); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + channel.writeOutbound(frame1); + } + }) + .isInstanceOf(EncoderException.class) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("empty header name"); + } + @Test void testNotEscapeStompHeadersForConnectCommand() { String expectedStompFrame = "CONNECT\n" - + "colonHeaderName-::colonHeaderValue-:\n" + + "backslashHeaderName-\\:backslashHeaderValue-\\\n" + '\n' + '\0'; StompFrame connectFrame = new DefaultStompFrame(StompCommand.CONNECT); connectFrame.headers() - .add("colonHeaderName-:", "colonHeaderValue-:"); + .add("backslashHeaderName-\\", "backslashHeaderValue-\\"); assertTrue(channel.writeOutbound(connectFrame)); @@ -142,14 +193,44 @@ void testNotEscapeStompHeadersForConnectCommand() { assertTrue(stompBuffer.release()); } + @ParameterizedTest + @ValueSource(chars = {'\r', '\n', '\0', ':'}) + void mustRejectIllegalCharsInConnectCommandHeaders(char illegalChar) { + final StompFrame connectFrame1 = new DefaultStompFrame(StompCommand.CONNECT); + connectFrame1.headers() + .add("header" + illegalChar, "value"); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + channel.writeOutbound(connectFrame1); + } + }) + .isInstanceOf(EncoderException.class) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + + final StompFrame connectFrame2 = new DefaultStompFrame(StompCommand.CONNECT); + connectFrame2.headers() + .add("header", "value" + illegalChar); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + channel.writeOutbound(connectFrame2); + } + }) + .isInstanceOf(EncoderException.class) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + } + @Test void testNotEscapeStompHeadersForConnectedCommand() { String expectedStompFrame = "CONNECTED\n" - + "colonHeaderName-::colonHeaderValue-:\n" + + "backslashHeaderName-\\:backslashHeaderValue-\\\n" + '\n' + '\0'; StompFrame connectedFrame = new DefaultStompFrame(StompCommand.CONNECTED); connectedFrame.headers() - .add("colonHeaderName-:", "colonHeaderValue-:"); + .add("backslashHeaderName-\\", "backslashHeaderValue-\\"); assertTrue(channel.writeOutbound(connectedFrame)); @@ -160,4 +241,34 @@ void testNotEscapeStompHeadersForConnectedCommand() { assertEquals(expectedStompFrame, stompBuffer.toString(StandardCharsets.UTF_8)); assertTrue(stompBuffer.release()); } + + @ParameterizedTest + @ValueSource(chars = {'\r', '\n', '\0', ':'}) + void mustRejectIllegalCharsInConnectedCommandHeaders(char illegalChar) { + final StompFrame connectedFrame1 = new DefaultStompFrame(StompCommand.CONNECTED); + connectedFrame1.headers() + .add("header" + illegalChar, "name"); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + channel.writeOutbound(connectedFrame1); + } + }) + .isInstanceOf(EncoderException.class) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + + final StompFrame connectedFrame2 = new DefaultStompFrame(StompCommand.CONNECTED); + connectedFrame2.headers() + .add("header", "name" + illegalChar); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + channel.writeOutbound(connectedFrame2); + } + }) + .isInstanceOf(EncoderException.class) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + } } diff --git a/codec-xml/pom.xml b/codec-xml/pom.xml index 7fc33662b62..db24ee9831b 100644 --- a/codec-xml/pom.xml +++ b/codec-xml/pom.xml @@ -91,4 +91,3 @@ - diff --git a/codec-xml/src/main/java/io/netty/handler/codec/xml/XmlDecoder.java b/codec-xml/src/main/java/io/netty/handler/codec/xml/XmlDecoder.java index d7f84fe885f..51de2a6be44 100644 --- a/codec-xml/src/main/java/io/netty/handler/codec/xml/XmlDecoder.java +++ b/codec-xml/src/main/java/io/netty/handler/codec/xml/XmlDecoder.java @@ -23,6 +23,7 @@ import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; +import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; import java.util.List; @@ -35,7 +36,17 @@ public class XmlDecoder extends ByteToMessageDecoder { - private static final AsyncXMLInputFactory XML_INPUT_FACTORY = new InputFactoryImpl(); + private static final AsyncXMLInputFactory XML_INPUT_FACTORY; + + static { + // Disable risky features by default as we read from the network and so things are considered untrusted. + InputFactoryImpl factory = new InputFactoryImpl(); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE); + factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, Boolean.FALSE); + XML_INPUT_FACTORY = factory; + } + private static final XmlDocumentEnd XML_DOCUMENT_END = XmlDocumentEnd.INSTANCE; private final AsyncXMLStreamReader streamReader = XML_INPUT_FACTORY.createAsyncForByteArray(); diff --git a/codec/src/main/java/io/netty/handler/codec/compression/Bzip2BlockDecompressor.java b/codec/src/main/java/io/netty/handler/codec/compression/Bzip2BlockDecompressor.java index 6b248b90bf5..0a98dab4c17 100644 --- a/codec/src/main/java/io/netty/handler/codec/compression/Bzip2BlockDecompressor.java +++ b/codec/src/main/java/io/netty/handler/codec/compression/Bzip2BlockDecompressor.java @@ -293,6 +293,9 @@ public int read() { crc.updateCRC(nextByte); } else { if (++rleAccumulator == 4) { + if (bwtBytesDecoded >= bwtBlockLength) { + throw new DecompressionException("malformed RLE: run-length byte missing at end of block"); + } // Accumulation complete, start repetition int rleRepeat = decodeNextBWTByte() + 1; this.rleRepeat = rleRepeat; diff --git a/codec/src/main/java/io/netty/handler/codec/xml/XmlFrameDecoder.java b/codec/src/main/java/io/netty/handler/codec/xml/XmlFrameDecoder.java index ed169a66228..2a7cf78f327 100644 --- a/codec/src/main/java/io/netty/handler/codec/xml/XmlFrameDecoder.java +++ b/codec/src/main/java/io/netty/handler/codec/xml/XmlFrameDecoder.java @@ -88,6 +88,9 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t boolean openingBracketFound = false; boolean atLeastOneXmlElementFound = false; boolean inCDATASection = false; + boolean inCommentBlock = false; + boolean inProcessingInstruction = false; + boolean inClosingTag = false; long openBracketsCount = 0; int length = 0; int leadingWhiteSpaceCount = 0; @@ -110,22 +113,18 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t fail(ctx); in.skipBytes(in.readableBytes()); return; - } else if (!inCDATASection && readByte == '<') { + } else if (inClosingTag && readByte == '<') { + fail(ctx); + in.skipBytes(in.readableBytes()); + return; + } else if (!inCDATASection && !inCommentBlock && !inProcessingInstruction && readByte == '<') { openingBracketFound = true; if (i < bufferLength - 1) { final byte peekAheadByte = in.getByte(i + 1); if (peekAheadByte == '/') { // found we can decrement openBracketsCount - if (in.getByte(peekFurtherAheadIndex) == '>') { - openBracketsCount--; - break; - } - peekFurtherAheadIndex++; - } + inClosingTag = true; } else if (isValidStartCharForXmlElement(peekAheadByte)) { atLeastOneXmlElementFound = true; // char after < is a valid xml element start char, @@ -135,6 +134,7 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t if (isCommentBlockStart(in, i)) { // start found openBracketsCount++; + inCommentBlock = true; } else if (isCDATABlockStart(in, i)) { // out) t } else if (peekAheadByte == '?') { // start found openBracketsCount++; + inProcessingInstruction = true; } } - } else if (!inCDATASection && readByte == '/') { + } else if (!inCDATASection && !inCommentBlock && !inProcessingInstruction && readByte == '/') { if (i < bufferLength - 1 && in.getByte(i + 1) == '>') { // found />, decrementing openBracketsCount openBracketsCount--; @@ -156,7 +157,22 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t if (i - 1 > -1) { final byte peekBehindByte = in.getByte(i - 1); - if (!inCDATASection) { + if (inCommentBlock) { + if (peekBehindByte == '-' && i - 2 > -1 && in.getByte(i - 2) == '-') { + // a was closed + openBracketsCount--; + inCommentBlock = false; + } + } else if (inProcessingInstruction) { + if (peekBehindByte == '?') { + // an tag was closed + openBracketsCount--; + inProcessingInstruction = false; + } + } else if (inClosingTag) { + openBracketsCount--; + inClosingTag = false; + } else if (!inCDATASection) { if (peekBehindByte == '?') { // an tag was closed openBracketsCount--; @@ -164,7 +180,7 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t // a was closed openBracketsCount--; } - } else if (peekBehindByte == ']' && i - 2 > -1 && in.getByte(i - 2) == ']') { + } else if (inCDATASection && peekBehindByte == ']' && i - 2 > -1 && in.getByte(i - 2) == ']') { // a block was closed openBracketsCount--; inCDATASection = false; diff --git a/codec/src/test/java/io/netty/handler/codec/compression/Bzip2DecoderTest.java b/codec/src/test/java/io/netty/handler/codec/compression/Bzip2DecoderTest.java index a5041d1bfe7..3f5ef0e795a 100644 --- a/codec/src/test/java/io/netty/handler/codec/compression/Bzip2DecoderTest.java +++ b/codec/src/test/java/io/netty/handler/codec/compression/Bzip2DecoderTest.java @@ -23,10 +23,14 @@ import org.junit.jupiter.api.function.Executable; import java.io.ByteArrayOutputStream; +import java.time.Duration; import java.util.Arrays; import static io.netty.handler.codec.compression.Bzip2Constants.*; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.fail; public class Bzip2DecoderTest extends AbstractDecoderTest { @@ -188,6 +192,95 @@ public void execute() { }, "start pointer invalid"); } + /** + * Regression test for the infinite-loop in {@link Bzip2BlockDecompressor#read()}. + * + *

The bzip2 block below is hand-crafted so that its inverse-BWT output is exactly + * four consecutive 'A' bytes with no trailing run-length count byte. This is a + * malformed-stream + * + *

Stream construction (bit-level, MSB first after the byte-aligned header): + *

+     *   Bytes  0– 3   "BZh1"                  stream header (block size 1 = 100 000 bytes)
+     *   Bytes  4– 9   0x314159265359           block-header magic (pi)
+     *   Bytes 10–13   0x00000000               block CRC — intentionally wrong; a
+     *                                           DecompressionException from checkCRC() is the
+     *                                           expected outcome after the fix is applied
+     *   --- bit-level section (read via Bzip2BitReader, MSB-first) ---
+     *    1 bit          randomized = 0
+     *   24 bits         bwtStartPointer = 0
+     *   16 bits         huffmanInUse16 = 0x0800 (group 4, bytes 0x40–0x4F present)
+     *   16 bits         group-4 symbol bitmap = 0x4000 (only 0x41 = 'A' present)
+     *    3 bits         totalTables = 2 (minimum allowed)
+     *   15 bits         totalSelectors = 1
+     *    1 bit          selector[0] unary-coded as 0 → table 0
+     *    8 bits         table 0: initial length 2 (5 bits = 00010), then three 0-delta
+     *                   bits for RUNA/RUNB/EOB → all code lengths = 2
+     *    8 bits         table 1: identical to table 0
+     *    6 bits         Huffman data: RUNB(01) RUNA(00) EOB(10)
+     *                   RUNB first: repeatCount=2, RUNA: repeatCount=4, EOB flushes
+     *                   4 copies of huffmanSymbolMap[0]='A' into the BWT block.
+     *                   bwtBlockLength = 4 with NO trailing count byte → triggers the bug.
+     *   48 bits         end-of-stream magic 0x177245 0x385090
+     *   32 bits         stream CRC = 0
+     * 
+ */ + @Test + public void testRleOffByOneDoesNotCauseInfiniteLoop() { + final byte[] malformed = { + 0x42, 0x5A, 0x68, 0x31, // "BZh1" + 0x31, 0x41, 0x59, 0x26, 0x53, 0x59, // block magic + 0x00, 0x00, 0x00, 0x00, // block CRC + // bit-level section (MSB-first packing, computed field-by-field): + 0x00, 0x00, 0x00, 0x04, 0x00, 0x20, // random+bwtPtr+inUse16 start + 0x00, 0x20, 0x00, 0x21, 0x01, 0x04, // inUse16 end+grp4+tbls+sel+tbl0 + (byte) 0x85, (byte) 0xDC, (byte) 0x91, 0x4E, 0x14, 0x24, // tbl1+data+EOS magic + 0x00, 0x00, 0x00, 0x00, 0x00 // stream CRC + padding + }; + + final EmbeddedChannel ch = new EmbeddedChannel(new Bzip2Decoder()); + assertTimeoutPreemptively(Duration.ofSeconds(5), new Executable() { + @Override + public void execute() throws Throwable { + assertThrows(DecompressionException.class, new Executable() { + @Override + public void execute() throws Throwable { + ch.writeInbound(Unpooled.wrappedBuffer(malformed)); + ch.finishAndReleaseAll(); + } + }); + } + }, "Bzip2Decoder hung: bwtBytesDecoded overshot bwtBlockLength and the " + + "equality termination check was permanently false"); + } + + /** + * Verifies that a well-formed bzip2 block whose inverse-BWT output ends with exactly + * four consecutive identical bytes followed by a run-length count byte is decoded + * correctly and terminates cleanly. + * + *

Compressing {@code "AAAA"}: bzip2's pre-BWT RLE encodes a run of exactly 4 + * identical bytes as {@code [A,A,A,A,0x00]} (the four bytes plus count-byte 0, + * meaning zero additional copies beyond the initial four). The post-BWT RLE + * decoder in {@link Bzip2BlockDecompressor#read()} must reach + * {@code rleAccumulator == 4}, read the count byte, and then terminate cleanly + * when {@code bwtBytesDecoded} reaches {@code bwtBlockLength == 5}. + */ + @Test + public void testWellFormedFourByteRleRunAtBlockEnd() throws Exception { + byte[] compressed = compress(new byte[]{'A', 'A', 'A', 'A'}); + channel.writeInbound(Unpooled.wrappedBuffer(compressed)); + ByteBuf decoded = channel.readInbound(); + assertNotNull(decoded, "expected decoded output for well-formed AAAA block"); + try { + byte[] result = new byte[decoded.readableBytes()]; + decoded.readBytes(result); + assertArrayEquals(new byte[]{'A', 'A', 'A', 'A'}, result); + } finally { + decoded.release(); + } + } + @Override protected byte[] compress(byte[] data) throws Exception { ByteArrayOutputStream os = new ByteArrayOutputStream(); diff --git a/codec/src/test/java/io/netty/handler/codec/xml/XmlFrameDecoderTest.java b/codec/src/test/java/io/netty/handler/codec/xml/XmlFrameDecoderTest.java index b4c0cfa441c..e1c9f0c619e 100644 --- a/codec/src/test/java/io/netty/handler/codec/xml/XmlFrameDecoderTest.java +++ b/codec/src/test/java/io/netty/handler/codec/xml/XmlFrameDecoderTest.java @@ -133,6 +133,33 @@ public void testDecodeInvalidXml() { testDecodeWithXml("", CharsetUtil.UTF_8)); + } + }); + ch.finishAndReleaseAll(); + } + + @Test + public void testDecodeInvalidRepeatedClosingTags() { + XmlFrameDecoder decoder = new XmlFrameDecoder(1048576); + final EmbeddedChannel ch = new EmbeddedChannel(decoder); + ch.writeInbound(Unpooled.copiedBuffer("" + @@ -150,6 +177,46 @@ public void testDecodeWithCDATABlockContainingNestedUnbalancedXml() { testDecodeWithXml(xml, xml); } + @Test + public void testDecodeWithCDATABlockContainingClosingTagThenOpeningBracket() { + final String xml = "" + + "" + + ""; + testDecodeWithXml(xml, xml); + } + + @Test + public void testDecodeWithCommentContainingClosingTagThenOpeningBracket() { + final String xml = "" + + "" + + ""; + testDecodeWithXml(xml, xml); + } + + @Test + public void testDecodeWithCommentContainingClosingTag() { + final String xml = "" + + "" + + ""; + testDecodeWithXml(xml, xml); + } + + @Test + public void testDecodeWithProcessingInstructionContainingClosingTagThenOpeningBracket() { + final String xml = "" + + "" + + ""; + testDecodeWithXml(xml, xml); + } + + @Test + public void testDecodeWithProcessingInstructionContainingClosingTag() { + final String xml = "" + + "" + + ""; + testDecodeWithXml(xml, xml); + } + @Test public void testDecodeWithMultipleMessages() { final String input = "123"), "123"); } + @Test + public void testFramingWithSplitClosingTag() { + testDecodeWithXml(Arrays.asList("", "123"), "123"); + } + + @Test + public void testFramingWithCommentContainingClosingTagThenOpeningBracket() { + final String frame = ""; + testDecodeWithXml(Arrays.asList(""), frame); + } + + @Test + public void testFramingWithProcessingInstructionContainingClosingTagThenOpeningBracket() { + final String frame = ""; + testDecodeWithXml(Arrays.asList(""), frame); + } + @Test public void testDecodeWithSampleXml() { for (final String xmlSample : xmlSamples) { diff --git a/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspClient.java b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspClient.java index 1daaaace016..b2f07fa0b94 100644 --- a/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspClient.java +++ b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspClient.java @@ -35,6 +35,7 @@ import io.netty.util.concurrent.FutureListener; import io.netty.util.concurrent.GenericFutureListener; import io.netty.util.concurrent.Promise; +import io.netty.util.internal.ObjectUtil; import io.netty.util.internal.SystemPropertyUtil; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; @@ -51,6 +52,7 @@ import org.bouncycastle.cert.ocsp.OCSPReqBuilder; import org.bouncycastle.cert.ocsp.OCSPResp; import org.bouncycastle.operator.ContentVerifierProvider; +import org.bouncycastle.operator.DigestCalculatorProvider; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; @@ -59,6 +61,7 @@ import java.net.URL; import java.security.SecureRandom; import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import static io.netty.handler.codec.http.HttpMethod.POST; @@ -89,19 +92,22 @@ final class OcspClient { * @param issuer {@link X509Certificate} issuer of client certificate * @param validateResponseNonce Set to {@code true} to enable OCSP response validation * @param ioTransport {@link IoTransport} to use - * @return {@link Promise} of {@link BasicOCSPResp} + * @return {@link Promise} of {@link BasicOCSPResp} */ - static Promise query(final X509Certificate x509Certificate, + static void query(final X509Certificate x509Certificate, final X509Certificate issuer, final boolean validateResponseNonce, - final IoTransport ioTransport, final DnsNameResolver dnsNameResolver) { + final IoTransport ioTransport, final DnsNameResolver dnsNameResolver, + final Promise responsePromise) { final EventLoop eventLoop = ioTransport.eventLoop(); - final Promise responsePromise = eventLoop.newPromise(); eventLoop.execute(new Runnable() { @Override public void run() { try { - CertificateID certificateID = new CertificateID(new JcaDigestCalculatorProviderBuilder() - .build().get(HASH_SHA1), new JcaX509CertificateHolder(issuer), + final DigestCalculatorProvider digestCalculatorProvider = new JcaDigestCalculatorProviderBuilder() + .build(); + + CertificateID certificateID = new CertificateID(digestCalculatorProvider.get(HASH_SHA1), + new JcaX509CertificateHolder(issuer), x509Certificate.getSerialNumber()); // Initialize OCSP Request Builder and add CertificateID into it. @@ -149,20 +155,30 @@ public void operationComplete(Future future) throws Exception { // If Future was successful then we have received OCSP response // We will now validate it. if (future.isSuccess()) { - BasicOCSPResp resp = (BasicOCSPResp) future.get().getResponseObject(); - validateResponse(responsePromise, resp, derNonce, issuer, validateResponseNonce); + final Object responseObject; + try { + responseObject = future.getNow().getResponseObject(); + } catch (OCSPException e) { + responsePromise.setFailure(future.cause()); + return; + } + if (responseObject instanceof BasicOCSPResp) { + validateResponse(x509Certificate, digestCalculatorProvider, responsePromise, + (BasicOCSPResp) responseObject, derNonce, issuer, validateResponseNonce); + } else { + responsePromise.tryFailure(new OCSPException("Unsupported OCSP response type: " + + (responseObject == null ? null : responseObject.getClass()))); + } } else { responsePromise.tryFailure(future.cause()); } } }); - } catch (Exception ex) { responsePromise.tryFailure(ex); } } }); - return responsePromise; } /** @@ -187,8 +203,7 @@ private static Promise query(final EventLoop eventLoop, final ByteBuf .option(ChannelOption.TCP_NODELAY, true) .channelFactory(ioTransport.socketChannel()) .attr(OcspServerCertificateValidator.OCSP_PIPELINE_ATTRIBUTE, Boolean.TRUE) - .handler(new Initializer(responsePromise)); - + .handler(new Initializer(responsePromise, 10 * 1000)); dnsNameResolver.resolve(host).addListener(new FutureListener() { @Override public void operationComplete(Future future) throws Exception { @@ -233,14 +248,26 @@ public void operationComplete(ChannelFuture future) { return responsePromise; } - private static void validateResponse(Promise responsePromise, BasicOCSPResp basicResponse, - DEROctetString derNonce, X509Certificate issuer, boolean validateNonce) { + private static void validateResponse( + X509Certificate x509Certificate, DigestCalculatorProvider digestCalculatorProvider, + Promise responsePromise, BasicOCSPResp basicResponse, + DEROctetString derNonce, X509Certificate issuer, boolean validateNonce) { try { // Validate number of responses. We only requested for 1 certificate // so number of responses must be 1. If not, we will throw an error. int responses = basicResponse.getResponses().length; if (responses != 1) { - throw new IllegalArgumentException("Expected number of responses was 1 but got: " + responses); + responsePromise.tryFailure( + new IllegalArgumentException("Expected number of responses was 1 but got: " + responses)); + return; + } + + CertificateID respCertId = basicResponse.getResponses()[0].getCertID(); + if (!respCertId.matchesIssuer(new JcaX509CertificateHolder(issuer), digestCalculatorProvider) + || !respCertId.getSerialNumber().equals(x509Certificate.getSerialNumber())) { + responsePromise.tryFailure( + new CertificateException("OCSP response CertID does not match queried certificate")); + return; } if (validateNonce) { @@ -314,9 +341,11 @@ private static String parseOcspUrlFromCertificate(X509Certificate cert) { static final class Initializer extends ChannelInitializer { private final Promise responsePromise; + private final long timeoutMillis; - Initializer(Promise responsePromise) { - this.responsePromise = checkNotNull(responsePromise, "ResponsePromise"); + Initializer(Promise responsePromise, long timeoutMillis) { + this.responsePromise = checkNotNull(responsePromise, "responsePromise"); + this.timeoutMillis = ObjectUtil.checkPositive(timeoutMillis, "timeoutMillis"); } @Override @@ -324,7 +353,7 @@ protected void initChannel(SocketChannel socketChannel) { ChannelPipeline pipeline = socketChannel.pipeline(); pipeline.addLast(new HttpClientCodec()); pipeline.addLast(new HttpObjectAggregator(OCSP_RESPONSE_MAX_SIZE)); - pipeline.addLast(new OcspHttpHandler(responsePromise)); + pipeline.addLast(new OcspHttpHandler(responsePromise, timeoutMillis)); } } diff --git a/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspHttpHandler.java b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspHttpHandler.java index dc04488903f..5c835ca0812 100644 --- a/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspHttpHandler.java +++ b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspHttpHandler.java @@ -16,38 +16,57 @@ package io.netty.handler.ssl.ocsp; import io.netty.buffer.ByteBufUtil; +import io.netty.channel.ChannelDuplexHandler; import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.ChannelPromise; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.GenericFutureListener; import io.netty.util.concurrent.Promise; +import io.netty.util.internal.ObjectUtil; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; import org.bouncycastle.cert.ocsp.OCSPException; import org.bouncycastle.cert.ocsp.OCSPResp; +import java.nio.channels.ClosedChannelException; +import java.util.concurrent.TimeUnit; + import static io.netty.handler.codec.http.HttpResponseStatus.OK; import static io.netty.util.internal.ObjectUtil.checkNotNull; -final class OcspHttpHandler extends SimpleChannelInboundHandler { +final class OcspHttpHandler extends ChannelDuplexHandler { private static final InternalLogger LOGGER = InternalLoggerFactory.getInstance(OcspHttpHandler.class); private final Promise responseFuture; - + private final long timeoutMillis; + private Future timeoutFuture; static final String OCSP_REQUEST_TYPE = "application/ocsp-request"; static final String OCSP_RESPONSE_TYPE = "application/ocsp-response"; /** * Create new {@link OcspHttpHandler} instance * - * @param responsePromise {@link Promise} of {@link OCSPResp} + * @param responsePromise {@link Promise} of {@link OCSPResp} + * @param timeoutMillis the timeout in milliseconds how long a response can take to before we fail the promise. */ - OcspHttpHandler(Promise responsePromise) { + OcspHttpHandler(Promise responsePromise, long timeoutMillis) { this.responseFuture = checkNotNull(responsePromise, "ResponsePromise"); + this.timeoutMillis = ObjectUtil.checkPositive(timeoutMillis, "timeoutMillis"); + this.responseFuture.addListener(new GenericFutureListener>() { + @Override + public void operationComplete(Future future) throws Exception { + if (timeoutFuture != null) { + timeoutFuture.cancel(true); + } + } + }); } @Override - protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse response) throws Exception { + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + FullHttpResponse response = (FullHttpResponse) msg; try { // If DEBUG is enabled then log the response if (LOGGER.isDebugEnabled()) { @@ -74,12 +93,37 @@ protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse response responseFuture.trySuccess(new OCSPResp(ByteBufUtil.getBytes(response.content()))); } finally { - ctx.channel().close(); + response.release(); + ctx.close(); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { responseFuture.tryFailure(cause); + ctx.close(); + } + + @Override + public void write(final ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + super.write(ctx, msg, promise); + timeoutFuture = ctx.executor().schedule(new Runnable() { + @Override + public void run() { + if (!responseFuture.isDone()) { + responseFuture.tryFailure(new OCSPException("OCSP response was not received within " + + timeoutMillis + "ms")); + ctx.close(); + } + } + }, timeoutMillis, TimeUnit.MILLISECONDS); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + if (!responseFuture.isDone()) { + responseFuture.tryFailure(new ClosedChannelException()); + } + super.channelInactive(ctx); } } diff --git a/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspServerCertificateValidator.java b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspServerCertificateValidator.java index 76b7bcf5621..861c0df70f6 100644 --- a/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspServerCertificateValidator.java +++ b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspServerCertificateValidator.java @@ -15,8 +15,11 @@ */ package io.netty.handler.ssl.ocsp; +import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelOutboundHandler; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.ByteToMessageDecoder; import io.netty.handler.ssl.SslHandler; import io.netty.handler.ssl.SslHandshakeCompletionEvent; import io.netty.resolver.dns.DnsNameResolver; @@ -30,9 +33,11 @@ import org.bouncycastle.cert.ocsp.RevokedStatus; import org.bouncycastle.cert.ocsp.SingleResp; +import java.net.SocketAddress; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.Date; +import java.util.List; import static io.netty.util.internal.ObjectUtil.checkNotNull; @@ -41,7 +46,7 @@ * using OCSP. Once TLS handshake is completed, {@link SslHandshakeCompletionEvent#SUCCESS} is fired, validator * will perform certificate validation using OCSP over HTTP/1.1 with the server's certificate issuer OCSP responder. */ -public class OcspServerCertificateValidator extends ChannelInboundHandlerAdapter { +public class OcspServerCertificateValidator extends ByteToMessageDecoder implements ChannelOutboundHandler { /** * An attribute used to mark all channels created by the {@link OcspServerCertificateValidator}. */ @@ -52,6 +57,8 @@ public class OcspServerCertificateValidator extends ChannelInboundHandlerAdapter private final boolean validateNonce; private final IoTransport ioTransport; private final DnsNameResolver dnsNameResolver; + private boolean ocspQueryInProgress; + private boolean readPending; /** * Create a new {@link OcspServerCertificateValidator} instance without nonce validation @@ -128,9 +135,12 @@ protected static DnsNameResolver createDefaultResolver(final IoTransport ioTrans } @Override - public void userEventTriggered(final ChannelHandlerContext ctx, final Object evt) throws Exception { - ctx.fireUserEventTriggered(evt); + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + // Just buffer until the handler is removed which will happen once we did finish the OCSP processing. + } + @Override + public void userEventTriggered(final ChannelHandlerContext ctx, final Object evt) throws Exception { if (evt instanceof SslHandshakeCompletionEvent) { SslHandshakeCompletionEvent sslHandshakeCompletionEvent = (SslHandshakeCompletionEvent) evt; @@ -144,57 +154,124 @@ public void userEventTriggered(final ChannelHandlerContext ctx, final Object evt assert certificates.length >= 2 : "There must an end-entity certificate and issuer certificate"; - Promise ocspRespPromise = OcspClient.query((X509Certificate) certificates[0], - (X509Certificate) certificates[1], validateNonce, ioTransport, dnsNameResolver); - + Promise ocspRespPromise = ctx.executor().newPromise(); + OcspClient.query((X509Certificate) certificates[0], (X509Certificate) certificates[1], + validateNonce, ioTransport, dnsNameResolver, ocspRespPromise); + ocspQueryInProgress = true; ocspRespPromise.addListener(new GenericFutureListener>() { @Override public void operationComplete(Future future) throws Exception { - // If Future is success then we have successfully received OCSP response - // from OCSP responder. We will validate it now and process. - if (future.isSuccess()) { - SingleResp response = future.get().getResponses()[0]; - - Date current = new Date(); - if (!(current.after(response.getThisUpdate()) && - current.before(response.getNextUpdate()))) { - ctx.fireExceptionCaught(new IllegalStateException("OCSP Response is out-of-date")); - } + ocspQueryInProgress = false; + try { + // If Future is success then we have successfully received OCSP response + // from OCSP responder. We will validate it now and process. + if (future.isSuccess()) { + SingleResp response = future.getNow().getResponses()[0]; - OcspResponse.Status status; - if (response.getCertStatus() == null) { - // 'null' means certificate is valid - status = OcspResponse.Status.VALID; - } else if (response.getCertStatus() instanceof RevokedStatus) { - status = OcspResponse.Status.REVOKED; - } else { - status = OcspResponse.Status.UNKNOWN; - } + Date current = new Date(); + Date thisUpdate = response.getThisUpdate(); + Date nextUpdate = response.getNextUpdate(); + if (thisUpdate == null || !current.after(thisUpdate) || + (nextUpdate != null && !current.before(nextUpdate))) { + ctx.fireExceptionCaught(new IllegalStateException("OCSP Response is out-of-date")); + return; + } + + OcspResponse.Status status; + if (response.getCertStatus() == null) { + // 'null' means certificate is valid + status = OcspResponse.Status.VALID; + } else if (response.getCertStatus() instanceof RevokedStatus) { + status = OcspResponse.Status.REVOKED; + } else { + status = OcspResponse.Status.UNKNOWN; + } - ctx.fireUserEventTriggered(new OcspValidationEvent( - new OcspResponse(status, response.getThisUpdate(), response.getNextUpdate()))); + ctx.fireUserEventTriggered(new OcspValidationEvent( + new OcspResponse(status, response.getThisUpdate(), response.getNextUpdate()))); - // If Certificate is not VALID and 'closeAndThrowIfNotValid' is set - // to 'true' then close the channel and throw an exception. - if (status != OcspResponse.Status.VALID && closeAndThrowIfNotValid) { - ctx.channel().close(); - // Certificate is not valid. Throw - ctx.fireExceptionCaught(new OCSPException( - "Certificate not valid. Status: " + status)); + // If Certificate is not VALID and 'closeAndThrowIfNotValid' is set + // to 'true' then close the channel and throw an exception. + if (status != OcspResponse.Status.VALID && closeAndThrowIfNotValid) { + // Certificate is not valid. Throw + ctx.fireExceptionCaught(new OCSPException( + "Certificate not valid. Status: " + status)); + ctx.close(); + } + } else { + ctx.fireExceptionCaught(future.cause()); + if (closeAndThrowIfNotValid) { + ctx.close(); + } + } + } finally { + ctx.fireUserEventTriggered(evt); + // Lets remove ourselves from the pipeline because we are done processing validation. + ctx.pipeline().remove(OcspServerCertificateValidator.this); + if (readPending) { + readPending = false; + ctx.read(); } - } else { - ctx.fireExceptionCaught(future.cause()); } } }); + } else { + ctx.fireUserEventTriggered(evt); } - // Lets remove ourselves from the pipeline because we are done processing validation. - ctx.pipeline().remove(this); + } else { + ctx.fireUserEventTriggered(evt); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - ctx.channel().close(); + ctx.close(); + } + + @Override + public void bind(ChannelHandlerContext ctx, SocketAddress localAddress, ChannelPromise promise) throws Exception { + ctx.bind(localAddress, promise); + } + + @Override + public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, + SocketAddress localAddress, ChannelPromise promise) throws Exception { + ctx.connect(remoteAddress, localAddress, promise); + } + + @Override + public void disconnect(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception { + ctx.disconnect(promise); + } + + @Override + public void close(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception { + ctx.close(promise); + } + + @Override + public void deregister(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception { + ctx.deregister(promise); + } + + @Override + public void read(ChannelHandlerContext ctx) throws Exception { + // Let's stop reading until we are done with the processing of the OCSP query. + if (ocspQueryInProgress) { + readPending = true; + } else { + readPending = false; + ctx.read(); + } + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + ctx.write(msg, promise); + } + + @Override + public void flush(ChannelHandlerContext ctx) throws Exception { + ctx.flush(); } } diff --git a/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspClientTest.java b/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspClientTest.java index 2be20b54152..11e4079737e 100644 --- a/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspClientTest.java +++ b/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspClientTest.java @@ -43,8 +43,9 @@ void simpleOcspQueryTest() throws IOException, ExecutionException, InterruptedEx X509Certificate serverCert = certs[0]; X509Certificate certIssuer = certs[1]; - Promise promise = OcspClient.query(serverCert, certIssuer, false, - IoTransport.DEFAULT, createDefaultResolver(IoTransport.DEFAULT)); + Promise promise = IoTransport.DEFAULT.eventLoop().newPromise(); + OcspClient.query(serverCert, certIssuer, false, + IoTransport.DEFAULT, createDefaultResolver(IoTransport.DEFAULT), promise); BasicOCSPResp basicOCSPResp = promise.get(); // 'null' means certificate is valid diff --git a/pom.xml b/pom.xml index 2762b55a723..a6bc89cfa8c 100644 --- a/pom.xml +++ b/pom.xml @@ -1625,6 +1625,59 @@ @io.netty.channel.ChannelHandlerMask.Skip Change is harmless for compatibility. Needed for a security fix. + + true + java.annotation.added + method void io.netty.handler.codec.spdy.SpdyHttpDecoder::channelInactive(io.netty.channel.ChannelHandlerContext) throws java.lang.Exception + method void io.netty.channel.ChannelInboundHandlerAdapter::channelInactive(io.netty.channel.ChannelHandlerContext) throws java.lang.Exception @ io.netty.handler.codec.spdy.SpdyHttpDecoder + @io.netty.channel.ChannelHandlerMask.Skip + Change is harmless for compatibility. Needed for a security fix. + + + true + java.annotation.removed + method void io.netty.channel.ChannelInboundHandlerAdapter::channelInactive(io.netty.channel.ChannelHandlerContext) throws java.lang.Exception @ io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + method void io.netty.handler.codec.ByteToMessageDecoder::channelInactive(io.netty.channel.ChannelHandlerContext) throws java.lang.Exception @ io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + @io.netty.channel.ChannelHandlerMask.Skip + Change is harmless for compatibility. Needed for a security fix. + + + true + java.annotation.removed + method void io.netty.channel.ChannelInboundHandlerAdapter::channelRead(io.netty.channel.ChannelHandlerContext, java.lang.Object) throws java.lang.Exception @ io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + method void io.netty.handler.codec.ByteToMessageDecoder::channelRead(io.netty.channel.ChannelHandlerContext, java.lang.Object) throws java.lang.Exception @ io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + @io.netty.channel.ChannelHandlerMask.Skip + Change is harmless for compatibility. Needed for a security fix. + + + true + java.annotation.removed + method void io.netty.channel.ChannelInboundHandlerAdapter::channelReadComplete(io.netty.channel.ChannelHandlerContext) throws java.lang.Exception @ io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + method void io.netty.handler.codec.ByteToMessageDecoder::channelReadComplete(io.netty.channel.ChannelHandlerContext) throws java.lang.Exception @ io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + @io.netty.channel.ChannelHandlerMask.Skip + Change is harmless for compatibility. Needed for a security fix. + + + true + java.method.finalMethodAddedToNonFinalClass + method void io.netty.handler.codec.ByteToMessageDecoder::discardSomeReadBytes() @ io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + Change is tolerated for compatibility. Part of a security fix. + + + true + java.method.nowFinal + method void io.netty.channel.ChannelHandlerAdapter::handlerRemoved(io.netty.channel.ChannelHandlerContext) throws java.lang.Exception @ io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + method void io.netty.handler.codec.ByteToMessageDecoder::handlerRemoved(io.netty.channel.ChannelHandlerContext) throws java.lang.Exception @ io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + Change is tolerated for compatibility. Part of a security fix. + + + true + java.class.nonFinalClassInheritsFromNewClass + class io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + class io.netty.handler.ssl.ocsp.OcspServerCertificateValidator + io.netty.handler.codec.ByteToMessageDecoder + Change is tolerated for compatibility. Part of a security fix. + From fca0764703b3bb59c6e6dc5d29c6d9710d35c0e6 Mon Sep 17 00:00:00 2001 From: Netty Project Bot Date: Wed, 8 Jul 2026 19:14:18 +0000 Subject: [PATCH 32/64] [maven-release-plugin] prepare release netty-4.1.136.Final --- all/pom.xml | 2 +- bom/pom.xml | 4 ++-- buffer/pom.xml | 2 +- codec-dns/pom.xml | 2 +- codec-haproxy/pom.xml | 2 +- codec-http/pom.xml | 2 +- codec-http2/pom.xml | 2 +- codec-memcache/pom.xml | 2 +- codec-mqtt/pom.xml | 2 +- codec-redis/pom.xml | 2 +- codec-smtp/pom.xml | 2 +- codec-socks/pom.xml | 2 +- codec-stomp/pom.xml | 2 +- codec-xml/pom.xml | 2 +- codec/pom.xml | 2 +- common/pom.xml | 2 +- dev-tools/pom.xml | 2 +- example/pom.xml | 2 +- handler-proxy/pom.xml | 2 +- handler-ssl-ocsp/pom.xml | 2 +- handler/pom.xml | 2 +- microbench/pom.xml | 2 +- pom.xml | 4 ++-- resolver-dns-classes-macos/pom.xml | 2 +- resolver-dns-native-macos/pom.xml | 2 +- resolver-dns/pom.xml | 2 +- resolver/pom.xml | 2 +- testsuite-autobahn/pom.xml | 2 +- testsuite-http2/pom.xml | 2 +- testsuite-native-image-client-runtime-init/pom.xml | 2 +- testsuite-native-image-client/pom.xml | 2 +- testsuite-native-image/pom.xml | 2 +- testsuite-native/pom.xml | 2 +- testsuite-osgi/pom.xml | 2 +- testsuite-shading/pom.xml | 2 +- testsuite/pom.xml | 2 +- transport-blockhound-tests/pom.xml | 2 +- transport-classes-epoll/pom.xml | 2 +- transport-classes-kqueue/pom.xml | 2 +- transport-native-epoll/pom.xml | 2 +- transport-native-kqueue/pom.xml | 2 +- transport-native-unix-common-tests/pom.xml | 2 +- transport-native-unix-common/pom.xml | 2 +- transport-rxtx/pom.xml | 2 +- transport-sctp/pom.xml | 2 +- transport-udt/pom.xml | 2 +- transport/pom.xml | 2 +- 47 files changed, 49 insertions(+), 49 deletions(-) diff --git a/all/pom.xml b/all/pom.xml index 416982a55aa..13f5eaa6829 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-all diff --git a/bom/pom.xml b/bom/pom.xml index 9e149adbfe5..aec5a513158 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -25,7 +25,7 @@ io.netty netty-bom - 4.1.136.Final-SNAPSHOT + 4.1.136.Final pom Netty/BOM @@ -49,7 +49,7 @@ https://github.com/netty/netty scm:git:git://github.com/netty/netty.git scm:git:ssh://git@github.com/netty/netty.git - HEAD + netty-4.1.136.Final diff --git a/buffer/pom.xml b/buffer/pom.xml index 3147b039745..bd46738cb6d 100644 --- a/buffer/pom.xml +++ b/buffer/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-buffer diff --git a/codec-dns/pom.xml b/codec-dns/pom.xml index 7c3dbdf11a0..b5666af19a9 100644 --- a/codec-dns/pom.xml +++ b/codec-dns/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-dns diff --git a/codec-haproxy/pom.xml b/codec-haproxy/pom.xml index c610bced002..ef0d9f0e597 100644 --- a/codec-haproxy/pom.xml +++ b/codec-haproxy/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-haproxy diff --git a/codec-http/pom.xml b/codec-http/pom.xml index f74caa386bc..17fe4ee21fa 100644 --- a/codec-http/pom.xml +++ b/codec-http/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-http diff --git a/codec-http2/pom.xml b/codec-http2/pom.xml index 6afce399178..d3cffabab3a 100644 --- a/codec-http2/pom.xml +++ b/codec-http2/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-http2 diff --git a/codec-memcache/pom.xml b/codec-memcache/pom.xml index 05d21ea8eb5..8a2b2e4958d 100644 --- a/codec-memcache/pom.xml +++ b/codec-memcache/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-memcache diff --git a/codec-mqtt/pom.xml b/codec-mqtt/pom.xml index afbd86140f9..c21bddc33fe 100644 --- a/codec-mqtt/pom.xml +++ b/codec-mqtt/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-mqtt diff --git a/codec-redis/pom.xml b/codec-redis/pom.xml index 721e848c5e8..008848444a7 100644 --- a/codec-redis/pom.xml +++ b/codec-redis/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-redis diff --git a/codec-smtp/pom.xml b/codec-smtp/pom.xml index 5c415fe4a79..24a718184a6 100644 --- a/codec-smtp/pom.xml +++ b/codec-smtp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-smtp diff --git a/codec-socks/pom.xml b/codec-socks/pom.xml index 65d5a5b747d..d6303bfbc57 100644 --- a/codec-socks/pom.xml +++ b/codec-socks/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-socks diff --git a/codec-stomp/pom.xml b/codec-stomp/pom.xml index e81b45807cb..b359e09407c 100644 --- a/codec-stomp/pom.xml +++ b/codec-stomp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-stomp diff --git a/codec-xml/pom.xml b/codec-xml/pom.xml index db24ee9831b..06a3dfb99b5 100644 --- a/codec-xml/pom.xml +++ b/codec-xml/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec-xml diff --git a/codec/pom.xml b/codec/pom.xml index 6538a110f5e..916f05e65cc 100644 --- a/codec/pom.xml +++ b/codec/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-codec diff --git a/common/pom.xml b/common/pom.xml index a2b3bec19c2..3d89945cec4 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-common diff --git a/dev-tools/pom.xml b/dev-tools/pom.xml index b87feb44fd4..0652636afdc 100644 --- a/dev-tools/pom.xml +++ b/dev-tools/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-dev-tools diff --git a/example/pom.xml b/example/pom.xml index 288e2d82547..445a63f6c24 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-example diff --git a/handler-proxy/pom.xml b/handler-proxy/pom.xml index 89a75978298..4043e07e802 100644 --- a/handler-proxy/pom.xml +++ b/handler-proxy/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-handler-proxy diff --git a/handler-ssl-ocsp/pom.xml b/handler-ssl-ocsp/pom.xml index 3791a8679a2..686a5997c2a 100644 --- a/handler-ssl-ocsp/pom.xml +++ b/handler-ssl-ocsp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-handler-ssl-ocsp diff --git a/handler/pom.xml b/handler/pom.xml index 3b1ce7cff83..a217c1934eb 100644 --- a/handler/pom.xml +++ b/handler/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-handler diff --git a/microbench/pom.xml b/microbench/pom.xml index 3a4d23e249c..1cddadc5ac5 100644 --- a/microbench/pom.xml +++ b/microbench/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-microbench diff --git a/pom.xml b/pom.xml index a6bc89cfa8c..7864dd47e8e 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ io.netty netty-parent pom - 4.1.136.Final-SNAPSHOT + 4.1.136.Final Netty https://netty.io/ @@ -53,7 +53,7 @@ https://github.com/netty/netty scm:git:git://github.com/netty/netty.git scm:git:ssh://git@github.com/netty/netty.git - HEAD + netty-4.1.136.Final diff --git a/resolver-dns-classes-macos/pom.xml b/resolver-dns-classes-macos/pom.xml index 9384ad80e3d..3aa1a342b93 100644 --- a/resolver-dns-classes-macos/pom.xml +++ b/resolver-dns-classes-macos/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-resolver-dns-classes-macos diff --git a/resolver-dns-native-macos/pom.xml b/resolver-dns-native-macos/pom.xml index 801e4eb57bf..b4b057c20c2 100644 --- a/resolver-dns-native-macos/pom.xml +++ b/resolver-dns-native-macos/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-resolver-dns-native-macos diff --git a/resolver-dns/pom.xml b/resolver-dns/pom.xml index d46d5c397d2..a20cfabd59d 100644 --- a/resolver-dns/pom.xml +++ b/resolver-dns/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-resolver-dns diff --git a/resolver/pom.xml b/resolver/pom.xml index 1d9f846b6ce..9e28f794d68 100644 --- a/resolver/pom.xml +++ b/resolver/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-resolver diff --git a/testsuite-autobahn/pom.xml b/testsuite-autobahn/pom.xml index 7e5560b16e6..e0403fc0260 100644 --- a/testsuite-autobahn/pom.xml +++ b/testsuite-autobahn/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-testsuite-autobahn diff --git a/testsuite-http2/pom.xml b/testsuite-http2/pom.xml index ad05f562747..ee93ad4fe9b 100644 --- a/testsuite-http2/pom.xml +++ b/testsuite-http2/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-testsuite-http2 diff --git a/testsuite-native-image-client-runtime-init/pom.xml b/testsuite-native-image-client-runtime-init/pom.xml index 7d351cea009..51b734f7bd8 100644 --- a/testsuite-native-image-client-runtime-init/pom.xml +++ b/testsuite-native-image-client-runtime-init/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-testsuite-native-image-client-runtime-init diff --git a/testsuite-native-image-client/pom.xml b/testsuite-native-image-client/pom.xml index 263bda53834..1c2aa499d11 100644 --- a/testsuite-native-image-client/pom.xml +++ b/testsuite-native-image-client/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-testsuite-native-image-client diff --git a/testsuite-native-image/pom.xml b/testsuite-native-image/pom.xml index 86c76aeecfa..4263987690e 100644 --- a/testsuite-native-image/pom.xml +++ b/testsuite-native-image/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-testsuite-native-image diff --git a/testsuite-native/pom.xml b/testsuite-native/pom.xml index 3493b181b15..a24886ed4ad 100644 --- a/testsuite-native/pom.xml +++ b/testsuite-native/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-testsuite-native diff --git a/testsuite-osgi/pom.xml b/testsuite-osgi/pom.xml index c2fe1f0fc13..17460bd1042 100644 --- a/testsuite-osgi/pom.xml +++ b/testsuite-osgi/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-testsuite-osgi diff --git a/testsuite-shading/pom.xml b/testsuite-shading/pom.xml index a447fb79fdf..468afabaebe 100644 --- a/testsuite-shading/pom.xml +++ b/testsuite-shading/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-testsuite-shading diff --git a/testsuite/pom.xml b/testsuite/pom.xml index 5a07dd30aae..414a7dbf424 100644 --- a/testsuite/pom.xml +++ b/testsuite/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-testsuite diff --git a/transport-blockhound-tests/pom.xml b/transport-blockhound-tests/pom.xml index 5ad1d954c86..9df065b45b8 100644 --- a/transport-blockhound-tests/pom.xml +++ b/transport-blockhound-tests/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-blockhound-tests diff --git a/transport-classes-epoll/pom.xml b/transport-classes-epoll/pom.xml index eeafc8819b2..23bbd8bd452 100644 --- a/transport-classes-epoll/pom.xml +++ b/transport-classes-epoll/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-classes-epoll diff --git a/transport-classes-kqueue/pom.xml b/transport-classes-kqueue/pom.xml index 65f4d75ae10..88d397e9996 100644 --- a/transport-classes-kqueue/pom.xml +++ b/transport-classes-kqueue/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-classes-kqueue diff --git a/transport-native-epoll/pom.xml b/transport-native-epoll/pom.xml index 878dddc6714..13961117b66 100644 --- a/transport-native-epoll/pom.xml +++ b/transport-native-epoll/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-native-epoll diff --git a/transport-native-kqueue/pom.xml b/transport-native-kqueue/pom.xml index 7c226f3bc7c..fb5bcce5e45 100644 --- a/transport-native-kqueue/pom.xml +++ b/transport-native-kqueue/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-native-kqueue diff --git a/transport-native-unix-common-tests/pom.xml b/transport-native-unix-common-tests/pom.xml index 76141d4f1b5..ad901b8a791 100644 --- a/transport-native-unix-common-tests/pom.xml +++ b/transport-native-unix-common-tests/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-native-unix-common-tests diff --git a/transport-native-unix-common/pom.xml b/transport-native-unix-common/pom.xml index f84d708f384..2a8418a2f6d 100644 --- a/transport-native-unix-common/pom.xml +++ b/transport-native-unix-common/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-native-unix-common diff --git a/transport-rxtx/pom.xml b/transport-rxtx/pom.xml index 4bc688ce5d1..394124bc156 100644 --- a/transport-rxtx/pom.xml +++ b/transport-rxtx/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-rxtx diff --git a/transport-sctp/pom.xml b/transport-sctp/pom.xml index 42d466325da..13dfe12525a 100644 --- a/transport-sctp/pom.xml +++ b/transport-sctp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-sctp diff --git a/transport-udt/pom.xml b/transport-udt/pom.xml index 2ea026fc40a..f8a5edb2790 100644 --- a/transport-udt/pom.xml +++ b/transport-udt/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport-udt diff --git a/transport/pom.xml b/transport/pom.xml index 0920d34ae35..8011284e8e7 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final-SNAPSHOT + 4.1.136.Final netty-transport From 716361857368203e189ddc3d54e2050862f1250c Mon Sep 17 00:00:00 2001 From: Netty Project Bot Date: Wed, 8 Jul 2026 19:14:22 +0000 Subject: [PATCH 33/64] [maven-release-plugin] prepare for next development iteration --- all/pom.xml | 2 +- bom/pom.xml | 4 ++-- buffer/pom.xml | 2 +- codec-dns/pom.xml | 2 +- codec-haproxy/pom.xml | 2 +- codec-http/pom.xml | 2 +- codec-http2/pom.xml | 2 +- codec-memcache/pom.xml | 2 +- codec-mqtt/pom.xml | 2 +- codec-redis/pom.xml | 2 +- codec-smtp/pom.xml | 2 +- codec-socks/pom.xml | 2 +- codec-stomp/pom.xml | 2 +- codec-xml/pom.xml | 2 +- codec/pom.xml | 2 +- common/pom.xml | 2 +- dev-tools/pom.xml | 2 +- example/pom.xml | 2 +- handler-proxy/pom.xml | 2 +- handler-ssl-ocsp/pom.xml | 2 +- handler/pom.xml | 2 +- microbench/pom.xml | 2 +- pom.xml | 4 ++-- resolver-dns-classes-macos/pom.xml | 2 +- resolver-dns-native-macos/pom.xml | 2 +- resolver-dns/pom.xml | 2 +- resolver/pom.xml | 2 +- testsuite-autobahn/pom.xml | 2 +- testsuite-http2/pom.xml | 2 +- testsuite-native-image-client-runtime-init/pom.xml | 2 +- testsuite-native-image-client/pom.xml | 2 +- testsuite-native-image/pom.xml | 2 +- testsuite-native/pom.xml | 2 +- testsuite-osgi/pom.xml | 2 +- testsuite-shading/pom.xml | 2 +- testsuite/pom.xml | 2 +- transport-blockhound-tests/pom.xml | 2 +- transport-classes-epoll/pom.xml | 2 +- transport-classes-kqueue/pom.xml | 2 +- transport-native-epoll/pom.xml | 2 +- transport-native-kqueue/pom.xml | 2 +- transport-native-unix-common-tests/pom.xml | 2 +- transport-native-unix-common/pom.xml | 2 +- transport-rxtx/pom.xml | 2 +- transport-sctp/pom.xml | 2 +- transport-udt/pom.xml | 2 +- transport/pom.xml | 2 +- 47 files changed, 49 insertions(+), 49 deletions(-) diff --git a/all/pom.xml b/all/pom.xml index 13f5eaa6829..338fab77596 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-all diff --git a/bom/pom.xml b/bom/pom.xml index aec5a513158..5e85cfc1bc7 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -25,7 +25,7 @@ io.netty netty-bom - 4.1.136.Final + 4.1.137.Final-SNAPSHOT pom Netty/BOM @@ -49,7 +49,7 @@ https://github.com/netty/netty scm:git:git://github.com/netty/netty.git scm:git:ssh://git@github.com/netty/netty.git - netty-4.1.136.Final + HEAD diff --git a/buffer/pom.xml b/buffer/pom.xml index bd46738cb6d..865bb5e291e 100644 --- a/buffer/pom.xml +++ b/buffer/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-buffer diff --git a/codec-dns/pom.xml b/codec-dns/pom.xml index b5666af19a9..e3f4b693e72 100644 --- a/codec-dns/pom.xml +++ b/codec-dns/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-dns diff --git a/codec-haproxy/pom.xml b/codec-haproxy/pom.xml index ef0d9f0e597..7dbae4be620 100644 --- a/codec-haproxy/pom.xml +++ b/codec-haproxy/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-haproxy diff --git a/codec-http/pom.xml b/codec-http/pom.xml index 17fe4ee21fa..b5f814888bd 100644 --- a/codec-http/pom.xml +++ b/codec-http/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-http diff --git a/codec-http2/pom.xml b/codec-http2/pom.xml index d3cffabab3a..e169e89ad82 100644 --- a/codec-http2/pom.xml +++ b/codec-http2/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-http2 diff --git a/codec-memcache/pom.xml b/codec-memcache/pom.xml index 8a2b2e4958d..2bebfb0d495 100644 --- a/codec-memcache/pom.xml +++ b/codec-memcache/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-memcache diff --git a/codec-mqtt/pom.xml b/codec-mqtt/pom.xml index c21bddc33fe..c9e2b8168ec 100644 --- a/codec-mqtt/pom.xml +++ b/codec-mqtt/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-mqtt diff --git a/codec-redis/pom.xml b/codec-redis/pom.xml index 008848444a7..33b22f861b7 100644 --- a/codec-redis/pom.xml +++ b/codec-redis/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-redis diff --git a/codec-smtp/pom.xml b/codec-smtp/pom.xml index 24a718184a6..0a456f01820 100644 --- a/codec-smtp/pom.xml +++ b/codec-smtp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-smtp diff --git a/codec-socks/pom.xml b/codec-socks/pom.xml index d6303bfbc57..cb2d3a210c6 100644 --- a/codec-socks/pom.xml +++ b/codec-socks/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-socks diff --git a/codec-stomp/pom.xml b/codec-stomp/pom.xml index b359e09407c..bee37764ba0 100644 --- a/codec-stomp/pom.xml +++ b/codec-stomp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-stomp diff --git a/codec-xml/pom.xml b/codec-xml/pom.xml index 06a3dfb99b5..60e90d39ff1 100644 --- a/codec-xml/pom.xml +++ b/codec-xml/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec-xml diff --git a/codec/pom.xml b/codec/pom.xml index 916f05e65cc..3e8ee32b1e5 100644 --- a/codec/pom.xml +++ b/codec/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-codec diff --git a/common/pom.xml b/common/pom.xml index 3d89945cec4..a0bf8e3228c 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-common diff --git a/dev-tools/pom.xml b/dev-tools/pom.xml index 0652636afdc..8868d74454d 100644 --- a/dev-tools/pom.xml +++ b/dev-tools/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-dev-tools diff --git a/example/pom.xml b/example/pom.xml index 445a63f6c24..ed76f3d66e2 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-example diff --git a/handler-proxy/pom.xml b/handler-proxy/pom.xml index 4043e07e802..615c5f9abcf 100644 --- a/handler-proxy/pom.xml +++ b/handler-proxy/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-handler-proxy diff --git a/handler-ssl-ocsp/pom.xml b/handler-ssl-ocsp/pom.xml index 686a5997c2a..14e9179b056 100644 --- a/handler-ssl-ocsp/pom.xml +++ b/handler-ssl-ocsp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-handler-ssl-ocsp diff --git a/handler/pom.xml b/handler/pom.xml index a217c1934eb..eb1cb0ca338 100644 --- a/handler/pom.xml +++ b/handler/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-handler diff --git a/microbench/pom.xml b/microbench/pom.xml index 1cddadc5ac5..9120c82db7d 100644 --- a/microbench/pom.xml +++ b/microbench/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-microbench diff --git a/pom.xml b/pom.xml index 7864dd47e8e..e11b03a5bb2 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ io.netty netty-parent pom - 4.1.136.Final + 4.1.137.Final-SNAPSHOT Netty https://netty.io/ @@ -53,7 +53,7 @@ https://github.com/netty/netty scm:git:git://github.com/netty/netty.git scm:git:ssh://git@github.com/netty/netty.git - netty-4.1.136.Final + HEAD diff --git a/resolver-dns-classes-macos/pom.xml b/resolver-dns-classes-macos/pom.xml index 3aa1a342b93..84ff71cef29 100644 --- a/resolver-dns-classes-macos/pom.xml +++ b/resolver-dns-classes-macos/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-resolver-dns-classes-macos diff --git a/resolver-dns-native-macos/pom.xml b/resolver-dns-native-macos/pom.xml index b4b057c20c2..3b729c98ded 100644 --- a/resolver-dns-native-macos/pom.xml +++ b/resolver-dns-native-macos/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-resolver-dns-native-macos diff --git a/resolver-dns/pom.xml b/resolver-dns/pom.xml index a20cfabd59d..a232555744b 100644 --- a/resolver-dns/pom.xml +++ b/resolver-dns/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-resolver-dns diff --git a/resolver/pom.xml b/resolver/pom.xml index 9e28f794d68..a71e22c6160 100644 --- a/resolver/pom.xml +++ b/resolver/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-resolver diff --git a/testsuite-autobahn/pom.xml b/testsuite-autobahn/pom.xml index e0403fc0260..cf7129085dd 100644 --- a/testsuite-autobahn/pom.xml +++ b/testsuite-autobahn/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-testsuite-autobahn diff --git a/testsuite-http2/pom.xml b/testsuite-http2/pom.xml index ee93ad4fe9b..d1422cff66e 100644 --- a/testsuite-http2/pom.xml +++ b/testsuite-http2/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-testsuite-http2 diff --git a/testsuite-native-image-client-runtime-init/pom.xml b/testsuite-native-image-client-runtime-init/pom.xml index 51b734f7bd8..a07bfd43124 100644 --- a/testsuite-native-image-client-runtime-init/pom.xml +++ b/testsuite-native-image-client-runtime-init/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-testsuite-native-image-client-runtime-init diff --git a/testsuite-native-image-client/pom.xml b/testsuite-native-image-client/pom.xml index 1c2aa499d11..1071b54314b 100644 --- a/testsuite-native-image-client/pom.xml +++ b/testsuite-native-image-client/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-testsuite-native-image-client diff --git a/testsuite-native-image/pom.xml b/testsuite-native-image/pom.xml index 4263987690e..3757b720504 100644 --- a/testsuite-native-image/pom.xml +++ b/testsuite-native-image/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-testsuite-native-image diff --git a/testsuite-native/pom.xml b/testsuite-native/pom.xml index a24886ed4ad..306b46d69e0 100644 --- a/testsuite-native/pom.xml +++ b/testsuite-native/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-testsuite-native diff --git a/testsuite-osgi/pom.xml b/testsuite-osgi/pom.xml index 17460bd1042..d7c40c44256 100644 --- a/testsuite-osgi/pom.xml +++ b/testsuite-osgi/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-testsuite-osgi diff --git a/testsuite-shading/pom.xml b/testsuite-shading/pom.xml index 468afabaebe..005b479897e 100644 --- a/testsuite-shading/pom.xml +++ b/testsuite-shading/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-testsuite-shading diff --git a/testsuite/pom.xml b/testsuite/pom.xml index 414a7dbf424..e963f6d635f 100644 --- a/testsuite/pom.xml +++ b/testsuite/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-testsuite diff --git a/transport-blockhound-tests/pom.xml b/transport-blockhound-tests/pom.xml index 9df065b45b8..15a729da9b9 100644 --- a/transport-blockhound-tests/pom.xml +++ b/transport-blockhound-tests/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-blockhound-tests diff --git a/transport-classes-epoll/pom.xml b/transport-classes-epoll/pom.xml index 23bbd8bd452..da7b79aa5ab 100644 --- a/transport-classes-epoll/pom.xml +++ b/transport-classes-epoll/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-classes-epoll diff --git a/transport-classes-kqueue/pom.xml b/transport-classes-kqueue/pom.xml index 88d397e9996..6af6a290431 100644 --- a/transport-classes-kqueue/pom.xml +++ b/transport-classes-kqueue/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-classes-kqueue diff --git a/transport-native-epoll/pom.xml b/transport-native-epoll/pom.xml index 13961117b66..0176294101b 100644 --- a/transport-native-epoll/pom.xml +++ b/transport-native-epoll/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-native-epoll diff --git a/transport-native-kqueue/pom.xml b/transport-native-kqueue/pom.xml index fb5bcce5e45..e5c8a514fe2 100644 --- a/transport-native-kqueue/pom.xml +++ b/transport-native-kqueue/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-native-kqueue diff --git a/transport-native-unix-common-tests/pom.xml b/transport-native-unix-common-tests/pom.xml index ad901b8a791..de9d1698e7b 100644 --- a/transport-native-unix-common-tests/pom.xml +++ b/transport-native-unix-common-tests/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-native-unix-common-tests diff --git a/transport-native-unix-common/pom.xml b/transport-native-unix-common/pom.xml index 2a8418a2f6d..8c14ad892b2 100644 --- a/transport-native-unix-common/pom.xml +++ b/transport-native-unix-common/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-native-unix-common diff --git a/transport-rxtx/pom.xml b/transport-rxtx/pom.xml index 394124bc156..31ae29ecd60 100644 --- a/transport-rxtx/pom.xml +++ b/transport-rxtx/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-rxtx diff --git a/transport-sctp/pom.xml b/transport-sctp/pom.xml index 13dfe12525a..c76950b3216 100644 --- a/transport-sctp/pom.xml +++ b/transport-sctp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-sctp diff --git a/transport-udt/pom.xml b/transport-udt/pom.xml index f8a5edb2790..184e4ebae9c 100644 --- a/transport-udt/pom.xml +++ b/transport-udt/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport-udt diff --git a/transport/pom.xml b/transport/pom.xml index 8011284e8e7..32ddbe7bbfd 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.136.Final + 4.1.137.Final-SNAPSHOT netty-transport From f25763016404cad46ed255b9b20f637139749b7f Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:57:33 +0200 Subject: [PATCH 34/64] Auto-port 4.1: AsciiString.cached(String) should sanitize the provided String (#13749) (#17069) Auto-port of #17007 to 4.1 Cherry-picked commit: ef1097214ffa30398f7a4ea97b268a755c31d38e --- ## Motivation `AsciiString.cached(String)` stored the original String reference directly in `asciiString.string`, bypassing the `c2b()` Latin-1 byte conversion performed in the constructor. When strings contained non-Latin-1 characters (>255), the byte array would get `'?'` in those positions (via `c2b` truncation), but `toString()` still returned the unsanitized original. This broke the invariant that the cached string matches the Latin-1 byte content exactly, preventing use of the cached string for fast equality comparisons (e.g., `String::contentEquals` with vectorized JVM intrinsics). ## Modification In `AsciiString.cached(String)`, the method now first sets the cached string to the original input (preserving identity for fast `equals()` checks), then scans the input for non-Latin-1 characters. If any are found, the cached string is reconstructed from the byte array via `toString(0)` to ensure consistency with the Latin-1 byte content. The Javadoc was updated to document this behavior. Added 6 test methods (`AsciiStringCharacterTest.java`) covering pure ASCII, Latin-1, non-Latin-1 sanitization, empty strings, byte-content round-trip, and real-world constant preservation. Tests for pure ASCII and Latin-1 strings assert that the original String identity is preserved (`assertSame`). ## Result For pure ASCII/Latin-1 inputs, the original String identity is preserved (enabling faster equality checks for interned constants). For non-Latin-1 inputs, the cached string is sanitized to match the Latin-1 byte content. Closes #13749 Co-authored-by: Vasily Pelikh Co-authored-by: Norman Maurer --- .../main/java/io/netty/util/AsciiString.java | 17 ++++- .../netty/util/AsciiStringCharacterTest.java | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/io/netty/util/AsciiString.java b/common/src/main/java/io/netty/util/AsciiString.java index 5c0d07dbf2d..9c7d7fdb1ee 100644 --- a/common/src/main/java/io/netty/util/AsciiString.java +++ b/common/src/main/java/io/netty/util/AsciiString.java @@ -1386,14 +1386,27 @@ public static AsciiString of(CharSequence string) { } /** - * Returns an {@link AsciiString} containing the given string and retains/caches the input - * string for later use in {@link #toString()}. + * Returns an {@link AsciiString} containing the given string and retains/caches the + * input string for later use in {@link #toString()}. + * If the input contains only Latin-1 characters (0-255), the original string is reused + * to preserve identity for faster equality checks. Otherwise, the string is reconstructed + * from the Latin-1 byte content to guarantee consistency (the constructor's {@link #c2b(char)} + * converts non-Latin-1 characters to {@code '?'}). * Used for the constants (which already stored in the JVM's string table) and in cases * where the guaranteed use of the {@link #toString()} method. */ public static AsciiString cached(String string) { AsciiString asciiString = new AsciiString(string); asciiString.string = string; + // Check if the input contains any non-Latin-1 characters that would have been + // truncated to '?' by c2b() during construction. If so, reconstruct the cached + // string from the byte array to ensure consistency. + for (int i = 0; i < string.length(); i++) { + if (string.charAt(i) > MAX_CHAR_VALUE) { + asciiString.string = asciiString.toString(0); + break; + } + } return asciiString; } diff --git a/common/src/test/java/io/netty/util/AsciiStringCharacterTest.java b/common/src/test/java/io/netty/util/AsciiStringCharacterTest.java index f009e483c02..a7c922a9e6d 100644 --- a/common/src/test/java/io/netty/util/AsciiStringCharacterTest.java +++ b/common/src/test/java/io/netty/util/AsciiStringCharacterTest.java @@ -550,4 +550,66 @@ public void testRegionMatchesAsciiHandlesOutOfBounds() { assertFalse(AsciiString.regionMatchesAscii(str, false, -1, hello, 0, 5)); assertFalse(AsciiString.regionMatchesAscii(str, false, 0, hello, -1, 5)); } + + @Test + public void testCachedWithAsciiString() { + // Pure ASCII strings should reuse the original string to preserve identity + String ascii = "hello"; + AsciiString cached = AsciiString.cached(ascii); + assertEquals(ascii, cached.toString()); + assertSame(ascii, cached.toString()); + assertEquals(ascii.length(), cached.length()); + assertTrue(cached.contentEquals(ascii)); + } + + @Test + public void testCachedWithAsciiLatin1String() { + // Latin-1 strings (chars 128-255) should reuse the original string to preserve identity + String latin1 = "h" + (char) 233 + "llo"; // héllo + AsciiString cached = AsciiString.cached(latin1); + assertEquals(latin1, cached.toString()); + assertSame(latin1, cached.toString()); + assertEquals(latin1.length(), cached.length()); + assertTrue(cached.contentEquals(latin1)); + } + + @Test + public void testCachedSanitizesNonLatin1String() { + // Chars > 255 should be sanitized to '?' in the cached string to match the byte content + String nonLatin1 = "test" + (char) 0x1234 + "ing"; + AsciiString cached = AsciiString.cached(nonLatin1); + // The char 0x1234 gets converted to '?' by c2b, so toString should reflect that + assertEquals("test?ing", cached.toString()); + } + + @Test + public void testCachedEmptyString() { + AsciiString cached = AsciiString.cached(""); + assertEquals("", cached.toString()); + assertTrue(cached.isEmpty()); + } + + @Test + public void testCachedStringMatchesByteContent() { + // The cached string should always match the byte content round-trip + String nonLatin1 = "a" + (char) 0x4321 + "b"; + AsciiString cached = AsciiString.cached(nonLatin1); + // Manually compute the expected sanitized string from the byte array + StringBuilder expected = new StringBuilder(); + for (byte b : cached.toByteArray()) { + expected.append((char) (b & 0xFF)); + } + assertEquals(expected.toString(), cached.toString()); + } + + @Test + public void testCachedWithAllAsciiConstants() { + // Constants used in the codebase should be unaffected + AsciiString host = AsciiString.cached("host"); + assertEquals("host", host.toString()); + AsciiString method = AsciiString.cached(":method"); + assertEquals(":method", method.toString()); + AsciiString status = AsciiString.cached(":status"); + assertEquals(":status", status.toString()); + } } From 24c2e3872d00c0df56da2c98215b8fdd66fa6096 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Thu, 9 Jul 2026 21:42:02 +0200 Subject: [PATCH 35/64] AsciiString.cached(String) should sanitize the provided String (#13749) (#17007) (#17075) ## Motivation `AsciiString.cached(String)` stored the original String reference directly in `asciiString.string`, bypassing the `c2b()` Latin-1 byte conversion performed in the constructor. When strings contained non-Latin-1 characters (>255), the byte array would get `'?'` in those positions (via `c2b` truncation), but `toString()` still returned the unsanitized original. This broke the invariant that the cached string matches the Latin-1 byte content exactly, preventing use of the cached string for fast equality comparisons (e.g., `String::contentEquals` with vectorized JVM intrinsics). ## Modification In `AsciiString.cached(String)`, the method now first sets the cached string to the original input (preserving identity for fast `equals()` checks), then scans the input for non-Latin-1 characters. If any are found, the cached string is reconstructed from the byte array via `toString(0)` to ensure consistency with the Latin-1 byte content. The Javadoc was updated to document this behavior. Added 6 test methods (`AsciiStringCharacterTest.java`) covering pure ASCII, Latin-1, non-Latin-1 sanitization, empty strings, byte-content round-trip, and real-world constant preservation. Tests for pure ASCII and Latin-1 strings assert that the original String identity is preserved (`assertSame`). ## Result For pure ASCII/Latin-1 inputs, the original String identity is preserved (enabling faster equality checks for interned constants). For non-Latin-1 inputs, the cached string is sanitized to match the Latin-1 byte content. Closes #13749 Co-authored-by: Norman Maurer Co-authored-by: Vasily Pelikh From 8b95db0d42f24e5e5dfd3fd2b62ca0cb68868487 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Fri, 10 Jul 2026 05:37:23 +0200 Subject: [PATCH 36/64] Fix AsciiString.cached(String) performance regression (#17074) (#17080) Motivation: https://github.com/netty/netty/pull/17007 fixed #13749 by making `AsciiString.cached(String)` sanitize non-Latin-1 input before caching the `String` value. That change preserved the correct behavior, but introduced a performance regression. The implementation now performs two separate scans over the input string: 1. `new AsciiString(string)` copies chars into the backing byte array via `c2b(...)`. 2. `AsciiString.cached(String)` scans the same string again to check whether any char is greater than `MAX_CHAR_VALUE`. PrintAssembly results show that C2 does not merge these two loops after JIT compilation. The old version still contains the second scan loop in `AsciiString.cached`, while the optimized version has a single loop that performs both `c2b(...)` conversion and the Latin-1 check. [jit-actual-old-c2-ascii-final.txt](https://github.com/user-attachments/files/29848724/jit-actual-old-c2-ascii-final.txt) [jit-actual-current-c2-ascii-final.txt](https://github.com/user-attachments/files/29848711/jit-actual-new-c2-ascii-final.txt) Modification: Merge the byte conversion and Latin-1 detection into a single loop in `AsciiString.cached(String)`. The method now allocates the byte array directly, fills it using the existing `c2b(char)` conversion, and tracks whether every input char is Latin-1 during the same pass. It still preserves the original `String` instance for ASCII/Latin-1 input, and reconstructs the cached string from the byte array only when sanitization is required. Result: This removes the extra input scan while preserving the behavior introduced by #17007. Co-authored-by: Mengyang Li <56702218+dreamlike-ocean@users.noreply.github.com> --- .../main/java/io/netty/util/AsciiString.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/common/src/main/java/io/netty/util/AsciiString.java b/common/src/main/java/io/netty/util/AsciiString.java index 9c7d7fdb1ee..a5bc21cfebb 100644 --- a/common/src/main/java/io/netty/util/AsciiString.java +++ b/common/src/main/java/io/netty/util/AsciiString.java @@ -1396,17 +1396,18 @@ public static AsciiString of(CharSequence string) { * where the guaranteed use of the {@link #toString()} method. */ public static AsciiString cached(String string) { - AsciiString asciiString = new AsciiString(string); - asciiString.string = string; - // Check if the input contains any non-Latin-1 characters that would have been - // truncated to '?' by c2b() during construction. If so, reconstruct the cached - // string from the byte array to ensure consistency. - for (int i = 0; i < string.length(); i++) { - if (string.charAt(i) > MAX_CHAR_VALUE) { - asciiString.string = asciiString.toString(0); - break; + byte[] value = PlatformDependent.allocateUninitializedArray(string.length()); + boolean allLatin1 = true; + for (int i = 0; i < value.length; i++) { + char c = string.charAt(i); + value[i] = c2b(c); + if (c > MAX_CHAR_VALUE) { + allLatin1 = false; } } + + AsciiString asciiString = new AsciiString(value, false); + asciiString.string = allLatin1 ? string : asciiString.toString(0); return asciiString; } From 63e69df0a67c26d4ba7d9f1cdcd8ab78b18a8968 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:37:59 +0200 Subject: [PATCH 37/64] Auto-port 4.1: SslHandler: Fix possible buffer leak when an OOME is thrown during allocation (#17078) Auto-port of #17059 to 4.1 Cherry-picked commit: 64ac5abdfa5bf4eecae40722f53cd0ab4b84f556 --- Motivation: When SslHandler throws an OOME during allocating a new out buffer we need to ensure we still release the original buffer to not leak. Modifications: - Catch Throwable and rethrow after releasing Result: Fixes https://github.com/netty/netty/issues/17057 --------- Co-authored-by: Norman Maurer --- .../main/java/io/netty/handler/ssl/SslHandler.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/handler/src/main/java/io/netty/handler/ssl/SslHandler.java b/handler/src/main/java/io/netty/handler/ssl/SslHandler.java index 8b8b88d4da7..a5c1c619be9 100644 --- a/handler/src/main/java/io/netty/handler/ssl/SslHandler.java +++ b/handler/src/main/java/io/netty/handler/ssl/SslHandler.java @@ -849,7 +849,7 @@ private void wrap(ChannelHandlerContext ctx, boolean inUnwrap) throws SSLExcepti break; } - SSLEngineResult result; + SSLEngineResult result = null; try { if (buf.readableBytes() > MAX_PLAINTEXT_LENGTH) { @@ -874,16 +874,16 @@ private void wrap(ChannelHandlerContext ctx, boolean inUnwrap) throws SSLExcepti } result = wrap(alloc, engine, buf, out); } - } catch (SSLException e) { - // Either wrapMultiple(...) or wrap(...) did throw. In this case we need to release the buffer - // that we removed from pendingUnencryptedWrites before failing the promise and rethrowing it. - // Failing to do so would result in a buffer leak. + } catch (Throwable e) { + // Either wrapMultiple(...), wrap(...) or allocateOutNetBuf(...) did throw. + // In this case we need to release the buffer that we removed from pendingUnencryptedWrites + // before failing the promise and rethrowing it. Failing to do so would result in a buffer leak. // See https://github.com/netty/netty/issues/14644 // // We don't need to release out here as this is done in a finally block already. buf.release(); promise.setFailure(e); - throw e; + PlatformDependent.throwException(e); } if (buf.isReadable()) { From 86d46095304401f8dc4d273d716d1dd059b8ce06 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Fri, 10 Jul 2026 05:39:05 +0200 Subject: [PATCH 38/64] Fix AsciiString.cached(String) performance regression (#17074) (#17083) Motivation: https://github.com/netty/netty/pull/17007 fixed #13749 by making `AsciiString.cached(String)` sanitize non-Latin-1 input before caching the `String` value. That change preserved the correct behavior, but introduced a performance regression. The implementation now performs two separate scans over the input string: 1. `new AsciiString(string)` copies chars into the backing byte array via `c2b(...)`. 2. `AsciiString.cached(String)` scans the same string again to check whether any char is greater than `MAX_CHAR_VALUE`. PrintAssembly results show that C2 does not merge these two loops after JIT compilation. The old version still contains the second scan loop in `AsciiString.cached`, while the optimized version has a single loop that performs both `c2b(...)` conversion and the Latin-1 check. [jit-actual-old-c2-ascii-final.txt](https://github.com/user-attachments/files/29848724/jit-actual-old-c2-ascii-final.txt) [jit-actual-current-c2-ascii-final.txt](https://github.com/user-attachments/files/29848711/jit-actual-new-c2-ascii-final.txt) Modification: Merge the byte conversion and Latin-1 detection into a single loop in `AsciiString.cached(String)`. The method now allocates the byte array directly, fills it using the existing `c2b(char)` conversion, and tracks whether every input char is Latin-1 during the same pass. It still preserves the original `String` instance for ASCII/Latin-1 input, and reconstructs the cached string from the byte array only when sanitization is required. Result: This removes the extra input scan while preserving the behavior introduced by #17007. Co-authored-by: Mengyang Li <56702218+dreamlike-ocean@users.noreply.github.com> From f2281ac259f381291908f924871c4a4583542234 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Fri, 10 Jul 2026 06:02:47 +0200 Subject: [PATCH 39/64] Fix deploy workflow --- .github/workflows/ci-deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-deploy.yml b/.github/workflows/ci-deploy.yml index 52b57740981..1cf5a3045b8 100644 --- a/.github/workflows/ci-deploy.yml +++ b/.github/workflows/ci-deploy.yml @@ -146,6 +146,9 @@ jobs: # Wait until we have staged everything needs: [ stage-snapshot-linux, stage-snapshot-macos ] steps: + # Pinned to v4.3.1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + # Pinned to v4.8.0 - name: Set up JDK 8 uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 From 9a2c826b09d9d9988c1e51ec0d5f3cec9e413a0d Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:43:21 +0200 Subject: [PATCH 40/64] Auto-port 4.1: Add HttpContentCompressor constructor with ability to specify desired maxPipelineDepth (#17085) Auto-port of #17068 to 4.1 Cherry-picked commit: 61aca07bf4b803286c4a6f349acffda167b7f93d --- Motivation: In scope of https://github.com/netty/netty/pull/17063, the `HttpContentEncoder` got a new property `maxPipelineDepth` and the respective constructor variant. However, the subclasses, notably `HttpContentCompressor` have not been updated to allow changing `maxPipelineDepth` and always use the default `128`. Modification: Add `HttpContentCompressor` constructor variant with ability to specify desired `maxPipelineDepth`. It would help us to absorb the change through configuration setting. @chrisvest would appreciate your feedback, thank you. Signed-off-by: Andriy Redko Co-authored-by: Andriy Redko --- .../codec/http/HttpContentCompressor.java | 20 +++++++++++++++++++ .../codec/http/HttpContentEncoder.java | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentCompressor.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentCompressor.java index 17f55d3ed54..3a29123305a 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentCompressor.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentCompressor.java @@ -173,6 +173,26 @@ public HttpContentCompressor(CompressionOptions... compressionOptions) { * if the default should be used. */ public HttpContentCompressor(int contentSizeThreshold, CompressionOptions... compressionOptions) { + this(contentSizeThreshold, DEFAULT_MAX_PIPELINE_DEPTH, compressionOptions); + } + + /** + * Create a new {@link HttpContentCompressor} instance with specified + * {@link CompressionOptions}s + * + * @param contentSizeThreshold + * The response body is compressed when the size of the response + * body exceeds the threshold. The value should be a non negative + * number. {@code 0} will enable compression for all responses. + * @param maxPipelineDepth + * The maximum allowed depth of the encoding pipeline queue, the default + * value is set to {@link DEFAULT_MAX_PIPELINE_DEPTH} + * @param compressionOptions {@link CompressionOptions} or {@code null} + * if the default should be used. + */ + public HttpContentCompressor(int contentSizeThreshold, int maxPipelineDepth, + CompressionOptions... compressionOptions) { + super(maxPipelineDepth); this.contentSizeThreshold = ObjectUtil.checkPositiveOrZero(contentSizeThreshold, "contentSizeThreshold"); BrotliOptions brotliOptions = null; GzipOptions gzipOptions = null; diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentEncoder.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentEncoder.java index a7b8f6d5c96..2bc8e1823c3 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentEncoder.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentEncoder.java @@ -55,6 +55,7 @@ * converts them into {@link ByteBuf}s. */ public abstract class HttpContentEncoder extends MessageToMessageCodec { + public static final int DEFAULT_MAX_PIPELINE_DEPTH = 128; private enum State { PASS_THROUGH, @@ -71,7 +72,7 @@ private enum State { private State state = State.AWAIT_HEADERS; public HttpContentEncoder() { - this(128); + this(DEFAULT_MAX_PIPELINE_DEPTH); } public HttpContentEncoder(int maxPipelineDepth) { From 0cfdb08d792392ef2056b5b4665d16a769d711d0 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:01:10 +0200 Subject: [PATCH 41/64] Auto-port 4.1: Fix AdaptiveByteBuf._setLongLE calling checked setLongLE (#17102) Auto-port of #17098 to 4.1 Cherry-picked commit: 303d8342278008ccb1bc0e47c502c5da8c7b0af6 --- Motivation: AdaptiveByteBuf._setLongLE delegates to the checked setLongLE on the root parent, unlike all other _set/_get methods which use the unchecked underscore-prefixed variants. This adds redundant bounds checking and ensureAccessible() on every little-endian long write. Modification: Call rootParent()._setLongLE instead of rootParent().setLongLE. Result: _setLongLE now follows the same unchecked pattern as _setLong, _setInt, _setIntLE, etc. Co-authored-by: Francesco Nigro --- .../src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java b/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java index 7bae63d8a3d..d80ed0ce4ef 100644 --- a/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java +++ b/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java @@ -1916,7 +1916,7 @@ protected void _setLong(int index, long value) { @Override protected void _setLongLE(int index, long value) { - rootParent().setLongLE(idx(index), value); + rootParent()._setLongLE(idx(index), value); } @Override From 3caae467928d945557ee711f131b5a139f5c096e Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:15:12 +0200 Subject: [PATCH 42/64] Auto-port 4.1: Reject negative maxOrder in PooledByteBufAllocator (#17095) Auto-port of #17093 to 4.1 Cherry-picked commit: 010b6e813e01214e0ee721f78b25ccd1b2549046 --- Motivation: The valid range of `maxOrder` is 0 to 14, but negative values were accepted because only the upper bound was validated. When configured through `io.netty.allocator.maxOrder`, a negative value may result in an invalid chunk size during static initialization. Modification: Validate the lower bound of `maxOrder` and add a regression test. Result: Negative `maxOrder` values are rejected with an `IllegalArgumentException`. Co-authored-by: CoderBruis <37364336+coderbruis@users.noreply.github.com> --- .../io/netty/buffer/PooledByteBufAllocator.java | 2 +- .../netty/buffer/PooledByteBufAllocatorTest.java | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/buffer/src/main/java/io/netty/buffer/PooledByteBufAllocator.java b/buffer/src/main/java/io/netty/buffer/PooledByteBufAllocator.java index 836ea169145..1c9ffb15da4 100644 --- a/buffer/src/main/java/io/netty/buffer/PooledByteBufAllocator.java +++ b/buffer/src/main/java/io/netty/buffer/PooledByteBufAllocator.java @@ -360,7 +360,7 @@ private static int validateAndCalculatePageShifts(int pageSize, int alignment) { } private static int validateAndCalculateChunkSize(int pageSize, int maxOrder) { - if (maxOrder > 14) { + if (maxOrder < 0 || maxOrder > 14) { throw new IllegalArgumentException("maxOrder: " + maxOrder + " (expected: 0-14)"); } diff --git a/buffer/src/test/java/io/netty/buffer/PooledByteBufAllocatorTest.java b/buffer/src/test/java/io/netty/buffer/PooledByteBufAllocatorTest.java index 64638f8e1cb..6fd19197cd6 100644 --- a/buffer/src/test/java/io/netty/buffer/PooledByteBufAllocatorTest.java +++ b/buffer/src/test/java/io/netty/buffer/PooledByteBufAllocatorTest.java @@ -23,6 +23,7 @@ import io.netty.util.internal.ThrowableUtil; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.function.Executable; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -45,6 +46,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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -78,6 +80,18 @@ protected void trimCaches(PooledByteBufAllocator allocator) { allocator.trimCurrentThreadCache(); } + @Test + public void testRejectNegativeMaxOrder() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, new Executable() { + @Override + public void execute() throws Throwable { + new PooledByteBufAllocator(true, 1, 1, 8192, -1, 0, 0, false); + } + }); + + assertEquals("maxOrder: -1 (expected: 0-14)", exception.getMessage()); + } + @Test public void testTrim() { PooledByteBufAllocator allocator = newAllocator(true); From 8b6eb02bec75736571e8b083519939e4c48e8153 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:16:24 +0200 Subject: [PATCH 43/64] Auto-port 4.1: Snappy: Guard decoder against invalid chunk lengths (#17110) Auto-port of #17099 to 4.1 Cherry-picked commit: b41f2a6f3416ead4149cd49e6c88373e380182fc --- Motivation: Malformed Snappy framed input can declare compressed or uncompressed chunk lengths that are too short to contain the mandatory masked checksum. In the compressed case this can lead to lower-level ByteBuf index errors instead of a DecompressionException. Modification: Validate minimum chunk lengths in SnappyFrameDecoder before reading the checksum or Snappy preamble. Added parameterized tests that cover invalid compressed and uncompressed chunk lengths with checksum validation both enabled and disabled. Result: Malformed Snappy chunks with invalid lengths are rejected with DecompressionException. Verification: ./mvnw -pl codec-compression -am -Dtest=SnappyFrameDecoderTest -Dsurefire.failIfNoSpecifiedTests=false test -DskipNativeTests -DskipAutobahnTests Co-authored-by: Jonas Konrad Co-authored-by: multicode --- .../codec/compression/SnappyFrameDecoder.java | 12 +++++ .../compression/SnappyFrameDecoderTest.java | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/codec/src/main/java/io/netty/handler/codec/compression/SnappyFrameDecoder.java b/codec/src/main/java/io/netty/handler/codec/compression/SnappyFrameDecoder.java index 51997596eb6..6e5be7d34ef 100644 --- a/codec/src/main/java/io/netty/handler/codec/compression/SnappyFrameDecoder.java +++ b/codec/src/main/java/io/netty/handler/codec/compression/SnappyFrameDecoder.java @@ -47,10 +47,14 @@ private enum ChunkType { private static final int SNAPPY_IDENTIFIER_LEN = 6; // See https://github.com/google/snappy/blob/1.1.9/framing_format.txt#L95 private static final int MAX_UNCOMPRESSED_DATA_SIZE = 65536 + 4; + // An uncompressed chunk contains a 4-byte masked checksum followed by the data. + private static final int MIN_UNCOMPRESSED_DATA_SIZE = 4; // See https://github.com/google/snappy/blob/1.1.9/framing_format.txt#L82 private static final int MAX_DECOMPRESSED_DATA_SIZE = 65536; // See https://github.com/google/snappy/blob/1.1.9/framing_format.txt#L82 private static final int MAX_COMPRESSED_CHUNK_SIZE = 16777216 - 1; + // A compressed chunk contains a 4-byte masked checksum followed by a Snappy stream. + private static final int MIN_COMPRESSED_CHUNK_SIZE = 5; private final Snappy snappy = new Snappy(); private final boolean validateChecksums; @@ -163,6 +167,10 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t throw new DecompressionException("Received UNCOMPRESSED_DATA larger than " + MAX_UNCOMPRESSED_DATA_SIZE + " bytes"); } + if (chunkLength < MIN_UNCOMPRESSED_DATA_SIZE) { + throw new DecompressionException("Received UNCOMPRESSED_DATA with invalid chunk length: " + + chunkLength); + } if (inSize < 4 + chunkLength) { return; @@ -186,6 +194,10 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t throw new DecompressionException("Received COMPRESSED_DATA that contains" + " chunk that exceeds " + MAX_COMPRESSED_CHUNK_SIZE + " bytes"); } + if (chunkLength < MIN_COMPRESSED_CHUNK_SIZE) { + throw new DecompressionException("Received COMPRESSED_DATA with invalid chunk length: " + + chunkLength); + } if (inSize < 4 + chunkLength) { return; diff --git a/codec/src/test/java/io/netty/handler/codec/compression/SnappyFrameDecoderTest.java b/codec/src/test/java/io/netty/handler/codec/compression/SnappyFrameDecoderTest.java index aee05aa1967..ddbbf87cd9b 100644 --- a/codec/src/test/java/io/netty/handler/codec/compression/SnappyFrameDecoderTest.java +++ b/codec/src/test/java/io/netty/handler/codec/compression/SnappyFrameDecoderTest.java @@ -22,6 +22,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -177,6 +179,26 @@ public void testCompressedDataDecodesAndAppendsToOut() { actual.release(); } + @ParameterizedTest + @CsvSource({ + "false, 0", "false, 1", "false, 2", "false, 3", "false, 4", + "true, 0", "true, 1", "true, 2", "true, 3", "true, 4" + }) + public void testCompressedDataWithTooShortChunkLengthThrowsException( + boolean validateChecksums, int chunkLength) { + assertInvalidChunkLength(validateChecksums, (byte) 0x00, chunkLength); + } + + @ParameterizedTest + @CsvSource({ + "false, 0", "false, 1", "false, 2", "false, 3", + "true, 0", "true, 1", "true, 2", "true, 3" + }) + public void testUncompressedDataWithTooShortChunkLengthThrowsException( + boolean validateChecksums, int chunkLength) { + assertInvalidChunkLength(validateChecksums, (byte) 0x01, chunkLength); + } + // The following two tests differ in only the checksum provided for the literal // uncompressed string "netty" @@ -222,4 +244,35 @@ public void testInvalidChecksumDoesNotThrowException() { channel.finishAndReleaseAll(); } } + + private static void assertInvalidChunkLength(boolean validateChecksums, byte chunkType, int chunkLength) { + final EmbeddedChannel channel = new EmbeddedChannel(new SnappyFrameDecoder(validateChecksums)); + try { + final ByteBuf in = channel.alloc().buffer(14 + chunkLength); + + // Snappy stream identifier chunk: type 0xff, 3-byte little-endian length 6, payload "sNaPpY". + in.writeByte(0xff); + in.writeMediumLE(6); + in.writeByte('s'); + in.writeByte('N'); + in.writeByte('a'); + in.writeByte('P'); + in.writeByte('p'); + in.writeByte('Y'); + + // Invalid data chunk header: caller-supplied type and too-short 3-byte little-endian length. + in.writeByte(chunkType); + in.writeMediumLE(chunkLength); + in.writeZero(chunkLength); + + assertThrows(DecompressionException.class, new Executable() { + @Override + public void execute() { + channel.writeInbound(in); + } + }); + } finally { + channel.finishAndReleaseAll(); + } + } } From 93122270dfefe140b4a40ae11eed0d318c060c9f Mon Sep 17 00:00:00 2001 From: Aayush Atharva <24762260+hyperxpro@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:50:08 +0530 Subject: [PATCH 44/64] Backport #16079 and #17114 (#17134) Backport #16079 and #17114 --- .../ssl/ocsp/NoOcspResponderException.java | 31 ++++ .../io/netty/handler/ssl/ocsp/OcspClient.java | 104 +++++++++++- .../handler/ssl/ocsp/OcspClientTest.java | 154 +++++++++++++++++- .../OcspServerCertificateValidatorTest.java | 4 +- 4 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/NoOcspResponderException.java diff --git a/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/NoOcspResponderException.java b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/NoOcspResponderException.java new file mode 100644 index 00000000000..dd36b297900 --- /dev/null +++ b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/NoOcspResponderException.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project 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: + * + * https://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 io.netty.handler.ssl.ocsp; + +/** + * Thrown internally by {@link OcspClient} if the certificate being checked does not specify any OCSP Responder. + *

+ * This exception extends {@link NullPointerException} for backwards compatibility reasons. + */ +public final class NoOcspResponderException extends NullPointerException { + /** + * Create a new instance with the given message. + * @param message The error message. + */ + public NoOcspResponderException(String message) { + super(message); + } +} diff --git a/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspClient.java b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspClient.java index b2f07fa0b94..c5d62471717 100644 --- a/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspClient.java +++ b/handler-ssl-ocsp/src/main/java/io/netty/handler/ssl/ocsp/OcspClient.java @@ -45,6 +45,7 @@ import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.Extensions; import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; import org.bouncycastle.cert.ocsp.BasicOCSPResp; import org.bouncycastle.cert.ocsp.CertificateID; @@ -59,10 +60,22 @@ import java.net.InetAddress; import java.net.URL; +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.security.cert.CertPathBuilder; +import java.security.cert.CertPathBuilderException; +import java.security.cert.CertStore; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; +import java.security.cert.CollectionCertStoreParameters; +import java.security.cert.PKIXBuilderParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509CertSelector; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import static io.netty.handler.codec.http.HttpMethod.POST; import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; @@ -92,7 +105,7 @@ final class OcspClient { * @param issuer {@link X509Certificate} issuer of client certificate * @param validateResponseNonce Set to {@code true} to enable OCSP response validation * @param ioTransport {@link IoTransport} to use - * @return {@link Promise} of {@link BasicOCSPResp} + * @param responsePromise {@link Promise} of {@link BasicOCSPResp} */ static void query(final X509Certificate x509Certificate, final X509Certificate issuer, final boolean validateResponseNonce, @@ -298,14 +311,85 @@ private static void validateNonce(BasicOCSPResp basicResponse, DEROctetString en /** * Validate OCSP response signature */ - private static void validateSignature(BasicOCSPResp resp, X509Certificate certificate) throws OCSPException { + static void validateSignature(BasicOCSPResp resp, X509Certificate issuerCertificate) throws OCSPException { try { - ContentVerifierProvider verifier = new JcaContentVerifierProviderBuilder().build(certificate); - if (!resp.isSignatureValid(verifier)) { - throw new OCSPException("OCSP signature is not valid"); + X509CertificateHolder[] certs = resp.getCerts(); + JcaContentVerifierProviderBuilder providerBuilder = new JcaContentVerifierProviderBuilder(); + + // If responder certificate is included, validate the chain + if (certs != null && certs.length > 0) { + + // Use the first included certificate to verify the OCSP response signature. + X509CertificateHolder responderCert = certs[0]; + + // Verify OCSP response signature using responder cert + ContentVerifierProvider responderVerifier = providerBuilder.build(responderCert); + + if (!resp.isSignatureValid(responderVerifier)) { + throw new OCSPException("OCSP response signature is not valid"); + } + + // Build chain from responder certificate to issuer using CertPathBuilder + validateCertificateChain(responderCert, certs, issuerCertificate); + } else { + // Validate signature using issuer certificate + ContentVerifierProvider issuerVerifier = providerBuilder.build(issuerCertificate); + + if (!resp.isSignatureValid(issuerVerifier)) { + throw new OCSPException("OCSP response signature is not valid"); + } } } catch (OperatorCreationException e) { throw new OCSPException("Error validating OCSP-Signature", e); + } catch (CertificateException e) { + throw new OCSPException("Error while processing certificates for OCSP signature validation", e); + } + } + + /** + * Validates that a certificate chain can be built from the responder certificate to the issuer. + * Uses Java's CertPathBuilder to construct and validate the chain. + */ + private static void validateCertificateChain(X509CertificateHolder responderCert, + X509CertificateHolder[] allCerts, + X509Certificate issuerCertificate) throws OCSPException { + try { + // Convert BouncyCastle certificate holders to Java X509Certificates + List certList = new ArrayList(allCerts.length); + for (X509CertificateHolder certHolder : allCerts) { + certList.add(new JcaX509CertificateConverter().getCertificate(certHolder)); + } + + // Create a CertStore with all the certificates from the OCSP response + CertStore certStore = CertStore.getInstance("Collection", + new CollectionCertStoreParameters(certList)); + + // Set up the target certificate selector for the responder certificate + X509CertSelector targetConstraints = new X509CertSelector(); + targetConstraints.setCertificate(new JcaX509CertificateConverter().getCertificate(responderCert)); + + // Set up trust anchor with the issuer certificate + TrustAnchor trustAnchor = new TrustAnchor(issuerCertificate, null); + + // Build PKIX parameters + PKIXBuilderParameters pkixParams = new PKIXBuilderParameters( + Collections.singleton(trustAnchor), targetConstraints); + pkixParams.addCertStore(certStore); + pkixParams.setRevocationEnabled(false); // Don't check revocation when validating OCSP response + + // Build and validate the certificate path + CertPathBuilder builder = CertPathBuilder.getInstance("PKIX"); + builder.build(pkixParams); + + // If we reach here, the chain is valid + } catch (CertPathBuilderException e) { + throw new OCSPException("OCSP responder certificate is not trusted by issuer: " + e.getMessage(), e); + } catch (InvalidAlgorithmParameterException e) { + throw new OCSPException("Error setting up certificate path validation", e); + } catch (NoSuchAlgorithmException e) { + throw new OCSPException("Error setting up certificate path validation", e); + } catch (CertificateException e) { + throw new OCSPException("Error converting certificates for path validation", e); } } @@ -329,13 +413,15 @@ private static String parseOcspUrlFromCertificate(X509Certificate cert) { AuthorityInformationAccess aiaExtension = AuthorityInformationAccess.fromExtensions(holder.getExtensions()); // Lookup for OCSP responder url - for (AccessDescription accessDescription : aiaExtension.getAccessDescriptions()) { - if (accessDescription.getAccessMethod().equals(id_ad_ocsp)) { - return accessDescription.getAccessLocation().getName().toASN1Primitive().toString(); + if (aiaExtension != null) { + for (AccessDescription accessDescription : aiaExtension.getAccessDescriptions()) { + if (accessDescription.getAccessMethod().equals(id_ad_ocsp)) { + return accessDescription.getAccessLocation().getName().toASN1Primitive().toString(); + } } } - throw new NullPointerException("Unable to find OCSP responder URL in Certificate"); + throw new NoOcspResponderException("Unable to find OCSP responder URL in Certificate"); } static final class Initializer extends ChannelInitializer { diff --git a/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspClientTest.java b/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspClientTest.java index 11e4079737e..04077199d9a 100644 --- a/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspClientTest.java +++ b/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspClientTest.java @@ -16,25 +16,54 @@ package io.netty.handler.ssl.ocsp; import io.netty.util.concurrent.Promise; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.cert.ocsp.BasicOCSPResp; +import org.bouncycastle.cert.ocsp.BasicOCSPRespBuilder; +import org.bouncycastle.cert.ocsp.CertificateID; +import org.bouncycastle.cert.ocsp.CertificateStatus; +import org.bouncycastle.cert.ocsp.OCSPException; +import org.bouncycastle.cert.ocsp.RespID; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import javax.net.ssl.HttpsURLConnection; import java.io.IOException; +import java.math.BigInteger; import java.net.URL; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.SecureRandom; import java.security.cert.X509Certificate; +import java.util.Date; import java.util.concurrent.ExecutionException; import static io.netty.handler.ssl.ocsp.OcspServerCertificateValidator.createDefaultResolver; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; class OcspClientTest { - @Test - void simpleOcspQueryTest() throws IOException, ExecutionException, InterruptedException { + private static final SecureRandom RANDOM = new SecureRandom(); + + @ParameterizedTest + @ValueSource(strings = {"https://apple.com"}) + void simpleOcspQueryTest(String urlString) throws IOException, ExecutionException, InterruptedException { HttpsURLConnection httpsConnection = null; try { - URL url = new URL("https://netty.io"); + URL url = new URL(urlString); httpsConnection = (HttpsURLConnection) url.openConnection(); httpsConnection.connect(); @@ -56,4 +85,123 @@ void simpleOcspQueryTest() throws IOException, ExecutionException, InterruptedEx } } } + + @Test + void validateSignatureWithIncludedChainSucceeds() throws Exception { + final CertAndKey rootIssuer = buildCertificate("CN=SomeRootCA", true, null); + CertAndKey intermediateIssuer = buildCertificate("CN=SomeIntermediateCA", true, rootIssuer); + CertAndKey ocspResponder = buildCertificate("CN=SomeOCSPResponder", false, intermediateIssuer); + + // Create actual OCSP response with the responder's certificate + X509CertificateHolder responderHolder = new JcaX509CertificateHolder(ocspResponder.certificate); + X509CertificateHolder intermediateHolder = new JcaX509CertificateHolder(intermediateIssuer.certificate); + + // Create a minimal BasicOCSPResp that contains the certificate chain + final BasicOCSPResp resp = createBasicOcspResponse( + ocspResponder, + new X509CertificateHolder[]{responderHolder, intermediateHolder} + ); + + assertDoesNotThrow(new Executable() { + @Override + public void execute() throws Throwable { + OcspClient.validateSignature(resp, rootIssuer.certificate); + } + }); + } + + @Test + void validateSignatureWithInvalidChainThrows() throws Exception { + // Build an unrelated responder chain so nothing is signed by the provided issuer (using RSA) + final CertAndKey issuerBundle = buildCertificate("CN=Issuer", true, null); + + // Different CA + CertAndKey otherRoot = buildCertificate("CN=SomeRootCA", true, null); + CertAndKey otherIntermediate = buildCertificate("CN=SomeIntermediateCA", true, otherRoot); + CertAndKey otherResponder = buildCertificate("CN=SomeResponder", false, otherIntermediate); + + X509CertificateHolder responderHolder = new JcaX509CertificateHolder(otherResponder.certificate); + X509CertificateHolder intermediateHolder = new JcaX509CertificateHolder(otherIntermediate.certificate); + + // Create actual OCSP response with untrusted chain + final BasicOCSPResp resp = createBasicOcspResponse( + otherResponder, + new X509CertificateHolder[]{responderHolder, intermediateHolder} + ); + + assertThrows(OCSPException.class, new Executable() { + @Override + public void execute() throws Throwable { + OcspClient.validateSignature(resp, issuerBundle.certificate); + } + }); + } + + private static BasicOCSPResp createBasicOcspResponse(CertAndKey responderBundle, + X509CertificateHolder[] certChain) throws Exception { + CertAndKey dummyCert = buildCertificate("CN=DummyCert", true, null); + + // Create certificate ID for OCSP response + CertificateID certId = new CertificateID( + new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASH_SHA1), + new JcaX509CertificateHolder(dummyCert.certificate), + dummyCert.certificate.getSerialNumber() + ); + + // Create response builder with responder ID based on certificate + X509CertificateHolder responderHolder = new JcaX509CertificateHolder(responderBundle.certificate); + RespID respID = new RespID(responderHolder.getSubject()); + + BasicOCSPRespBuilder respBuilder = new BasicOCSPRespBuilder(respID); + + // Add response for the certificate (status: good) + respBuilder.addResponse(certId, CertificateStatus.GOOD); + + // Build and sign the response with the responder's private key + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .build(responderBundle.keyPair.getPrivate()); + + return respBuilder.build(signer, certChain, new Date()); + } + + /** + * Build an X.509 certificate with the given subject. If {@code issuer} is {@code null} the certificate is + * self-signed, otherwise it is issued (signed) by the given issuer. Uses RSA-2048 keys. + */ + private static CertAndKey buildCertificate(String subjectDn, boolean isCertificateAuthority, + CertAndKey issuer) throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + X500Name subject = new X500Name(subjectDn); + boolean selfSigned = issuer == null; + X500Name issuerName = selfSigned + ? subject + : new JcaX509CertificateHolder(issuer.certificate).getSubject(); + PrivateKey signingKey = selfSigned ? keyPair.getPrivate() : issuer.keyPair.getPrivate(); + + Date notBefore = new Date(System.currentTimeMillis() - 86400000L); + Date notAfter = new Date(System.currentTimeMillis() + 365L * 86400000L); + + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuerName, new BigInteger(64, RANDOM), notBefore, notAfter, subject, keyPair.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCertificateAuthority)); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(signingKey); + X509CertificateHolder holder = builder.build(signer); + X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(holder); + + return new CertAndKey(certificate, keyPair); + } + + private static final class CertAndKey { + final X509Certificate certificate; + final KeyPair keyPair; + + CertAndKey(X509Certificate certificate, KeyPair keyPair) { + this.certificate = certificate; + this.keyPair = keyPair; + } + } } diff --git a/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspServerCertificateValidatorTest.java b/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspServerCertificateValidatorTest.java index 0be194159ce..d6f3fb42382 100644 --- a/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspServerCertificateValidatorTest.java +++ b/handler-ssl-ocsp/src/test/java/io/netty/handler/ssl/ocsp/OcspServerCertificateValidatorTest.java @@ -58,7 +58,7 @@ void connectUsingHttpAndValidateCertificateUsingOcspTest() throws Exception { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); - pipeline.addLast(sslContext.newHandler(ch.alloc(), "netty.io", 443)); + pipeline.addLast(sslContext.newHandler(ch.alloc(), "apple.com", 443)); pipeline.addLast(new OcspServerCertificateValidator(false)); pipeline.addLast(new SimpleChannelInboundHandler() { @Override @@ -80,7 +80,7 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { } }); - ChannelFuture channelFuture = bootstrap.connect("netty.io", 443); + ChannelFuture channelFuture = bootstrap.connect("apple.com", 443); channelFuture.sync(); // Wait for maximum of 1 minute for Ocsp validation to happen From c69f04392579230f6cabe899e9fc22f9c2bc7c56 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:55:44 +0200 Subject: [PATCH 45/64] Auto-port 4.1: Use safe decompressor in Lz4FrameDecoder (#17121) Auto-port of #17118 to 4.1 Cherry-picked commit: e64a6b505d54cf1478b9c804f6508333626070a5 --- Motivation: `Lz4FrameDecoder` currently uses `LZ4FastDecompressor`. Recent lz4-java security hardening has degraded the performance of this path, while the decoder already knows the exact compressed block length required by `LZ4SafeDecompressor`. The fast API also does not accept the compressed source length, allowing malformed blocks to consume trailing readable bytes. Modification: - Use `LZ4SafeDecompressor` from the configured `LZ4Factory`. - Pass the exact declared compressed and decompressed lengths to the bounded decompression API. - Reject output whose actual decompressed length differs from the frame header. - Add regression coverage for reading beyond the declared compressed length and for decompressed-length mismatches. Result: Valid LZ4 frames continue to decode normally, while malformed blocks are constrained to their declared input and output bounds. The `codec-compression` test suite passes with 359 tests, and the focused `Lz4FrameDecoderTest` passes after allocating test inputs through the channel allocator. --------- Co-authored-by: Jonas Konrad Co-authored-by: multicode Co-authored-by: Chris Vest --- .../codec/compression/Lz4FrameDecoder.java | 22 +++++++-- .../compression/Lz4FrameDecoderTest.java | 48 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/codec/src/main/java/io/netty/handler/codec/compression/Lz4FrameDecoder.java b/codec/src/main/java/io/netty/handler/codec/compression/Lz4FrameDecoder.java index c1d39b3cc4f..6595cf288dd 100644 --- a/codec/src/main/java/io/netty/handler/codec/compression/Lz4FrameDecoder.java +++ b/codec/src/main/java/io/netty/handler/codec/compression/Lz4FrameDecoder.java @@ -21,8 +21,9 @@ import io.netty.util.internal.ObjectUtil; import net.jpountz.lz4.LZ4Exception; import net.jpountz.lz4.LZ4Factory; -import net.jpountz.lz4.LZ4FastDecompressor; +import net.jpountz.lz4.LZ4SafeDecompressor; +import java.nio.ByteBuffer; import java.util.List; import java.util.zip.Checksum; @@ -67,7 +68,7 @@ private enum State { /** * Underlying decompressor in use. */ - private LZ4FastDecompressor decompressor; + private LZ4SafeDecompressor decompressor; /** * Underlying checksum calculator in use. @@ -174,7 +175,7 @@ public Lz4FrameDecoder(LZ4Factory factory, Checksum checksum) { * maximum length of the decompressed block. If {@code 0} is given it uses {@code 32MB} by default. */ public Lz4FrameDecoder(LZ4Factory factory, Checksum checksum, int maxDecompressedLength) { - decompressor = ObjectUtil.checkNotNull(factory, "factory").fastDecompressor(); + decompressor = ObjectUtil.checkNotNull(factory, "factory").safeDecompressor(); this.checksum = checksum == null ? null : ByteBufChecksum.wrapChecksum(checksum); this.maxDecompressedLength = maxDecompressedLength == 0 ? MAX_BLOCK_SIZE : ObjectUtil.checkInRange(maxDecompressedLength, 0, MAX_BLOCK_SIZE, "maxDecompressedLength"); @@ -266,8 +267,19 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t case BLOCK_TYPE_COMPRESSED: uncompressed = ctx.alloc().buffer(decompressedLength, decompressedLength); - decompressor.decompress(CompressionUtil.safeReadableNioBuffer(in), - uncompressed.internalNioBuffer(uncompressed.writerIndex(), decompressedLength)); + ByteBuffer source = CompressionUtil.safeNioBuffer( + in, in.readerIndex(), compressedLength); + ByteBuffer destination = uncompressed.internalNioBuffer( + uncompressed.writerIndex(), decompressedLength); + int actualDecompressedLength = decompressor.decompress( + source, source.position(), compressedLength, + destination, destination.position(), decompressedLength); + if (actualDecompressedLength != decompressedLength) { + throw new DecompressionException(String.format( + "stream corrupted: decompressedLength(%d) and " + + "actualDecompressedLength(%d) mismatch", + decompressedLength, actualDecompressedLength)); + } // Update the writerIndex now to reflect what we decompressed. uncompressed.writerIndex(uncompressed.writerIndex() + decompressedLength); break; diff --git a/codec/src/test/java/io/netty/handler/codec/compression/Lz4FrameDecoderTest.java b/codec/src/test/java/io/netty/handler/codec/compression/Lz4FrameDecoderTest.java index ec6b4ed6f57..86140fd3ca0 100644 --- a/codec/src/test/java/io/netty/handler/codec/compression/Lz4FrameDecoderTest.java +++ b/codec/src/test/java/io/netty/handler/codec/compression/Lz4FrameDecoderTest.java @@ -49,6 +49,54 @@ protected EmbeddedChannel createChannel() { return new EmbeddedChannel(new Lz4FrameDecoder(true, 31 * 1024 * 1024)); } + @Test + public void testRejectsCompressedDataBeyondDeclaredLength() { + final EmbeddedChannel decoder = new EmbeddedChannel(new Lz4FrameDecoder(false)); + final ByteBuf input = decoder.alloc().buffer(); + input.writeLong(MAGIC_NUMBER); + input.writeByte(BLOCK_TYPE_COMPRESSED); + input.writeIntLE(1); + input.writeIntLE(8); + input.writeIntLE(0); + input.writeByte(0x80); + input.writeZero(8); + + try { + assertThrows(DecompressionException.class, new Executable() { + @Override + public void execute() throws Throwable { + decoder.writeInbound(input); + } + }); + } finally { + decoder.finishAndReleaseAll(); + } + } + + @Test + public void testRejectsDecompressedLengthMismatch() { + final EmbeddedChannel decoder = new EmbeddedChannel(new Lz4FrameDecoder(false)); + final ByteBuf input = decoder.alloc().buffer(); + input.writeLong(MAGIC_NUMBER); + input.writeByte(BLOCK_TYPE_COMPRESSED); + input.writeIntLE(2); + input.writeIntLE(8); + input.writeIntLE(0); + input.writeByte(0x10); + input.writeByte(0); + + try { + assertThrows(DecompressionException.class, new Executable() { + @Override + public void execute() throws Throwable { + decoder.writeInbound(input); + } + }); + } finally { + decoder.finishAndReleaseAll(); + } + } + @Test public void testUnexpectedBlockIdentifier() { final byte[] data = Arrays.copyOf(DATA, DATA.length); From b94986e983b05b8ea19ada8f0bd656c2e2b8a8c9 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:10:41 +0200 Subject: [PATCH 46/64] Auto-port 4.1: Configure TestLens for the PR builds (#17133) Auto-port of #17129 to 4.1 Cherry-picked commit: 79904440ff32fd6b6446b7bef9402a1e09241aac --- Motivation: TestLens is a new tool by the JUnit maintainers that helps to get flaky tests under control. It is free for open source projects. Modification: TestLens needs to instrument the maven POM files, so this adds a github action invocation to the PR builds that do that before running our maven builds. Result: TestLens is now enabled in our PR builds. Co-authored-by: Chris Vest --- .github/workflows/ci-pr.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index f67ed7ed0fd..a4b8dafaf57 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -90,6 +90,9 @@ jobs: cache-windows-maven-${{ hashFiles('**/pom.xml') }} cache-windows-maven- + - name: Set up TestLens + uses: testlens-app/setup-testlens@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # Pin 1.9.3 + - name: Build project run: ./mvnw.cmd -B -ntp --file pom.xml clean package -Pboringssl -DskipHttp2Testsuite=true -DskipAutobahnTestsuite=true @@ -248,6 +251,9 @@ jobs: cache-maven-al2023-${{ hashFiles('**/pom.xml') }} cache-maven-al2023- + - name: Set up TestLens + uses: testlens-app/setup-testlens@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # Pin 1.9.3 + - name: Build docker image run: docker compose ${{ matrix.docker-compose-build }} @@ -326,6 +332,9 @@ jobs: - name: Install tools via brew run: brew bundle + - name: Set up TestLens + uses: testlens-app/setup-testlens@3f82d2dc6cd5c03f02ce5f7885a21195d85f8d14 # Pin 1.9.3 + - name: Build project run: ./mvnw -B -ntp --file pom.xml clean package -Pboringssl -DskipHttp2Testsuite=true -DskipAutobahnTestsuite=true -DskipTests=true From ac9ec259c1c0027871aa09594fe87c73151ea9c7 Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Fri, 24 Jul 2026 13:11:15 -0700 Subject: [PATCH 47/64] =?UTF-8?q?Fix=20maxAllocation=20for=20brotli-encode?= =?UTF-8?q?d=20content=20in=20HttpContentDecompress=E2=80=A6=20(#17124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …or (#17037) Motivation HttpContentDecompressor documents maxAllocation as the maximum decompression buffer size. For gzip / deflate / zstd it is routed to each decoder's output-cap parameter. For brotli it was passed to new BrotliDecoder(maxAllocation), whose single argument is inputBufferSize, not the output cap. So a large maxAllocation enlarged brotli's input buffer while the output cap stayed at the 64 KiB default; a small maxAllocation did not tighten the output cap for brotli at all. Modifications Add BrotliDecoder.newDecoderWithMaxAllocation(int): routes maxAllocation to outputBufferSize, 0 falls back to new BrotliDecoder(). `HttpContentDecompressor`: brotli branch uses the new factory instead of new BrotliDecoder(maxAllocation). Add HttpContentDecompressorTest#testBrotliDecodingHonorsMaxAllocationAsOutputCap: brotli-compress 128 KiB, decode with HttpContentDecompressor(64), assert lossless decode and >100 chunks. Result Fixes #17034. maxAllocation now has consistent semantics across gzip / deflate / zstd / brotli. No API removed, one new public factory added on BrotliDecoder. --------- Co-authored-by: Norman Maurer (cherry picked from commit 0332676fd1d7b65a8a8b249a5bf96b30e2c998a3) Co-authored-by: skyguard1 --- .../codec/http/HttpContentDecompressor.java | 2 +- .../http/HttpContentDecompressorTest.java | 73 +++++++++++++++++++ .../codec/compression/BrotliDecoder.java | 16 ++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentDecompressor.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentDecompressor.java index f6fde488627..21fbad525df 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentDecompressor.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpContentDecompressor.java @@ -104,7 +104,7 @@ protected EmbeddedChannel newContentDecoder(String contentEncoding) throws Excep } if (Brotli.isAvailable() && BR.contentEqualsIgnoreCase(contentEncoding)) { return new EmbeddedChannel(ctx.channel().id(), ctx.channel().metadata().hasDisconnect(), - ctx.channel().config(), new BrotliDecoder(maxAllocation)); + ctx.channel().config(), BrotliDecoder.newDecoderWithMaxAllocation(maxAllocation)); } if (SNAPPY.contentEqualsIgnoreCase(contentEncoding)) { diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentDecompressorTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentDecompressorTest.java index 734eecdaf6d..111d3afd470 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentDecompressorTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/HttpContentDecompressorTest.java @@ -26,14 +26,19 @@ import io.netty.handler.codec.compression.Zstd; import io.netty.handler.flow.FlowControlHandler; import io.netty.util.ReferenceCountUtil; +import com.aayushatharva.brotli4j.encoder.BrotliOutputStream; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import java.io.ByteArrayOutputStream; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -196,6 +201,74 @@ public void testZipBomb(String encoding) { assertEquals((long) chunkSize * numberOfChunks, incomingHandler.total); } + @Test + public void testBrotliDecodingHonorsMaxAllocationAsOutputCap() throws Exception { + Assumptions.assumeTrue(Brotli.isAvailable(), + "brotli4j native library not available on this platform"); + + // 128KB of moderately compressible bytes so the decompressed size + // definitely exceeds BrotliDecoder's 64KB default output cap and any + // small maxAllocation we might set below. + byte[] payload = new byte[128 * 1024]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i % 251); + } + ByteArrayOutputStream compressedOut = new ByteArrayOutputStream(); + BrotliOutputStream brotliOs = new BrotliOutputStream(compressedOut); + brotliOs.write(payload); + brotliOs.close(); + byte[] compressed = compressedOut.toByteArray(); + + // Deliberately use a tiny maxAllocation. Before the fix this configured + // BrotliDecoder.inputBufferSize = 64, effectively breaking the decoder + // for realistic payloads; after the fix it configures outputBufferSize. + EmbeddedChannel channel = new EmbeddedChannel(new HttpContentDecompressor(64)); + try { + HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + response.headers().set(HttpHeaderNames.CONTENT_ENCODING, HttpHeaderValues.BR); + response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED); + assertTrue(channel.writeInbound(response)); + assertTrue(channel.writeInbound(new DefaultHttpContent(Unpooled.wrappedBuffer(compressed)))); + assertTrue(channel.writeInbound(LastHttpContent.EMPTY_LAST_CONTENT)); + + // Drain and concatenate every decompressed HttpContent chunk. + byte[] decompressed = new byte[0]; + int contentChunks = 0; + Object msg; + while ((msg = channel.readInbound()) != null) { + try { + if (msg instanceof HttpContent) { + ByteBuf buf = ((HttpContent) msg).content(); + if (buf.readableBytes() > 0) { + contentChunks++; + int len = decompressed.length; + decompressed = Arrays.copyOf(decompressed, len + buf.readableBytes()); + buf.readBytes(decompressed, len, buf.readableBytes()); + } + } + } finally { + ReferenceCountUtil.release(msg); + } + } + + assertThat(decompressed) + .as("decompressed bytes must match original payload") + .isEqualTo(payload); + + // Regression signal: with maxAllocation=64 correctly routed to + // BrotliDecoder.outputBufferSize, a 128KB payload must be forwarded + // in many small chunks. Under the previous (buggy) wiring the same + // maxAllocation would be applied to inputBufferSize while + // outputBufferSize stayed at BrotliDecoder's 64KB default, so only + // a handful of large chunks would be emitted. + assertThat(contentChunks) + .as("expected many small chunks when maxAllocation=64 caps output size") + .isGreaterThan(100); + } finally { + channel.finishAndReleaseAll(); + } + } + private static final class ZipBombIncomingHandler extends ChannelInboundHandlerAdapter { final int memoryLimit; long total; diff --git a/codec/src/main/java/io/netty/handler/codec/compression/BrotliDecoder.java b/codec/src/main/java/io/netty/handler/codec/compression/BrotliDecoder.java index b4df8233256..6daa29dde45 100644 --- a/codec/src/main/java/io/netty/handler/codec/compression/BrotliDecoder.java +++ b/codec/src/main/java/io/netty/handler/codec/compression/BrotliDecoder.java @@ -80,6 +80,22 @@ public BrotliDecoder(int inputBufferSize, int outputBufferSize) { this.outputBufferSize = ObjectUtil.checkPositive(outputBufferSize, "outputBufferSize"); } + /** + * Creates a new {@link BrotliDecoder} that use the {@code maxAllocation} + * semantics: the supplied value bounds the size of the emitted decompressed chunks. + * The input buffer size stays at the decoder's default. + * + * @param maxAllocation maximum size, in bytes, of each decompressed output + * buffer forwarded downstream; if {@code 0}, the + * decoder's default output cap is used. + */ + public static BrotliDecoder newDecoderWithMaxAllocation(int maxAllocation) { + ObjectUtil.checkPositiveOrZero(maxAllocation, "maxAllocation"); + return maxAllocation > 0 ? + new BrotliDecoder(DEFAULT_INPUT_BUFFER_SIZE, maxAllocation) : + new BrotliDecoder(); + } + private void forwardOutput(ChannelHandlerContext ctx) { ByteBuffer nativeBuffer = decoder.pull(outputBufferSize); // nativeBuffer actually wraps brotli's internal buffer so we need to copy its content From 8aab58084bc520e2900746a8190818c8c068cd44 Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Mon, 27 Jul 2026 08:31:52 -0700 Subject: [PATCH 48/64] =?UTF-8?q?Propagate=20the=20CI=20envionment=20varia?= =?UTF-8?q?bles=20through=20to=20the=20docker=20builds=20(#=E2=80=A6=20(#1?= =?UTF-8?q?7143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …17138) Motivation: TestLens requires this in order to activate its profile. It's possible some of our other tools, e.g. sdkman also make use of this. Modification: Add `CI` to the environments in our docker-compose files. Result: We should now get testlens results from our docker-based linux builds. (cherry picked from commit 6e8c31d986277796ffac89605b1eabfc5b533475) --- docker/docker-compose.al2023.yaml | 1 + docker/docker-compose.centos-7.yaml | 1 + docker/docker-compose.ubuntu-20.04.yaml | 1 + docker/docker-compose.yaml | 1 + 4 files changed, 4 insertions(+) diff --git a/docker/docker-compose.al2023.yaml b/docker/docker-compose.al2023.yaml index ae8e9b4106d..cd85fd464ff 100644 --- a/docker/docker-compose.al2023.yaml +++ b/docker/docker-compose.al2023.yaml @@ -11,6 +11,7 @@ services: depends_on: [runtime-setup] environment: LD_LIBRARY_PATH: /opt/aws-lc/lib64 + CI: volumes: # Use a separate directory for the AL2023 Maven repository - ~/.m2-al2023:/root/.m2 diff --git a/docker/docker-compose.centos-7.yaml b/docker/docker-compose.centos-7.yaml index 14437428a34..512d9ed20c3 100644 --- a/docker/docker-compose.centos-7.yaml +++ b/docker/docker-compose.centos-7.yaml @@ -19,6 +19,7 @@ services: - GPG_PASSPHRASE - GPG_PRIVATE_KEY - MAVEN_OPTS + - CI volumes: - ~/.ssh:/root/.ssh - ~/.gnupg:/root/.gnupg diff --git a/docker/docker-compose.ubuntu-20.04.yaml b/docker/docker-compose.ubuntu-20.04.yaml index 40ddafffd77..99d5a3a779d 100644 --- a/docker/docker-compose.ubuntu-20.04.yaml +++ b/docker/docker-compose.ubuntu-20.04.yaml @@ -16,6 +16,7 @@ services: - GPG_PASSPHRASE - GPG_PRIVATE_KEY - MAVEN_OPTS + - CI volumes: - ~/.ssh:/root/.ssh - ~/.gnupg:/root/.gnupg diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index af79a8187c7..26f4a9ae112 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -16,6 +16,7 @@ services: - GPG_PASSPHRASE - GPG_PRIVATE_KEY - MAVEN_OPTS + - CI volumes: - ~/.ssh:/root/.ssh - ~/.gnupg:/root/.gnupg From d7e5efad35c5d501a5879824eafbd9c56b088704 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:10:00 +0200 Subject: [PATCH 49/64] Auto-port 4.1: fix(mqtt): drop UNSUBACK reason codes for MQTT 3.x encoding (#17137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-port of #17117 to 4.1 Cherry-picked commit: cddcdef3b72a40b7f8b26c373cea1b23c96b5ad4 --- Motivation: MQTT 3.x UNSUBACK only includes the Packet Identifier, while the reason code payload is a new addition in MQTT 5.0. Modification: drop UNSUBACK reason codes for MQTT 3.x encoding --------- Co-authored-by: 如梦技术 <596392912@qq.com> Co-authored-by: Chris Vest --- .../netty/handler/codec/mqtt/MqttEncoder.java | 9 +++-- .../handler/codec/mqtt/MqttCodecTest.java | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttEncoder.java b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttEncoder.java index 729efcf3dc0..89653403a97 100644 --- a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttEncoder.java +++ b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttEncoder.java @@ -389,13 +389,18 @@ private static ByteBuf encodeUnsubAckMessage( MqttUnsubAckMessage message) { if (message.variableHeader() instanceof MqttMessageIdAndPropertiesVariableHeader) { MqttVersion mqttVersion = getMqttVersion(ctx); + // Reason Codes were introduced in MQTT 5.0 only. MQTT 3.1.1 (and 3.1) UNSUBACK packets + // have no payload, so reason codes must be suppressed for older protocol versions + // even when the caller populated them via MqttMessageBuilders. + final boolean writeReasonCodes = mqttVersion == MqttVersion.MQTT_5; ByteBuf propertiesBuf = encodePropertiesIfNeeded(mqttVersion, ctx.alloc(), message.idAndPropertiesVariableHeader().properties()); try { int variableHeaderBufferSize = 2 + propertiesBuf.readableBytes(); MqttUnsubAckPayload payload = message.payload(); - int payloadBufferSize = payload == null ? 0 : payload.unsubscribeReasonCodes().size(); + int payloadBufferSize = writeReasonCodes && payload != null + ? payload.unsubscribeReasonCodes().size() : 0; int variablePartSize = variableHeaderBufferSize + payloadBufferSize; int fixedHeaderBufferSize = 1 + getVariableLengthInt(variablePartSize); ByteBuf buf = ctx.alloc().buffer(fixedHeaderBufferSize + variablePartSize); @@ -404,7 +409,7 @@ private static ByteBuf encodeUnsubAckMessage( buf.writeShort(message.variableHeader().messageId()); buf.writeBytes(propertiesBuf); - if (payload != null) { + if (writeReasonCodes && payload != null) { for (Short reasonCode : payload.unsubscribeReasonCodes()) { buf.writeByte(reasonCode); } diff --git a/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java b/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java index f5584293a04..dd979bdfb19 100644 --- a/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java +++ b/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java @@ -952,6 +952,41 @@ public void testUnsubAckMessageForMqtt5() throws Exception { decodedMessage.payload().unsubscribeReasonCodes()); } + @Test + public void testUnsubAckMessageForMqtt311DropsReasonCodes() throws Exception { + // MQTT 3.1.1 UNSUBACK has no properties and no payload. Even when a caller populates + // properties / reason codes (for example via MqttMessageBuilders), the encoder must + // strip them so the wire format remains valid for 3.1.1 (Remaining Length must be 2). + when(versionAttrMock.get()).thenReturn(MqttVersion.MQTT_3_1_1); + + MqttProperties props = new MqttProperties(); + props.add(new MqttProperties.IntegerProperty(PAYLOAD_FORMAT_INDICATOR.value(), 6)); + final MqttUnsubAckMessage message = MqttMessageBuilders.unsubAck() + .packetId((short) 1) + .properties(props) + .addReasonCode((short) 0x83) + .build(); + ByteBuf byteBuf = MqttEncoder.doEncode(ctx, message); + + // Fixed header (1 byte) + Remaining Length (0x02) + Packet Identifier (2 bytes) = 4 bytes. + assertEquals(4, byteBuf.readableBytes()); + assertEquals(MqttMessageType.UNSUBACK.value() << 4, byteBuf.getByte(0) & 0xF0); + assertEquals(2, byteBuf.getByte(1)); + assertEquals(1, byteBuf.getUnsignedShort(2)); + + mqttDecoder.channelRead(ctx, byteBuf); + + assertEquals(1, out.size()); + + final MqttUnsubAckMessage decodedMessage = (MqttUnsubAckMessage) out.get(0); + validateFixedHeaders(message.fixedHeader(), decodedMessage.fixedHeader()); + validateMessageIdVariableHeader( + message.variableHeader(), + decodedMessage.variableHeader()); + assertTrue(decodedMessage.payload() == null + || decodedMessage.payload().unsubscribeReasonCodes().isEmpty()); + } + @Test public void testDisconnectMessageForMqtt5() throws Exception { when(versionAttrMock.get()).thenReturn(MqttVersion.MQTT_5); From 868996043ba27528357e6b1f5ac3947fe04df794 Mon Sep 17 00:00:00 2001 From: Francesco Nigro Date: Thu, 30 Jul 2026 19:00:10 +0200 Subject: [PATCH 50/64] Fix buddy cache evicting chunks with live buffers (#17154) (#17176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: The shared ConcurrentSkipListChunkCache eviction policy picked the chunk with the lowest refCnt when the cache exceeded CHUNK_REUSE_QUEUE. Since chunks enter the cache with refCnt = 1 + N (N live buffers), evicting them creates zombie chunks: out of the cache, backing buffer still alive until all N buffers release. This caused 16 GB RSS on the AG benchmark. Modification: Only evict chunks where refCnt == 1 (no live buffers — just the base construction reference). If no idle chunk exists, let the cache grow past the cap. markToDeallocate on an idle chunk decrements refCnt to 0, triggering deallocate which frees the backing buffer cleanly. Result: Less memory churn under allocation pressure still allowing it to shrink on subsequence cache requests (cherry picked from commit https://github.com/franz1981/netty/commit/34bfcc7d04363c1290bf81fed203cd93ca1c7764) --- .../buffer/AdaptivePoolingAllocator.java | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java b/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java index d80ed0ce4ef..64f809f0493 100644 --- a/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java +++ b/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java @@ -615,25 +615,14 @@ public boolean offerChunk(Chunk chunk) { int size = chunks.size(); while (size > CHUNK_REUSE_QUEUE) { - // Deallocate the chunk with the fewest incoming references. int key = -1; Chunk toDeallocate = null; for (IntEntry entry : chunks) { Chunk candidate = entry.getValue(); - if (candidate != null) { - if (toDeallocate == null) { - toDeallocate = candidate; - key = entry.getKey(); - } else { - int candidateRefCnt = candidate.refCnt(); - int toDeallocateRefCnt = toDeallocate.refCnt(); - if (candidateRefCnt < toDeallocateRefCnt || - candidateRefCnt == toDeallocateRefCnt && - candidate.capacity() < toDeallocate.capacity()) { - toDeallocate = candidate; - key = entry.getKey(); - } - } + if (candidate != null && candidate.refCnt() == 1) { + toDeallocate = candidate; + key = entry.getKey(); + break; } } if (toDeallocate == null) { From 287dd71bccb2346b6ce44751bca89ab337730d61 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:41:23 +0200 Subject: [PATCH 51/64] Auto-port 4.1: Avoid classloader leak via GlobalEventExecutor terminationFuture failure (#17189) Auto-port of #17140 to 4.1 Cherry-picked commit: cee0005f7915ef422a95948c43e8280b82e77fd7 --- ### Motivation `GlobalEventExecutor.INSTANCE` is a static singleton that lives for the lifetime of the classloader that loaded it. Its `terminationFuture` holds a `FailedFuture` whose cause was a plain `UnsupportedOperationException`. Although `ThrowableUtil.unknownStackTrace(...)` replaces the *visible* stack trace with a single synthetic frame, the exception's internal (native) `backtrace` field is still populated at construction time by `fillInStackTrace()`. That backtrace pins the classloader of whatever thread happened to trigger the lazy initialization of `INSTANCE` (e.g. a web application's `WebAppClassLoader`), making all of that classloader's classes immortal/undeployable. This is a follow-up to the leak fixed in #14622. Fixes #17128 ### Modification Introduce a private `StacklessUnsupportedOperationException` whose `fillInStackTrace()` is a no-op, so the native backtrace is never captured, and use it for the `terminationFuture` failure. This mirrors the stackless-exception pattern already used throughout Netty. ### Result The `terminationFuture` failure no longer retains a native backtrace, so it can no longer pin the triggering thread's classloader. **Verification done:** Added `GlobalEventExecutorTest#testTerminationFutureFailureDoesNotFillInStackTrace`, which asserts that after `fillInStackTrace()` the cause's stack trace stays the single synthetic `terminationFuture` frame. It fails before the change (backtrace repopulates to 76 native frames) and passes after. Ran `mvn -pl common test -Dtest=GlobalEventExecutorTest` (6/6 pass) and `checkstyle:check@check-style` (clean) on JDK 25. Co-authored-by: seonwoojung --- .../util/concurrent/GlobalEventExecutor.java | 25 ++++++++++++++++--- .../concurrent/GlobalEventExecutorTest.java | 21 ++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/io/netty/util/concurrent/GlobalEventExecutor.java b/common/src/main/java/io/netty/util/concurrent/GlobalEventExecutor.java index 3885e713553..80d2a774f23 100644 --- a/common/src/main/java/io/netty/util/concurrent/GlobalEventExecutor.java +++ b/common/src/main/java/io/netty/util/concurrent/GlobalEventExecutor.java @@ -88,9 +88,8 @@ private GlobalEventExecutor() { threadFactory = ThreadExecutorMap.apply(new DefaultThreadFactory( DefaultThreadFactory.toPoolName(getClass()), false, Thread.NORM_PRIORITY, null), this); - UnsupportedOperationException terminationFailure = new UnsupportedOperationException(); - ThrowableUtil.unknownStackTrace(terminationFailure, GlobalEventExecutor.class, "terminationFuture"); - terminationFuture = new FailedFuture(this, terminationFailure); + terminationFuture = new FailedFuture(this, + StacklessUnsupportedOperationException.newInstance(GlobalEventExecutor.class, "terminationFuture")); } /** @@ -325,4 +324,24 @@ public void run() { } } } + + private static final class StacklessUnsupportedOperationException extends UnsupportedOperationException { + + private static final long serialVersionUID = -8060232216137960173L; + + private StacklessUnsupportedOperationException() { } + + // Override fillInStackTrace() so we not populate the backtrace via a native call and so leak the + // Classloader. As the GlobalEventExecutor.INSTANCE is a singleton and holds on to this exception via its + // terminationFuture, a populated backtrace would pin the Classloader of whatever thread happened to trigger + // the lazy initialization of INSTANCE (see https://github.com/netty/netty/issues/17128). + @Override + public Throwable fillInStackTrace() { + return this; + } + + static StacklessUnsupportedOperationException newInstance(Class clazz, String method) { + return ThrowableUtil.unknownStackTrace(new StacklessUnsupportedOperationException(), clazz, method); + } + } } diff --git a/common/src/test/java/io/netty/util/concurrent/GlobalEventExecutorTest.java b/common/src/test/java/io/netty/util/concurrent/GlobalEventExecutorTest.java index 85d5667d4d8..e5326fc8ad3 100644 --- a/common/src/test/java/io/netty/util/concurrent/GlobalEventExecutorTest.java +++ b/common/src/test/java/io/netty/util/concurrent/GlobalEventExecutorTest.java @@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; @@ -156,6 +157,26 @@ public void run() { assertTrue(t.ran.get()); } + @Test + public void testTerminationFutureFailureDoesNotFillInStackTrace() { + // The GlobalEventExecutor.INSTANCE is a singleton that lives for the lifetime of the Classloader that + // loaded it. It holds on to the failure of its terminationFuture forever, so that failure must not + // populate a (native) backtrace: doing so would pin the Classloader of whatever thread happened to + // trigger the lazy initialization of INSTANCE (see https://github.com/netty/netty/issues/17128). + Throwable cause = e.terminationFuture().cause(); + assertNotNull(cause); + assertTrue(cause instanceof UnsupportedOperationException); + + StackTraceElement[] before = cause.getStackTrace(); + assertEquals(1, before.length); + assertEquals(GlobalEventExecutor.class.getName(), before[0].getClassName()); + assertEquals("terminationFuture", before[0].getMethodName()); + + // fillInStackTrace() must be a no-op; otherwise it would repopulate the backtrace with native frames. + cause.fillInStackTrace(); + assertArrayEquals(before, cause.getStackTrace()); + } + private static final class TestRunnable implements Runnable { final AtomicBoolean ran = new AtomicBoolean(); final long delay; From 25f7e5eae7b7ac6190e2f707e2fe827fb1e7d2f8 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:45:15 +0200 Subject: [PATCH 52/64] Auto-port 4.1: `HttpObjectEncoder` / `DefaultHttp2FrameWriter`: fix buffer leak when a `Throwable` is thrown during header encoding (#17178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-port of #17089 to 4.1 Cherry-picked commit: 10e24f9bfb5d754c2f2c6e1be61c3f5d9ad038a0 --- ### Motivation Fixes #17088. Same class of bug as the `SslHandler` leak fixed in #17059: a header buffer is allocated, then a `Throwable` (typically `OutOfMemoryError`) is thrown before the buffer is handed off, leaving it unreleased. #6729 reported the exact symptom on `HttpObjectEncoder.encodeHeaders` back in 2017 but couldn't be reproduced on demand and was closed; the root cause was never fixed. Affected paths: - HTTP/1 — `encodeInitHttpMessage()` and `encodeFullHttpMessage()` in `HttpObjectEncoder`: `buf` from `ctx.alloc().buffer(...)` leaks if `encodeHeaders()` throws before it is added to `out`. - HTTP/2 — `writeHeadersInternal()`, `writePushPromise()` and `writeContinuationFrames()` in `DefaultHttp2FrameWriter`: the retained `fragment` / frame-header buffer leaks if a later allocation throws after the fragment is sliced off the header block. With pooled direct buffers this leaks off-heap memory the GC cannot reclaim, so repeated OOME on these paths ends in `OutOfDirectMemoryError` / process death. ### Modification - `HttpObjectEncoder`: guard the header buffer with a success/handed-off flag and release it in a `finally` unless ownership was transferred. In `encodeFullHttpMessage()` the flag is set immediately before `encodeByteBufHttpContent()` so the chunked path — where `buf` is already added to `out` before `encodeChunkedHttpContent()` can throw — does not double-release. - `DefaultHttp2FrameWriter`: hoist `fragment` to method scope, null it right after `ctx.write(fragment, ...)`, and release it in `finally` if non-null. `writeContinuationFrames()` additionally guards the reused frame-header buffer with a per-fragment flag. - Add tests to both modules using a tracking allocator that injects an `OutOfMemoryError` on a targeted allocation, asserting every tracked buffer reaches `refCnt() == 0` after the failure. ### Result No buffer leak when a `Throwable` is thrown mid header encoding. The normal path is unchanged. Measured with the tracking allocator (each OOME on these paths leaks exactly one header buffer, so the leak grows linearly with the number of affected requests): | path | leak / request | before | after | |---|---|---|---| | `HttpObjectEncoder` init / full | 256 B | `refCnt == 1` | `0` | | Http2 `writeHeaders` / `writePushPromise` | 256 B | `refCnt == 1` | `0` | | Http2 `writeContinuationFrames` (large headers) | 64 KiB | `refCnt == 1` | `0` | At scale on the 256 B paths that is ~244 MiB leaked per 1M affected requests; on the CONTINUATION path (large headers) ~61 GiB per 1M. After the fix the leak is `0` regardless of request count. --------- Co-authored-by: HwangRock <157935545+HwangRock@users.noreply.github.com> Co-authored-by: Norman Maurer --- .../handler/codec/http/HttpObjectEncoder.java | 53 ++++-- .../codec/http/HttpResponseEncoderTest.java | 164 ++++++++++++++++++ .../codec/http2/DefaultHttp2FrameWriter.java | 73 +++++--- .../http2/DefaultHttp2FrameWriterTest.java | 129 ++++++++++++++ 4 files changed, 375 insertions(+), 44 deletions(-) diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpObjectEncoder.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpObjectEncoder.java index b80e3d235e8..d760cd0d827 100755 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpObjectEncoder.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpObjectEncoder.java @@ -326,19 +326,26 @@ private void encodeFullHttpMessage(ChannelHandlerContext ctx, Object o, List allocated = new ArrayList(); + private final int failOnInitialCapacity; + + TrackingFailingAllocator() { + this(NEVER_FAIL); + } + + TrackingFailingAllocator(int failOnInitialCapacity) { + super(false); + this.failOnInitialCapacity = failOnInitialCapacity; + } + + private void failIfTargeted(int initialCapacity) { + if (failOnInitialCapacity != NEVER_FAIL && initialCapacity == failOnInitialCapacity) { + throw new OutOfMemoryError("simulated allocation failure for capacity " + initialCapacity); + } + } + + @Override + protected ByteBuf newHeapBuffer(int initialCapacity, int maxCapacity) { + failIfTargeted(initialCapacity); + ByteBuf buf = delegate.heapBuffer(initialCapacity, maxCapacity); + allocated.add(buf); + return buf; + } + + @Override + protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) { + failIfTargeted(initialCapacity); + ByteBuf buf = delegate.directBuffer(initialCapacity, maxCapacity); + allocated.add(buf); + return buf; + } + + @Override + public boolean isDirectBufferPooled() { + return delegate.isDirectBufferPooled(); + } + } + + private static final class ThrowingHeaders extends DefaultHttpHeaders { + @Override + public Iterator> iteratorCharSequence() { + return new Iterator>() { + @Override + public boolean hasNext() { + return true; + } + + @Override + public Entry next() { + throw new OutOfMemoryError("simulated header encoding failure"); + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } + } } diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameWriter.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameWriter.java index 9fb1d6cd03a..f637fa8a84f 100644 --- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameWriter.java +++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2FrameWriter.java @@ -359,6 +359,7 @@ public ChannelFuture writePing(ChannelHandlerContext ctx, boolean ack, long data public ChannelFuture writePushPromise(ChannelHandlerContext ctx, int streamId, int promisedStreamId, Http2Headers headers, int padding, ChannelPromise promise) { ByteBuf headerBlock = null; + ByteBuf fragment = null; SimpleChannelPromiseAggregator promiseAggregator = new SimpleChannelPromiseAggregator(promise, ctx.channel(), ctx.executor()); try { @@ -375,7 +376,7 @@ public ChannelFuture writePushPromise(ChannelHandlerContext ctx, int streamId, // INT_FIELD_LENGTH is for the length of the promisedStreamId int nonFragmentLength = INT_FIELD_LENGTH + padding; int maxFragmentLength = maxFrameSize - nonFragmentLength; - ByteBuf fragment = headerBlock.readRetainedSlice(min(headerBlock.readableBytes(), maxFragmentLength)); + fragment = headerBlock.readRetainedSlice(min(headerBlock.readableBytes(), maxFragmentLength)); flags.endOfHeaders(!headerBlock.isReadable()); @@ -390,6 +391,7 @@ public ChannelFuture writePushPromise(ChannelHandlerContext ctx, int streamId, // Write the first fragment. ctx.write(fragment, promiseAggregator.newPromise()); + fragment = null; // Write out the padding, if any. if (paddingBytes(padding) > 0) { @@ -406,6 +408,9 @@ public ChannelFuture writePushPromise(ChannelHandlerContext ctx, int streamId, promiseAggregator.doneAllocatingPromises(); PlatformDependent.throwException(t); } finally { + if (fragment != null) { + fragment.release(); + } if (headerBlock != null) { headerBlock.release(); } @@ -497,6 +502,7 @@ private ChannelFuture writeHeadersInternal(ChannelHandlerContext ctx, int streamId, Http2Headers headers, int padding, boolean endStream, boolean hasPriority, int streamDependency, short weight, boolean exclusive, ChannelPromise promise) { ByteBuf headerBlock = null; + ByteBuf fragment = null; SimpleChannelPromiseAggregator promiseAggregator = new SimpleChannelPromiseAggregator(promise, ctx.channel(), ctx.executor()); try { @@ -517,7 +523,7 @@ private ChannelFuture writeHeadersInternal(ChannelHandlerContext ctx, // Read the first fragment (possibly everything). int nonFragmentBytes = padding + flags.getNumPriorityBytes(); int maxFragmentLength = maxFrameSize - nonFragmentBytes; - ByteBuf fragment = headerBlock.readRetainedSlice(min(headerBlock.readableBytes(), maxFragmentLength)); + fragment = headerBlock.readRetainedSlice(min(headerBlock.readableBytes(), maxFragmentLength)); // Set the end of headers flag for the first frame. flags.endOfHeaders(!headerBlock.isReadable()); @@ -537,6 +543,7 @@ private ChannelFuture writeHeadersInternal(ChannelHandlerContext ctx, // Write the first fragment. ctx.write(fragment, promiseAggregator.newPromise()); + fragment = null; // Write out the padding, if any. if (paddingBytes(padding) > 0) { @@ -553,6 +560,9 @@ private ChannelFuture writeHeadersInternal(ChannelHandlerContext ctx, promiseAggregator.doneAllocatingPromises(); PlatformDependent.throwException(t); } finally { + if (fragment != null) { + fragment.release(); + } if (headerBlock != null) { headerBlock.release(); } @@ -568,33 +578,46 @@ private ChannelFuture writeContinuationFrames(ChannelHandlerContext ctx, int str Http2Flags flags = new Http2Flags(); if (headerBlock.isReadable()) { - int fragmentReadableBytes; ByteBuf buf = null; - - do { - fragmentReadableBytes = min(headerBlock.readableBytes(), maxFrameSize); - ByteBuf fragment = headerBlock.readRetainedSlice(fragmentReadableBytes); - - if (headerBlock.isReadable()) { - if (buf == null) { - buf = ctx.alloc().buffer(CONTINUATION_FRAME_HEADER_LENGTH); - writeFrameHeaderInternal(buf, fragmentReadableBytes, CONTINUATION, flags, streamId); - } - ctx.write(buf.retainedSlice(), promiseAggregator.newPromise()); - } else { - // The frame header is different for the last frame, so re-allocate and release the old buffer - if (buf != null) { - buf.release(); + try { + do { + fragmentReadableBytes = min(headerBlock.readableBytes(), maxFrameSize); + ByteBuf fragment = headerBlock.readRetainedSlice(fragmentReadableBytes); + boolean fragmentWritten = false; + try { + if (headerBlock.isReadable()) { + if (buf == null) { + buf = ctx.alloc().buffer(CONTINUATION_FRAME_HEADER_LENGTH); + writeFrameHeaderInternal(buf, fragmentReadableBytes, CONTINUATION, flags, streamId); + } + ctx.write(buf.retainedSlice(), promiseAggregator.newPromise()); + } else { + // The frame header is different for the last frame, so re-allocate and release + // the old buffer + if (buf != null) { + buf.release(); + buf = null; + } + flags = flags.endOfHeaders(true); + buf = ctx.alloc().buffer(CONTINUATION_FRAME_HEADER_LENGTH); + writeFrameHeaderInternal(buf, fragmentReadableBytes, CONTINUATION, flags, streamId); + ctx.write(buf, promiseAggregator.newPromise()); + buf = null; + } + ctx.write(fragment, promiseAggregator.newPromise()); + fragmentWritten = true; + } finally { + if (!fragmentWritten) { + fragment.release(); + } } - flags = flags.endOfHeaders(true); - buf = ctx.alloc().buffer(CONTINUATION_FRAME_HEADER_LENGTH); - writeFrameHeaderInternal(buf, fragmentReadableBytes, CONTINUATION, flags, streamId); - ctx.write(buf, promiseAggregator.newPromise()); + } while (headerBlock.isReadable()); + } finally { + if (buf != null) { + buf.release(); } - ctx.write(fragment, promiseAggregator.newPromise()); - - } while (headerBlock.isReadable()); + } } return promiseAggregator; } diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2FrameWriterTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2FrameWriterTest.java index 8311a20823e..5cd0f023c34 100644 --- a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2FrameWriterTest.java +++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2FrameWriterTest.java @@ -14,7 +14,9 @@ */ package io.netty.handler.codec.http2; +import io.netty.buffer.AbstractByteBufAllocator; import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; import io.netty.buffer.Unpooled; import io.netty.buffer.UnpooledByteBufAllocator; import io.netty.channel.Channel; @@ -27,6 +29,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.mockito.invocation.InvocationOnMock; @@ -34,10 +37,14 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertArrayEquals; 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.mockito.Mockito.*; /** @@ -359,6 +366,128 @@ public void writePriorityDefaults() { assertEquals(expectedOutbound, outbound); } + @Test + public void writeHeadersReleasesHeaderBlockWhenFrameHeaderAllocationFails() { + TrackingFailingAllocator allocator = + new TrackingFailingAllocator(Http2CodecUtil.HEADERS_FRAME_HEADER_LENGTH); + when(ctx.alloc()).thenReturn(allocator); + + final int streamId = 1; + final Http2Headers headers = new DefaultHttp2Headers() + .method("GET").path("/").authority("foo.com").scheme("https"); + + assertThrows(OutOfMemoryError.class, new Executable() { + @Override + public void execute() throws Throwable { + frameWriter.writeHeaders(ctx, streamId, headers, 0, true, promise); + } + }); + + assertAllTrackedBuffersReleased(allocator); + } + + @Test + public void writePushPromiseReleasesHeaderBlockWhenFrameHeaderAllocationFails() { + TrackingFailingAllocator allocator = + new TrackingFailingAllocator(Http2CodecUtil.PUSH_PROMISE_FRAME_HEADER_LENGTH); + when(ctx.alloc()).thenReturn(allocator); + + final int streamId = 1; + final int promisedStreamId = 2; + final Http2Headers headers = new DefaultHttp2Headers() + .method("GET").path("/").authority("foo.com").scheme("https"); + + assertThrows(OutOfMemoryError.class, new Executable() { + @Override + public void execute() throws Throwable { + frameWriter.writePushPromise(ctx, streamId, promisedStreamId, headers, 0, promise); + } + }); + + assertAllTrackedBuffersReleased(allocator); + } + + @Test + public void writeContinuationFramesReleasesHeaderBlockWhenFrameHeaderAllocationFails() throws Exception { + final int streamId = 1; + Http2Headers headers = new DefaultHttp2Headers() + .method("GET").path("/").authority("foo.com").scheme("https"); + final Http2Headers largeHeaders = dummyHeaders(headers, 60); + + http2HeadersEncoder.configuration().maxHeaderListSize(Integer.MAX_VALUE); + frameWriter.headersConfiguration().maxHeaderListSize(Integer.MAX_VALUE); + frameWriter.maxFrameSize(Http2CodecUtil.MAX_FRAME_SIZE_LOWER_BOUND); + + TrackingFailingAllocator allocator = + new TrackingFailingAllocator(Http2CodecUtil.CONTINUATION_FRAME_HEADER_LENGTH); + when(ctx.alloc()).thenReturn(allocator); + + assertThrows(OutOfMemoryError.class, new Executable() { + @Override + public void execute() throws Throwable { + frameWriter.writeHeaders(ctx, streamId, largeHeaders, 0, true, promise); + } + }); + + assertAllTrackedBuffersReleased(allocator); + } + + private static void assertAllTrackedBuffersReleased(TrackingFailingAllocator allocator) { + assertFalse(allocator.allocated.isEmpty(), "expected at least one buffer to be allocated"); + for (ByteBuf buf : allocator.allocated) { + assertEquals(0, buf.refCnt(), "expected tracked buffer to be fully released"); + } + } + + /** + * A {@link ByteBufAllocator} that throws an {@link OutOfMemoryError} when asked to allocate a buffer with a + * given {@code initialCapacity}, and otherwise delegates to an unpooled allocator while tracking every + * successfully allocated buffer for leak verification. + */ + private static final class TrackingFailingAllocator extends AbstractByteBufAllocator { + private static final int NEVER_FAIL = -1; + + private final ByteBufAllocator delegate = new UnpooledByteBufAllocator(false); + private final List allocated = new ArrayList(); + private final int failOnInitialCapacity; + + TrackingFailingAllocator() { + this(NEVER_FAIL); + } + + TrackingFailingAllocator(int failOnInitialCapacity) { + super(false); + this.failOnInitialCapacity = failOnInitialCapacity; + } + + private void failIfTargeted(int initialCapacity) { + if (failOnInitialCapacity != NEVER_FAIL && initialCapacity == failOnInitialCapacity) { + throw new OutOfMemoryError("simulated allocation failure for capacity " + initialCapacity); + } + } + + @Override + protected ByteBuf newHeapBuffer(int initialCapacity, int maxCapacity) { + failIfTargeted(initialCapacity); + ByteBuf buf = delegate.heapBuffer(initialCapacity, maxCapacity); + allocated.add(buf); + return buf; + } + + @Override + protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) { + failIfTargeted(initialCapacity); + ByteBuf buf = delegate.directBuffer(initialCapacity, maxCapacity); + allocated.add(buf); + return buf; + } + + @Override + public boolean isDirectBufferPooled() { + return delegate.isDirectBufferPooled(); + } + } + private byte[] headerPayload(int streamId, Http2Headers headers, byte padding) throws Http2Exception, IOException { if (padding == 0) { return headerPayload(streamId, headers); From f4f1b9d19e70dd5a6498f3ab5ccbb775d32aa243 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Mon, 3 Aug 2026 05:58:09 -0700 Subject: [PATCH 53/64] BrotliEncoder: Prevent duplicate close scheduling (#17175) (#17193) Motivation: `BrotliEncoderChannel.close()` closes its destination, which re-enters `BrotliEncoder.Writer.close()`. The re-entrant call schedules another finish task before `isClosed` is set. If the first finish fails, the second task can retry with an already-failed close promise. Modification: Track whether writer close has already been initiated and ignore subsequent close calls before scheduling another finish task. Result: Brotli encoder close is idempotent while a finish is pending, and exceptional finalization no longer retries with an already-completed promise. Co-authored-by: Jonas Konrad Co-authored-by: multicode --- .../io/netty/handler/codec/compression/BrotliEncoder.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codec/src/main/java/io/netty/handler/codec/compression/BrotliEncoder.java b/codec/src/main/java/io/netty/handler/codec/compression/BrotliEncoder.java index 652dd8b453a..2ed026ae666 100644 --- a/codec/src/main/java/io/netty/handler/codec/compression/BrotliEncoder.java +++ b/codec/src/main/java/io/netty/handler/codec/compression/BrotliEncoder.java @@ -187,6 +187,7 @@ private static final class Writer implements WritableByteChannel { private ByteBuf writableBuffer; private final BrotliEncoderChannel brotliEncoderChannel; private final ChannelHandlerContext ctx; + private boolean closeInitiated; private boolean isClosed; private Writer(Encoder.Parameters parameters, ChannelHandlerContext ctx) throws IOException { @@ -240,8 +241,11 @@ public boolean isOpen() { @Override public void close() { + if (closeInitiated) { + return; + } + closeInitiated = true; final ChannelPromise promise = ctx.newPromise(); - ctx.executor().execute(new Runnable() { @Override public void run() { From 9cdfef43d58cbcfb9083fbad3700609bc0b71fd0 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:07:23 +0200 Subject: [PATCH 54/64] Auto-port 4.1: Update compress-lzf to 1.2.1 (#17197) Auto-port of #17194 to 4.1 Cherry-picked commit: 035d76e3f43fa462e101c1e03b59a69984c5ebf3 --- Motivation: Update Netty's optional LZF compression dependency to the latest Maven Central release. Modification: Bump `com.ning:compress-lzf` from `1.2.0` to `1.2.1` in root dependency management. Result: The `codec-compression` LZF tests pass locally: ```bash ./mvnw -B -ntp -Dmaven.repo.local=/tmp/opencode/netty-maven-home/repository \ -pl codec-compression -am test \ -Dtest=LzfEncoderTest,LzfDecoderTest,LzfIntegrationTest,LengthAwareLzfIntegrationTest \ -Dsurefire.failIfNoSpecifiedTests=false ``` Tests run: 36, Failures: 0, Errors: 0, Skipped: 0 Co-authored-by: Jonas Konrad Co-authored-by: multicode --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e11b03a5bb2..80fcd6f9ba0 100644 --- a/pom.xml +++ b/pom.xml @@ -1058,7 +1058,7 @@ com.ning compress-lzf - 1.2.0 + 1.2.1 at.yawk.lz4 From 95bc09d63093b758cf1b63f241e0f0bceec3e5c5 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Mon, 3 Aug 2026 10:37:36 -0700 Subject: [PATCH 55/64] Do not write WebSocket handshake response to the tail of the pipeline (#17192) (#17200) `WebSocketServerHandshaker` writes the 101 Switching Protocols response via `Channel.writeAndFlush()`. The write starts at the tail of the pipeline, so every `ChannelOutboundHandler` placed after `WebSocketServerProtocolHandler` sees the raw HTTP upgrade response, even though handlers there operate on WebSocket frames. In #17141 a handler that queues outbound messages until `HandshakeComplete` captured the response itself, so the handshake could never complete. `WebSocketServerHandshaker` already provides `ChannelHandlerContext` overloads for `close()` for the same reason; this change applies the same pattern to the handshake response. - Add `handshake(ChannelHandlerContext, ...)` overloads to `WebSocketServerHandshaker`, following the existing `close(ChannelHandlerContext, ...)` overloads in the same class, and route both variants through a shared `handshake0(ChannelOutboundInvoker, Channel, ...)`. - `WebSocketServerProtocolHandshakeHandler` now passes its ctx, so the response is written from the handshake handler's former position. - Update the benchmarkserver example to use the new overload. - Tests: a regression test asserts a handler behind the protocol handler observes no write during the handshake, for both full and non-full upgrade requests. Existing tests that captured the response behind the protocol handler now read it via `readOutbound()`. Four `handshake(null, ...)` calls needed a `(Channel)` cast to stay unambiguous. Notes: - With the documented pipeline ordering (extension/compression handler before the protocol handler, as in `WebSocketServerInitializer`) nothing changes: the write still traverses `WebSocketServerExtensionHandler` and the HTTP encoder. Only handlers behind the protocol handler stop seeing the 101, which is the bug being fixed. - The tail-write behavior of the `Channel` overloads is unchanged and still covered by `WebSocketServerHandshaker00/08/13Test` and `WebSocketServerHandshakerTest`. Fixes #17141. --------- Co-authored-by: el-psy-kongroo-d <307969302+el-psy-kongroo-d@users.noreply.github.com> Co-authored-by: Norman Maurer Co-authored-by: el-psy-kongroo-d Co-authored-by: el-psy-kongroo-d <307969302+el-psy-kongroo-d@users.noreply.github.com> --- .../websocketx/WebSocketServerHandshaker.java | 122 +++++++++++++++++- ...bSocketServerProtocolHandshakeHandler.java | 2 +- .../WebSocketServerHandshakerTest.java | 9 +- .../WebSocketServerProtocolHandlerTest.java | 80 ++++++++++-- .../WebSocketServerHandler.java | 2 +- 5 files changed, 193 insertions(+), 22 deletions(-) diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker.java b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker.java index 5271102b7eb..617b19b316f 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshaker.java @@ -166,6 +166,9 @@ public WebSocketDecoderConfig decoderConfig() { * Performs the opening handshake. When call this method you MUST NOT retain the * {@link FullHttpRequest} which is passed in. * + * When called from within a {@link ChannelHandler} you most likely want to use + * {@link #handshake(ChannelHandlerContext, FullHttpRequest)}. + * * @param channel * Channel * @param req @@ -182,6 +185,9 @@ public ChannelFuture handshake(Channel channel, FullHttpRequest req) { * * When call this method you MUST NOT retain the {@link FullHttpRequest} which is passed in. * + * When called from within a {@link ChannelHandler} you most likely want to use + * {@link #handshake(ChannelHandlerContext, FullHttpRequest, HttpHeaders, ChannelPromise)}. + * * @param channel * Channel * @param req @@ -195,6 +201,57 @@ public ChannelFuture handshake(Channel channel, FullHttpRequest req) { */ public final ChannelFuture handshake(Channel channel, FullHttpRequest req, HttpHeaders responseHeaders, final ChannelPromise promise) { + return handshake0(channel, channel, req, responseHeaders, promise); + } + + /** + * Performs the opening handshake. When call this method you MUST NOT retain the + * {@link FullHttpRequest} which is passed in. + * + * The handshake response is written to the given {@link ChannelHandlerContext}, so handlers placed + * after the {@link ChannelHandler} the context belongs to will not see the response. The handler the + * context belongs to must be placed after the HTTP encoder as the response still needs to be encoded. + * + * @param ctx + * the {@link ChannelHandlerContext} to use. + * @param req + * HTTP Request + * @return future + * The {@link ChannelFuture} which is notified once the opening handshake completes + */ + public ChannelFuture handshake(ChannelHandlerContext ctx, FullHttpRequest req) { + ObjectUtil.checkNotNull(ctx, "ctx"); + return handshake(ctx, req, null, ctx.newPromise()); + } + + /** + * Performs the opening handshake + * + * When call this method you MUST NOT retain the {@link FullHttpRequest} which is passed in. + * + * The handshake response is written to the given {@link ChannelHandlerContext}, so handlers placed + * after the {@link ChannelHandler} the context belongs to will not see the response. The handler the + * context belongs to must be placed after the HTTP encoder as the response still needs to be encoded. + * + * @param ctx + * the {@link ChannelHandlerContext} to use. + * @param req + * HTTP Request + * @param responseHeaders + * Extra headers to add to the handshake response or {@code null} if no extra headers should be added + * @param promise + * the {@link ChannelPromise} to be notified when the opening handshake is done + * @return future + * the {@link ChannelFuture} which is notified when the opening handshake is done + */ + public ChannelFuture handshake(ChannelHandlerContext ctx, FullHttpRequest req, + HttpHeaders responseHeaders, final ChannelPromise promise) { + ObjectUtil.checkNotNull(ctx, "ctx"); + return handshake0(ctx, ctx.channel(), req, responseHeaders, promise); + } + + private ChannelFuture handshake0(final ChannelOutboundInvoker invoker, final Channel channel, FullHttpRequest req, + HttpHeaders responseHeaders, final ChannelPromise promise) { if (logger.isDebugEnabled()) { logger.debug("{} WebSocket version {} server handshake", channel, version()); @@ -227,7 +284,7 @@ public final ChannelFuture handshake(Channel channel, FullHttpRequest req, encoderName = p.context(HttpResponseEncoder.class).name(); p.addBefore(encoderName, "wsencoder", newWebSocketEncoder()); } - channel.writeAndFlush(response).addListener(new ChannelFutureListener() { + invoker.writeAndFlush(response).addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { if (future.isSuccess()) { @@ -246,6 +303,9 @@ public void operationComplete(ChannelFuture future) throws Exception { * Performs the opening handshake. When call this method you MUST NOT retain the * {@link FullHttpRequest} which is passed in. * + * When called from within a {@link ChannelHandler} you most likely want to use + * {@link #handshake(ChannelHandlerContext, HttpRequest)}. + * * @param channel * Channel * @param req @@ -262,6 +322,9 @@ public ChannelFuture handshake(Channel channel, HttpRequest req) { * * When call this method you MUST NOT retain the {@link HttpRequest} which is passed in. * + * When called from within a {@link ChannelHandler} you most likely want to use + * {@link #handshake(ChannelHandlerContext, HttpRequest, HttpHeaders, ChannelPromise)}. + * * @param channel * Channel * @param req @@ -275,8 +338,59 @@ public ChannelFuture handshake(Channel channel, HttpRequest req) { */ public final ChannelFuture handshake(final Channel channel, HttpRequest req, final HttpHeaders responseHeaders, final ChannelPromise promise) { + return handshake0(channel, channel, req, responseHeaders, promise); + } + + /** + * Performs the opening handshake. When call this method you MUST NOT retain the + * {@link HttpRequest} which is passed in. + * + * The handshake response is written to the given {@link ChannelHandlerContext}, so handlers placed + * after the {@link ChannelHandler} the context belongs to will not see the response. The handler the + * context belongs to must be placed after the HTTP encoder as the response still needs to be encoded. + * + * @param ctx + * the {@link ChannelHandlerContext} to use. + * @param req + * HTTP Request + * @return future + * The {@link ChannelFuture} which is notified once the opening handshake completes + */ + public ChannelFuture handshake(ChannelHandlerContext ctx, HttpRequest req) { + ObjectUtil.checkNotNull(ctx, "ctx"); + return handshake(ctx, req, null, ctx.newPromise()); + } + + /** + * Performs the opening handshake + * + * When call this method you MUST NOT retain the {@link HttpRequest} which is passed in. + * + * The handshake response is written to the given {@link ChannelHandlerContext}, so handlers placed + * after the {@link ChannelHandler} the context belongs to will not see the response. The handler the + * context belongs to must be placed after the HTTP encoder as the response still needs to be encoded. + * + * @param ctx + * the {@link ChannelHandlerContext} to use. + * @param req + * HTTP Request + * @param responseHeaders + * Extra headers to add to the handshake response or {@code null} if no extra headers should be added + * @param promise + * the {@link ChannelPromise} to be notified when the opening handshake is done + * @return future + * the {@link ChannelFuture} which is notified when the opening handshake is done + */ + public ChannelFuture handshake(ChannelHandlerContext ctx, HttpRequest req, + final HttpHeaders responseHeaders, final ChannelPromise promise) { + ObjectUtil.checkNotNull(ctx, "ctx"); + return handshake0(ctx, ctx.channel(), req, responseHeaders, promise); + } + + private ChannelFuture handshake0(final ChannelOutboundInvoker invoker, final Channel channel, HttpRequest req, + final HttpHeaders responseHeaders, final ChannelPromise promise) { if (req instanceof FullHttpRequest) { - return handshake(channel, (FullHttpRequest) req, responseHeaders, promise); + return handshake0(invoker, channel, (FullHttpRequest) req, responseHeaders, promise); } if (logger.isDebugEnabled()) { @@ -350,7 +464,7 @@ public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { private void handleHandshakeRequest(ChannelHandlerContext ctx, HttpObject httpObject) { if (httpObject instanceof FullHttpRequest) { ctx.pipeline().remove(this); - handshake(channel, (FullHttpRequest) httpObject, responseHeaders, promise); + handshake0(invoker, channel, (FullHttpRequest) httpObject, responseHeaders, promise); return; } @@ -360,7 +474,7 @@ private void handleHandshakeRequest(ChannelHandlerContext ctx, HttpObject httpOb fullHttpRequest = null; try { ctx.pipeline().remove(this); - handshake(channel, handshakeRequest, responseHeaders, promise); + handshake0(invoker, channel, handshakeRequest, responseHeaders, promise); } finally { handshakeRequest.release(); } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerProtocolHandshakeHandler.java b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerProtocolHandshakeHandler.java index 6cbb25df2a8..c8b8f7de341 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerProtocolHandshakeHandler.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/websocketx/WebSocketServerProtocolHandshakeHandler.java @@ -85,7 +85,7 @@ public void channelRead(final ChannelHandlerContext ctx, Object msg) throws Exce WebSocketServerProtocolHandler.setHandshaker(ctx.channel(), handshaker); ctx.pipeline().remove(this); - final ChannelFuture handshakeFuture = handshaker.handshake(ctx.channel(), req); + final ChannelFuture handshakeFuture = handshaker.handshake(ctx, req); handshakeFuture.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) { diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshakerTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshakerTest.java index 4e182671d94..d3912230969 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshakerTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerHandshakerTest.java @@ -17,6 +17,7 @@ import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.http.DefaultFullHttpRequest; @@ -112,7 +113,7 @@ public void testWebSocketServerHandshakeException() { "ws://example.com/chat"); request.headers().set("x-client-header", "value"); try { - serverHandshaker.handshake(null, request, null, null); + serverHandshaker.handshake((Channel) null, request, null, null); } catch (WebSocketServerHandshakeException exception) { assertNotNull(exception.getMessage()); assertEquals(request.headers(), exception.request().headers()); @@ -195,7 +196,7 @@ public void testHandshakeExceptionWhenConnectionHeaderIsAbsent() { Throwable exception = assertThrows(WebSocketServerHandshakeException.class, new Executable() { @Override public void execute() throws Throwable { - serverHandshaker.handshake(null, request, null, null); + serverHandshaker.handshake((Channel) null, request, null, null); } }); @@ -221,7 +222,7 @@ public void testHandshakeExceptionWhenInvalidConnectionHeader() { Throwable exception = assertThrows(WebSocketServerHandshakeException.class, new Executable() { @Override public void execute() throws Throwable { - serverHandshaker.handshake(null, request, null, null); + serverHandshaker.handshake((Channel) null, request, null, null); } }); @@ -247,7 +248,7 @@ public void testHandshakeExceptionWhenInvalidUpgradeHeader() { Throwable exception = assertThrows(WebSocketServerHandshakeException.class, new Executable() { @Override public void execute() throws Throwable { - serverHandshaker.handshake(null, request, null, null); + serverHandshaker.handshake((Channel) null, request, null, null); } }); diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerProtocolHandlerTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerProtocolHandlerTest.java index bfad1c6e6b4..4e56683cf9f 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerProtocolHandlerTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/websocketx/WebSocketServerProtocolHandlerTest.java @@ -77,7 +77,7 @@ private void testHttpUpgradeRequest0(boolean full) { ChannelHandlerContext handshakerCtx = ch.pipeline().context(WebSocketServerProtocolHandshakeHandler.class); writeUpgradeRequest(ch, full); - FullHttpResponse response = responses.remove(); + FullHttpResponse response = ch.readOutbound(); assertEquals(SWITCHING_PROTOCOLS, response.status()); response.release(); assertNotNull(WebSocketServerProtocolHandler.getHandshaker(handshakerCtx.channel())); @@ -99,7 +99,7 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { }); writeUpgradeRequest(ch); - FullHttpResponse response = responses.remove(); + FullHttpResponse response = ch.readOutbound(); assertEquals(SWITCHING_PROTOCOLS, response.status()); response.release(); assertNotNull(WebSocketServerProtocolHandler.getHandshaker(handshakerCtx.channel())); @@ -161,7 +161,7 @@ public void testCreateUTF8Validator() { new MockOutboundHandler()); writeUpgradeRequest(ch); - FullHttpResponse response = responses.remove(); + FullHttpResponse response = ch.readOutbound(); assertEquals(SWITCHING_PROTOCOLS, response.status()); response.release(); @@ -182,7 +182,7 @@ public void testDoNotCreateUTF8Validator() { new MockOutboundHandler()); writeUpgradeRequest(ch); - FullHttpResponse response = responses.remove(); + FullHttpResponse response = ch.readOutbound(); assertEquals(SWITCHING_PROTOCOLS, response.status()); response.release(); @@ -195,7 +195,7 @@ public void testHandleTextFrame() { EmbeddedChannel ch = createChannel(customTextFrameHandler); writeUpgradeRequest(ch); - FullHttpResponse response = responses.remove(); + FullHttpResponse response = ch.readOutbound(); assertEquals(SWITCHING_PROTOCOLS, response.status()); response.release(); @@ -227,18 +227,21 @@ public void testCheckWebSocketPathStartWithSlash() { FullHttpResponse response; - createChannel(config, null).writeInbound(builder.uri("/test").build()); - response = responses.remove(); + EmbeddedChannel ch = createChannel(config, null); + ch.writeInbound(builder.uri("/test").build()); + response = ch.readOutbound(); assertEquals(SWITCHING_PROTOCOLS, response.status()); response.release(); - createChannel(config, null).writeInbound(builder.uri("/?q=v").build()); - response = responses.remove(); + ch = createChannel(config, null); + ch.writeInbound(builder.uri("/?q=v").build()); + response = ch.readOutbound(); assertEquals(SWITCHING_PROTOCOLS, response.status()); response.release(); - createChannel(config, null).writeInbound(builder.uri("/").build()); - response = responses.remove(); + ch = createChannel(config, null); + ch.writeInbound(builder.uri("/").build()); + response = ch.readOutbound(); assertEquals(SWITCHING_PROTOCOLS, response.status()); response.release(); } @@ -266,7 +269,7 @@ public void testCheckValidWebSocketPath() { new MockOutboundHandler()); ch.writeInbound(httpRequest); - FullHttpResponse response = responses.remove(); + FullHttpResponse response = ch.readOutbound(); assertEquals(SWITCHING_PROTOCOLS, response.status()); response.release(); } @@ -400,6 +403,59 @@ public void testCloseFrameSentWhenClientChannelClosedSilently() throws Exception assertFalse(server.finishAndReleaseAll()); } + @Test + public void testHandshakeResponseNotSeenByHandlersAfterProtocolHandlerFull() throws Exception { + testHandshakeResponseNotSeenByHandlersAfterProtocolHandler0(true); + } + + @Test + public void testHandshakeResponseNotSeenByHandlersAfterProtocolHandlerNonFull() throws Exception { + testHandshakeResponseNotSeenByHandlersAfterProtocolHandler0(false); + } + + private void testHandshakeResponseNotSeenByHandlersAfterProtocolHandler0(boolean full) throws Exception { + final Queue writtenAfterProtocolHandler = new ArrayDeque(); + EmbeddedChannel client = createClient(); + final EmbeddedChannel server; + if (full) { + server = createServer(); + } else { + // No HttpObjectAggregator so that the handshake handler receives a plain HttpRequest. + server = new EmbeddedChannel( + new HttpServerCodec(), + new WebSocketServerProtocolHandler(WebSocketServerProtocolConfig.newBuilder() + .websocketPath("/test") + .dropPongFrames(false) + .build())); + } + // Added after register() so the recorder really sits behind the WebSocketServerProtocolHandler. + server.pipeline().addLast(new ChannelOutboundHandlerAdapter() { + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + writtenAfterProtocolHandler.add(msg); + ctx.write(msg, promise); + } + }); + + assertFalse(server.writeInbound(client.readOutbound())); + assertFalse(client.writeInbound(server.readOutbound())); + + // The handshake response must not pass through handlers placed after the protocol handler. + assertTrue(writtenAfterProtocolHandler.isEmpty()); + + client.close(); + assertFalse(server.writeInbound(client.readOutbound())); + assertFalse(client.isOpen()); + assertFalse(server.isOpen()); + + CloseWebSocketFrame closeMessage = decode(server.readOutbound(), CloseWebSocketFrame.class); + assertEquals(closeMessage.statusCode(), WebSocketCloseStatus.NORMAL_CLOSURE.code()); + closeMessage.release(); + + assertFalse(client.finishAndReleaseAll()); + assertFalse(server.finishAndReleaseAll()); + } + private EmbeddedChannel createClient(ChannelHandler... handlers) throws Exception { WebSocketClientProtocolConfig clientConfig = WebSocketClientProtocolConfig.newBuilder() .webSocketUri("http://test/test") diff --git a/example/src/main/java/io/netty/example/http/websocketx/benchmarkserver/WebSocketServerHandler.java b/example/src/main/java/io/netty/example/http/websocketx/benchmarkserver/WebSocketServerHandler.java index fcc37da054a..98ae78a8b8a 100644 --- a/example/src/main/java/io/netty/example/http/websocketx/benchmarkserver/WebSocketServerHandler.java +++ b/example/src/main/java/io/netty/example/http/websocketx/benchmarkserver/WebSocketServerHandler.java @@ -103,7 +103,7 @@ private void handleHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) { if (handshaker == null) { WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel()); } else { - handshaker.handshake(ctx.channel(), req); + handshaker.handshake(ctx, req); } } From 1b8faadf159a7cfdd5d3e20068c12edf4bfe46b2 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Mon, 3 Aug 2026 15:13:44 -0700 Subject: [PATCH 56/64] `HttpServerCodec`: do not consume the method queue for 1xx interim responses (#17182) (#17203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #17181. `HttpClientCodec` and `HttpContentEncoder`, but `HttpServerCodec` was left out. `isContentAlwaysEmpty(HttpResponse)` in `HttpServerCodec` polls the request method queue for every response it encodes: ```java @Override protected boolean isContentAlwaysEmpty(HttpResponse msg) { methodFlag = pollMethod(); return methodFlag == METHOD_FLAG_HEAD || super.isContentAlwaysEmpty(msg); } ``` It is invoked for every `HttpResponse` on both encoder paths — `encodeFullHttpMessage()` and `encodeInitHttpMessage()` in `HttpObjectEncoder`. An interim response is not the final response to the queued request, so consuming an entry shifts the queue by one and the wrong method gets paired with the final response. Both failure modes are reachable from first-party handlers: `HttpServerExpectContinueHandler` and `HttpObjectAggregator` write `100 Continue` through this encoder. Skip `pollMethod()` when the response status class is `INFORMATIONAL` and delegate to the super method, mirroring the guard already present in `isContentAlwaysEmpty(HttpMessage)` in `HttpClientCodec`. `methodFlag` is intentionally left untouched on the interim path. `sanitizeHeadersBeforeEncode()` reads it for the CONNECT check, but that branch also requires `codeClass() == SUCCESS`, which a 1xx response never satisfies. Interim responses no longer shift the method queue, so HEAD and CONNECT are paired with their own final response. Encoder output for the two broken cases, measured with the tests added here: | scenario | before | after | |---|---|---| | `HEAD /a`, then `103`, then final `200 OK` with content | `HTTP/1.1 200 OK\r\ncontent-length: 4\r\n\r\nbody` | `HTTP/1.1 200 OK\r\ncontent-length: 4\r\n\r\n` | | pipelined `GET /a` + `HEAD /b`, then `103`, then `/a`'s `200 OK` with content | `HTTP/1.1 200 OK\r\ncontent-length: 6\r\n\r\n` | `HTTP/1.1 200 OK\r\ncontent-length: 6\r\n\r\nbody-a` | | `HEAD /` with `Upgrade`, then `101` | `HTTP/1.1 101 Switching Protocols\r\nconnection: upgrade\r\nupgrade: websocket\r\n\r\n` | unchanged | The first row is a HEAD response carrying a body, which per RFC 9110 section 9.3.2 must never happen — on a keep-alive connection the peer reads those bytes as the start of the next response. The second is a non-HEAD response losing its body while keeping `Content-Length`, leaving the peer waiting for six bytes that never arrive. Sequential request/response traffic recovers on its own, since an empty queue falls back to `METHOD_FLAG_OTHER`. Corruption needs either a 1xx preceding a HEAD response, or pipelining combined with a 1xx. 101 is included in `INFORMATIONAL` and is therefore covered by the guard, with no change on the wire. After switching protocols the codec is removed from the pipeline — by `upgradeFrom()` in `HttpServerCodec` for h2c, and by `WebSocketServerHandshaker` for WebSocket — so the entry left behind is discarded together with the handler. The third row above pins this. This also corrects CONNECT: previously a 1xx could consume the CONNECT entry, so the final 2xx response missed the `Transfer-Encoding` stripping in `sanitizeHeadersBeforeEncode()`. `codec-http` passes in full: 8973 tests, 0 failures. Co-authored-by: Norman Maurer --------- Co-authored-by: HwangRock <157935545+HwangRock@users.noreply.github.com> Co-authored-by: Chris Vest --- .../handler/codec/http/HttpServerCodec.java | 7 +- .../codec/http/HttpServerCodecTest.java | 354 ++++++++++++++++++ 2 files changed, 360 insertions(+), 1 deletion(-) diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpServerCodec.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpServerCodec.java index 9e1526d0a96..2a8933e3a81 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpServerCodec.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpServerCodec.java @@ -28,7 +28,6 @@ import static io.netty.handler.codec.http.HttpObjectDecoder.DEFAULT_MAX_CHUNK_SIZE; import static io.netty.handler.codec.http.HttpObjectDecoder.DEFAULT_MAX_HEADER_SIZE; import static io.netty.handler.codec.http.HttpObjectDecoder.DEFAULT_MAX_INITIAL_LINE_LENGTH; -import static io.netty.handler.codec.http.HttpObjectDecoder.DEFAULT_VALIDATE_HEADERS; /** * A combination of {@link HttpRequestDecoder} and {@link HttpResponseEncoder} @@ -216,6 +215,12 @@ protected void sanitizeHeadersBeforeEncode(HttpResponse msg, boolean isAlwaysEmp @Override protected boolean isContentAlwaysEmpty(@SuppressWarnings("unused") HttpResponse msg) { + if (msg.status().codeClass() == HttpStatusClass.INFORMATIONAL) { + // An informational response should be excluded from paired comparison. This covers 101 as well: + // once the protocol is switched this handler is removed from the pipeline, so the entry that is + // left behind goes away with it. Just delegate to super method which has all the needed handling. + return super.isContentAlwaysEmpty(msg); + } method = queue.poll(); return HttpMethod.HEAD.equals(method) || super.isContentAlwaysEmpty(msg); } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/HttpServerCodecTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/HttpServerCodecTest.java index c58dddc81b3..d7a395b66da 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/HttpServerCodecTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/HttpServerCodecTest.java @@ -21,6 +21,7 @@ import io.netty.util.CharsetUtil; import io.netty.util.ReferenceCountUtil; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -227,6 +228,359 @@ public void testConnectionClosedAfterResponseWhenBothTransferEncodingAndContentL assertFalse(ch.finishAndReleaseAll()); } + @Test + public void testInterleavedRequestResponseAcrossOverflow() { + // Test interleaved enqueue/dequeue that crosses the inline-to-overflow boundary. + // Send some requests, process some responses, then send more requests to trigger overflow. + EmbeddedChannel ch = new EmbeddedChannel(new HttpServerCodec()); + + // Send 30 GET requests (not yet filling the 32-slot inline queue). + StringBuilder requests = new StringBuilder(); + for (int i = 0; i < 30; i++) { + requests.append("GET /").append(i).append(" HTTP/1.1\r\nHost: a\r\n\r\n"); + } + assertTrue(ch.writeInbound(Unpooled.copiedBuffer(requests.toString(), CharsetUtil.UTF_8))); + + // Drain inbound. + for (;;) { + Object msg = ch.readInbound(); + if (msg == null) { + break; + } + if (msg instanceof HttpContent) { + ((HttpContent) msg).release(); + } + } + + // Respond to 10 of them (draining 10 from the queue, leaving 20). + for (int i = 0; i < 10; i++) { + FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, + Unpooled.copiedBuffer("ok", CharsetUtil.UTF_8)); + resp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 2); + assertTrue(ch.writeOutbound(resp)); + ByteBuf buf = ch.readOutbound(); + buf.release(); + } + + // Now send 15 more requests (20 remaining + 15 = 35 total, exceeding inline capacity of 32). + // Put a HEAD request as the last one to verify overflow ordering. + requests = new StringBuilder(); + for (int i = 30; i < 44; i++) { + requests.append("GET /").append(i).append(" HTTP/1.1\r\nHost: a\r\n\r\n"); + } + requests.append("HEAD /44 HTTP/1.1\r\nHost: a\r\n\r\n"); + assertTrue(ch.writeInbound(Unpooled.copiedBuffer(requests.toString(), CharsetUtil.UTF_8))); + + // Drain inbound. + for (;;) { + Object msg = ch.readInbound(); + if (msg == null) { + break; + } + if (msg instanceof HttpContent) { + ((HttpContent) msg).release(); + } + } + + // Respond to remaining 20 GET requests (inline queue). + for (int i = 10; i < 30; i++) { + FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, + Unpooled.copiedBuffer("ok", CharsetUtil.UTF_8)); + resp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 2); + assertTrue(ch.writeOutbound(resp)); + ByteBuf buf = ch.readOutbound(); + buf.release(); + } + + // Respond to the 14 GET requests that were added. + for (int i = 30; i < 44; i++) { + FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, + Unpooled.copiedBuffer("ok", CharsetUtil.UTF_8)); + resp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 2); + assertTrue(ch.writeOutbound(resp)); + ByteBuf buf = ch.readOutbound(); + String encoded = buf.toString(CharsetUtil.US_ASCII); + assertTrue(encoded.contains("ok"), "GET response at position " + i + " should contain body"); + buf.release(); + } + + // Respond to the HEAD request at position 44 — must be content-always-empty. + HttpResponse headResp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + headResp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 999); + assertTrue(ch.writeOutbound(headResp)); + assertTrue(ch.writeOutbound(LastHttpContent.EMPTY_LAST_CONTENT)); + + ByteBuf buf = ch.readOutbound(); + String encoded = buf.toString(CharsetUtil.US_ASCII); + assertTrue(encoded.contains("HTTP/1.1 200 OK"), "HEAD response should be 200 OK"); + buf.release(); + + buf = ch.readOutbound(); + assertFalse(buf.isReadable(), "HEAD response body should be empty in overflow scenario"); + buf.release(); + + assertFalse(ch.finishAndReleaseAll()); + } + + @Test + public void testGetMethodHasNormalBody() { + // Verify a simple GET request is treated as METHOD_FLAG_NONE and body is included. + EmbeddedChannel ch = new EmbeddedChannel(new HttpServerCodec()); + + assertTrue(ch.writeInbound(Unpooled.copiedBuffer( + "GET / HTTP/1.1\r\nHost: a\r\n\r\n", CharsetUtil.UTF_8))); + + HttpRequest request = ch.readInbound(); + assertEquals(HttpMethod.GET, request.method()); + LastHttpContent content = ch.readInbound(); + content.release(); + + FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, + Unpooled.copiedBuffer("hello", CharsetUtil.UTF_8)); + resp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 5); + assertTrue(ch.writeOutbound(resp)); + + ByteBuf buf = ch.readOutbound(); + String encoded = buf.toString(CharsetUtil.US_ASCII); + assertTrue(encoded.contains("hello"), "GET response should contain body"); + assertTrue(encoded.contains("content-length: 5")); + buf.release(); + + assertFalse(ch.finishAndReleaseAll()); + } + + @Test + public void testPostMethodHasNormalBody() { + // POST should also be METHOD_FLAG_NONE. + EmbeddedChannel ch = new EmbeddedChannel(new HttpServerCodec()); + + assertTrue(ch.writeInbound(Unpooled.copiedBuffer( + "POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 0\r\n\r\n", CharsetUtil.UTF_8))); + + for (;;) { + Object msg = ch.readInbound(); + if (msg == null) { + break; + } + if (msg instanceof HttpContent) { + ((HttpContent) msg).release(); + } + } + + FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, + Unpooled.copiedBuffer("result", CharsetUtil.UTF_8)); + resp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 6); + assertTrue(ch.writeOutbound(resp)); + + ByteBuf buf = ch.readOutbound(); + String encoded = buf.toString(CharsetUtil.US_ASCII); + assertTrue(encoded.contains("result"), "POST response should contain body"); + buf.release(); + + assertFalse(ch.finishAndReleaseAll()); + } + + @Test + public void testOverflowDrainsBeforeInlineRefill() { + // Verify that once overflow is activated, subsequent enqueues also go to overflow + // until the overflow drains, keeping FIFO order correct. + EmbeddedChannel ch = new EmbeddedChannel(new HttpServerCodec()); + + int totalRequests = 34; // 32 inline + 2 overflow + + // Send 34 pipelined requests: position 33 (overflow) is HEAD. + StringBuilder requests = new StringBuilder(); + for (int i = 0; i < totalRequests; i++) { + if (i == 33) { + requests.append("HEAD /").append(i).append(" HTTP/1.1\r\nHost: a\r\n\r\n"); + } else { + requests.append("GET /").append(i).append(" HTTP/1.1\r\nHost: a\r\n\r\n"); + } + } + assertTrue(ch.writeInbound(Unpooled.copiedBuffer(requests.toString(), CharsetUtil.UTF_8))); + + // Drain inbound. + for (;;) { + Object msg = ch.readInbound(); + if (msg == null) { + break; + } + if (msg instanceof HttpContent) { + ((HttpContent) msg).release(); + } + } + + // Send responses for first 33 (all GET). + for (int i = 0; i < 33; i++) { + FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, + Unpooled.copiedBuffer("ok", CharsetUtil.UTF_8)); + resp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 2); + assertTrue(ch.writeOutbound(resp)); + ByteBuf buf = ch.readOutbound(); + String encoded = buf.toString(CharsetUtil.US_ASCII); + assertTrue(encoded.contains("ok"), "GET response at position " + i + " should contain body"); + buf.release(); + } + + // Response for position 33 — HEAD from overflow queue. + HttpResponse headResp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + headResp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 42); + assertTrue(ch.writeOutbound(headResp)); + assertTrue(ch.writeOutbound(LastHttpContent.EMPTY_LAST_CONTENT)); + + ByteBuf buf = ch.readOutbound(); + buf.release(); + + buf = ch.readOutbound(); + assertFalse(buf.isReadable(), "HEAD response from overflow queue body should be empty"); + buf.release(); + + assertFalse(ch.finishAndReleaseAll()); + } + + @Test + public void testPollFromEmptyQueueReturnsNone() { + // If a response is written without a matching request (unusual but defensive), + // pollMethod should return METHOD_FLAG_NONE, so body is treated normally. + final EmbeddedChannel ch = new EmbeddedChannel(new HttpServerCodec()); + + // Write a response without any prior request. + final FullHttpResponse resp = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, + Unpooled.copiedBuffer("data", CharsetUtil.UTF_8)); + resp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 4); + assertDoesNotThrow(new Executable() { + @Override + public void execute() throws Throwable { + ch.writeOutbound(resp); + } + }); + + ByteBuf buf = ch.readOutbound(); + assertNotNull(buf); + String encoded = buf.toString(CharsetUtil.US_ASCII); + assertTrue(encoded.contains("data"), "Response body should be present even without prior request"); + buf.release(); + + ch.finishAndReleaseAll(); + } + + @Test + public void testHeadResponseHasNoContentAfterInterimResponse() { + // A 1xx interim response must not consume an entry of the method queue, as it is not the + // final response for the queued request. + EmbeddedChannel ch = new EmbeddedChannel(new HttpServerCodec()); + + // Send a single HEAD request. Method queue = [HEAD]. + assertTrue(ch.writeInbound(Unpooled.copiedBuffer( + "HEAD /a HTTP/1.1\r\nHost: a\r\n\r\n", CharsetUtil.UTF_8))); + HttpRequest request = ch.readInbound(); + assertEquals(HttpMethod.HEAD, request.method()); + LastHttpContent requestContent = ch.readInbound(); + assertFalse(requestContent.content().isReadable()); + requestContent.release(); + + // Write a 103 Early Hints interim response. The HEAD entry must stay in the queue. + FullHttpResponse earlyHints = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.EARLY_HINTS); + assertTrue(ch.writeOutbound(earlyHints)); + ByteBuf earlyHintsBuf = ch.readOutbound(); + assertEquals("HTTP/1.1 103 Early Hints\r\n\r\n", earlyHintsBuf.toString(CharsetUtil.US_ASCII)); + earlyHintsBuf.release(); + + // Now write the final response for the HEAD request, with content attached. + FullHttpResponse finalResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, + Unpooled.copiedBuffer("body", CharsetUtil.UTF_8)); + finalResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 4); + assertTrue(ch.writeOutbound(finalResponse)); + + // A HEAD response must never carry a body per RFC 9110 section 9.3.2, regardless of + // any interim responses sent before it. + ByteBuf buf = ch.readOutbound(); + assertEquals("HTTP/1.1 200 OK\r\ncontent-length: 4\r\n\r\n", buf.toString(CharsetUtil.US_ASCII)); + buf.release(); + + assertFalse(ch.finishAndReleaseAll()); + } + + @Test + public void testPipelinedResponseContentPreservedAfterInterimResponse() { + // A 1xx interim response for one pipelined request must not consume a method queue entry, + // so the queue order is preserved for the remaining pipelined requests. + EmbeddedChannel ch = new EmbeddedChannel(new HttpServerCodec()); + + // Pipeline two requests: GET /a then HEAD /b. Method queue = [OTHER, HEAD]. + assertTrue(ch.writeInbound(Unpooled.copiedBuffer( + "GET /a HTTP/1.1\r\nHost: a\r\n\r\n" + + "HEAD /b HTTP/1.1\r\nHost: a\r\n\r\n", CharsetUtil.UTF_8))); + + HttpRequest requestA = ch.readInbound(); + assertEquals(HttpMethod.GET, requestA.method()); + LastHttpContent requestAContent = ch.readInbound(); + assertFalse(requestAContent.content().isReadable()); + requestAContent.release(); + + HttpRequest requestB = ch.readInbound(); + assertEquals(HttpMethod.HEAD, requestB.method()); + LastHttpContent requestBContent = ch.readInbound(); + assertFalse(requestBContent.content().isReadable()); + requestBContent.release(); + + // Send a 1xx interim response for /a. The queue must stay at [OTHER, HEAD]. + FullHttpResponse earlyHints = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.EARLY_HINTS); + assertTrue(ch.writeOutbound(earlyHints)); + ByteBuf earlyHintsBuf = ch.readOutbound(); + assertEquals("HTTP/1.1 103 Early Hints\r\n\r\n", earlyHintsBuf.toString(CharsetUtil.US_ASCII)); + earlyHintsBuf.release(); + + // Now send the final response to /a, with content attached. + FullHttpResponse finalResponseA = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, + Unpooled.copiedBuffer("body-a", CharsetUtil.UTF_8)); + finalResponseA.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 6); + assertTrue(ch.writeOutbound(finalResponseA)); + + // The GET /a response body must be preserved. + ByteBuf buf = ch.readOutbound(); + assertEquals("HTTP/1.1 200 OK\r\ncontent-length: 6\r\n\r\nbody-a", buf.toString(CharsetUtil.US_ASCII)); + buf.release(); + + assertFalse(ch.finishAndReleaseAll()); + } + + @Test + public void testSwitchingProtocolsResponseHasNoContent() { + // A 101 Switching Protocols response must never carry a body. + EmbeddedChannel ch = new EmbeddedChannel(new HttpServerCodec()); + + // Send a HEAD request that asks to switch protocols. Method queue = [HEAD]. + assertTrue(ch.writeInbound(Unpooled.copiedBuffer( + "HEAD / HTTP/1.1\r\n" + + "Host: a\r\n" + + "Connection: upgrade\r\n" + + "Upgrade: websocket\r\n\r\n", CharsetUtil.UTF_8))); + HttpRequest request = ch.readInbound(); + assertEquals(HttpMethod.HEAD, request.method()); + LastHttpContent requestContent = ch.readInbound(); + assertFalse(requestContent.content().isReadable()); + requestContent.release(); + + HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.SWITCHING_PROTOCOLS); + response.headers().set(HttpHeaderNames.CONNECTION, "upgrade"); + response.headers().set(HttpHeaderNames.UPGRADE, "websocket"); + assertTrue(ch.writeOutbound(response)); + assertTrue(ch.writeOutbound(LastHttpContent.EMPTY_LAST_CONTENT)); + + ByteBuf buf = ch.readOutbound(); + assertEquals("HTTP/1.1 101 Switching Protocols\r\n" + + "connection: upgrade\r\n" + + "upgrade: websocket\r\n\r\n", buf.toString(CharsetUtil.US_ASCII)); + buf.release(); + + buf = ch.readOutbound(); + assertFalse(buf.isReadable()); + buf.release(); + + assertFalse(ch.finishAndReleaseAll()); + } + private static ByteBuf prepareDataChunk(int size) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < size; ++i) { From b8a40d2125ce5e617811ed55406a9b63abf99775 Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Mon, 3 Aug 2026 23:24:38 -0700 Subject: [PATCH 57/64] Adaptive allocator backports (#17206) Backport https://github.com/netty/netty/pull/16766 and https://github.com/netty/netty/pull/17166 to 4.1 --------- Co-authored-by: Francesco Nigro --- .../buffer/AdaptivePoolingAllocator.java | 699 ++++++++++++++++-- .../buffer/AdaptiveByteBufAllocatorTest.java | 97 ++- .../buffer/SizeClassedChunkCacheTest.java | 621 ++++++++++++++++ 3 files changed, 1333 insertions(+), 84 deletions(-) create mode 100644 buffer/src/test/java/io/netty/buffer/SizeClassedChunkCacheTest.java diff --git a/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java b/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java index 64f809f0493..350db601cdd 100644 --- a/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java +++ b/buffer/src/main/java/io/netty/buffer/AdaptivePoolingAllocator.java @@ -29,6 +29,7 @@ import io.netty.util.concurrent.FastThreadLocalThread; import io.netty.util.concurrent.MpscAtomicIntegerArrayQueue; import io.netty.util.concurrent.MpscIntQueue; +import io.netty.util.internal.LongCounter; import io.netty.util.internal.MathUtil; import io.netty.util.internal.ObjectUtil; import io.netty.util.internal.PlatformDependent; @@ -48,12 +49,13 @@ import java.nio.channels.GatheringByteChannel; import java.nio.channels.ScatteringByteChannel; import java.nio.charset.Charset; +import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.Queue; -import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import java.util.concurrent.atomic.LongAdder; import java.util.concurrent.locks.StampedLock; @@ -80,15 +82,16 @@ * This allows the allocator to quickly respond to changes in the application workload, * without suffering undue overhead from maintaining its statistics. *

- * Since magazines are "relatively thread-local", the allocator has a central queue that allow excess chunks from any - * magazine, to be shared with other magazines. - * The {@link #createSharedChunkQueue()} method can be overridden to customize this queue. + * Since magazines are "relatively thread-local", the allocator has a chunk cache that allows excess chunks from any + * magazine to be shared with other magazines. */ @SuppressJava6Requirement(reason = "Guarded by version check") @UnstableApi final class AdaptivePoolingAllocator implements AdaptiveByteBufAllocator.AdaptiveAllocatorApi { private static final int LOW_MEM_THRESHOLD = 512 * 1024 * 1024; - private static final boolean IS_LOW_MEM = Runtime.getRuntime().maxMemory() <= LOW_MEM_THRESHOLD; + private static final boolean IS_LOW_MEM = SystemPropertyUtil.getBoolean( + "io.netty.allocator.lowMemory", + Runtime.getRuntime().maxMemory() <= LOW_MEM_THRESHOLD); /** * Whether the IS_LOW_MEM setting should disable thread-local magazines. @@ -126,9 +129,39 @@ final class AdaptivePoolingAllocator implements AdaptiveByteBufAllocator.Adaptiv * The default size is twice {@link NettyRuntime#availableProcessors()}, * same as the maximum number of magazines per magazine group. */ - private static final int CHUNK_REUSE_QUEUE = Math.max(2, SystemPropertyUtil.getInt( + static final int CHUNK_REUSE_QUEUE = Math.max(2, SystemPropertyUtil.getInt( "io.netty.allocator.chunkReuseQueueCapacity", NettyRuntime.availableProcessors() * 2)); + static final long CHUNK_PURGE_POLLS_THREAD_LOCAL = Math.max(1, SystemPropertyUtil.getLong( + "io.netty.allocator.chunkPurgePollsThreadLocal", 16L)); + + static final long CHUNK_PURGE_POLLS_SHARED = Math.max(1, SystemPropertyUtil.getLong( + "io.netty.allocator.chunkPurgePollsShared", 128L)); + + static final int CHUNK_PURGE_THRESHOLD = Math.max(1, SystemPropertyUtil.getInt( + "io.netty.allocator.chunkPurgeThreshold", 3)); + + /** + * Per-size-class upper bound (in bytes) on the thread-local chunk cache. + * When a size class cache holds this many bytes worth of chunks, + * further offers are rejected and the chunk is marked for immediate deallocation. + * Chunks already in the cache are only evicted by the purge mechanism (they must be full and idle for + * {@link #CHUNK_PURGE_THRESHOLD} consecutive purge cycles). + */ + static final int THREAD_LOCAL_CACHE_MAX_BYTES = Math.max(1, SystemPropertyUtil.getInt( + "io.netty.allocator.threadLocalChunkCacheMaxBytes", 8 * 1024 * 1024)); + + /** + * Per-size-class lower bound (in bytes) on the thread-local chunk cache. + * The purge mechanism will not evict chunks below this retention floor, even if they are full and idle. + * Clamped to {@link #THREAD_LOCAL_CACHE_MAX_BYTES} if the configured value exceeds it. + * When equal to {@link #THREAD_LOCAL_CACHE_MAX_BYTES}, purge eviction is effectively disabled. + */ + static final int THREAD_LOCAL_CACHE_MIN_BYTES = Math.min(THREAD_LOCAL_CACHE_MAX_BYTES, + Math.max(1, SystemPropertyUtil.getInt( + "io.netty.allocator.threadLocalChunkCacheMinBytes", + THREAD_LOCAL_CACHE_MAX_BYTES / 2))); + /** * The capacity if the magazine local buffer queue. This queue just pools the outer ByteBuf instance and not * the actual memory and so helps to reduce GC pressure. @@ -231,30 +264,6 @@ private static MagazineGroup[] createMagazineGroupSizeClasses( return groups; } - /** - * Create a thread-safe multi-producer, multi-consumer queue to hold chunks that spill over from the - * internal Magazines. - *

- * Each Magazine can only hold two chunks at any one time: the chunk it currently allocates from, - * and the next-in-line chunk which will be used for allocation once the current one has been used up. - * This queue will be used by magazines to share any excess chunks they allocate, so that they don't need to - * allocate new chunks when their current and next-in-line chunks have both been used up. - *

- * The simplest implementation of this method is to return a new {@link ConcurrentLinkedQueue}. - * However, the {@code CLQ} is unbounded, and this means there's no limit to how many chunks can be cached in this - * queue. - *

- * Each chunk in this queue can be up to {@link #MAX_CHUNK_SIZE} in size, so it is recommended to use a bounded - * queue to limit the maximum memory usage. - *

- * The default implementation will create a bounded queue with a capacity of {@link #CHUNK_REUSE_QUEUE}. - * - * @return A new multi-producer, multi-consumer queue. - */ - private static Queue createSharedChunkQueue() { - return PlatformDependent.newFixedMpmcQueue(CHUNK_REUSE_QUEUE); - } - @Override public ByteBuf allocate(int size, int maxCapacity) { return allocate(size, maxCapacity, Thread.currentThread(), null); @@ -268,7 +277,7 @@ private AdaptiveByteBuf allocate(int size, int maxCapacity, Thread currentThread if (!FastThreadLocalThread.willCleanupFastThreadLocals(Thread.currentThread()) || IS_LOW_MEM || (magazineGroups = threadLocalGroup.get()) == null) { - magazineGroups = sizeClassedMagazineGroups; + magazineGroups = sizeClassedMagazineGroups; } if (index < magazineGroups.length) { allocated = magazineGroups[index].allocate(size, maxCapacity, currentThread, buf); @@ -317,7 +326,7 @@ private AdaptiveByteBuf allocateFallback(int size, int maxCapacity, Thread curre chunkRegistry.add(chunk); try { boolean success = chunk.readInitInto(buf, size, size, maxCapacity); - assert success: "Failed to initialize ByteBuf with dedicated chunk"; + assert success : "Failed to initialize ByteBuf with dedicated chunk"; } finally { // As the chunk is an one-off we need to always call release explicitly as readInitInto(...) // will take care of retain once when successful. Once The AdaptiveByteBuf is released it will @@ -337,7 +346,7 @@ private Magazine getFallbackMagazine(Thread currentThread) { */ void reallocate(int size, int maxCapacity, AdaptiveByteBuf into) { AdaptiveByteBuf result = allocate(size, maxCapacity, Thread.currentThread(), into); - assert result == into: "Re-allocation created separate buffer instance"; + assert result == into : "Re-allocation created separate buffer instance"; } @Override @@ -352,9 +361,9 @@ public long usedMemory() { @Override protected void finalize() throws Throwable { try { - super.finalize(); - } finally { free(); + } finally { + super.finalize(); } } @@ -509,55 +518,560 @@ private void free() { } private void freeChunkReuseQueue(Thread ownerThread) { - Chunk chunk; - while ((chunk = chunkCache.pollChunk(0)) != null) { - if (ownerThread != null && chunk instanceof SizeClassedChunk) { - SizeClassedChunk threadLocalChunk = (SizeClassedChunk) chunk; - assert ownerThread == threadLocalChunk.ownerThread; - // no release segment can ever happen from the owner Thread since it's not running anymore - // This is required to let the ownerThread to be GC'ed despite there are AdaptiveByteBuf - // that reference some thread local chunk - threadLocalChunk.ownerThread = null; + if (ownerThread != null && chunkCache instanceof ThreadLocalSizeClassedChunkCache) { + ThreadLocalSizeClassedChunkCache tlCache = (ThreadLocalSizeClassedChunkCache) chunkCache; + int mask = tlCache.chunks.length - 1; + for (int i = 0; i < tlCache.count; i++) { + SizeClassedChunk chunk = tlCache.chunks[(tlCache.head + i) & mask]; + assert ownerThread == chunk.ownerThread; + chunk.ownerThread = null; } - chunk.markToDeallocate(); } + chunkCache.free(); } } - private interface ChunkCache { + interface ChunkCache { Chunk pollChunk(int size); + boolean offerChunk(Chunk chunk); + + void free(); + + boolean isEmpty(); } - private static final class ConcurrentQueueChunkCache implements ChunkCache { + // Cached chunks are detached from magazines: no readInitInto can happen, so segment count + // can only grow (external releaseSegment returns) and never shrink. Once a chunk reaches + // full capacity (hasFullCapacity), it stays idle while in the cache. + // + // Epoch-based aging invariants (both caches): + // + // 1. CLASSIFICATION: purge scans all chunks. Idle (hasFullCapacity) → epoch++. + // Non-idle → epoch = 0. Only idle chunks can accumulate epoch. + // + // 2. EVICTION: idle chunks with epoch > CHUNK_PURGE_THRESHOLD are evicted (markToDeallocate). + // Eviction is immediate — all segments are in, no outstanding references. + // Non-idle chunks are never evicted (deallocation would be deferred, not immediate). + // A retention floor prevents over-eviction: CHUNK_REUSE_QUEUE for the shared cache, + // purgeRetentionFloor (from THREAD_LOCAL_CACHE_MIN_BYTES) for the thread-local cache. + // + // 3. SCAN RESET: scanForCapacity resets purgeEpoch = 0 on the chunk it picks. The scan + // knows the chunk is being used. The chunk gets allocated from, becomes non-idle, and + // the next purge resets its epoch anyway (non-idle → 0). The scan reset covers the case + // where all segments return before the next purge (short-lived buffers). + // + // 4. CONVERGENCE: idle chunks that are never picked by scan age undisturbed across + // purge cycles. After CHUNK_PURGE_THRESHOLD + 1 consecutive cycles of being idle and + // unpolled, they are evicted. Chunks picked by scan get epoch reset — aging interrupted. + // Thread-local: partition orders [epoch=0 | 0=T | noCap]. Scan takes + // from head (epoch=0 first). Chunks with epoch>=threshold are placed at the back of + // the hasCap zone so scan doesn't reach them — they age to threshold+1 and get evicted. + // Shared: approximate, converges over multiple cycles (FIFO queue ordering, + // LRU preference in scan, retained counter in purge). + abstract static class SizeClassedChunkCache implements ChunkCache { + static SizeClassedChunkCache create(boolean isThreadLocal, int chunkSize) { + return isThreadLocal ? new ThreadLocalSizeClassedChunkCache(chunkSize) : + new SharedSizeClassedChunkCache(); + } + + @Override + public abstract SizeClassedChunk pollChunk(int size); + + // Visible for testing: triggers a purge scan bypassing the budget counter. + abstract SizeClassedChunk forcePurge(); + } + + /** + * Ring buffer cache for thread-local chunk reuse (SPSC — only the owner thread accesses it). + * + *

Logical layout after purge: + *

+     *   head                          tail
+     *   v                             v
+     *   [..., notEmpty, notEmpty, ..., empty, empty, ..., null, ...]
+     *        |--- notEmptyCount ---|--- emptyCount --|
+     *        |------------ count ------------------|
+     * 
+ * + *

Physical layout when the ring wraps: + *

+     *   0         tail          head          length
+     *   v         v             v             v
+     *   [...tail] [  unused  ]  [head................]
+     *             ^             |--- content wraps ---|
+     *             wrap point
+     * 
+ * + *

scanForCapacity — O(1) fast path takes from head while {@code notEmptyCount > 0}: + *

+     *   before: notEmptyCount=2, count=5
+     *   [NE, NE, E, E, E, _, _, _]
+     *    ^head            ^tail
+     *
+     *   after: returns NE, notEmptyCount=1, count=4
+     *   [_,  NE, E, E, E, _, _, _]
+     *        ^head        ^tail
+     * 
+ * Fallback when {@code notEmptyCount == 0}: linear scan of the empty zone for chunks + * that gained capacity from external segment returns. + * + *

offerChunk — write at tail, grow (double + linearize) if full: + *

+     *   before: count=4
+     *   [_,  NE, E, E, E, _, _, _]
+     *        ^head        ^tail
+     *
+     *   after: count=5
+     *   [_,  NE, E, E, E, X, _, _]
+     *        ^head           ^tail
+     * 
+ * + *

runPurgeScan (every {@link #CHUNK_PURGE_POLLS_THREAD_LOCAL} polls) — + * two passes. Pass 1: age idle chunks (full → epoch++, non-full → epoch=0), evict + * past threshold, compact survivors (nulls stale slots inline). Pass 2: partition + * hasCap to front / noCap to back, then three-way Dutch-flag within hasCap into + * [epoch=0 | 0<epoch<threshold | epoch>=threshold]. Chunks with epoch>=threshold + * are placed at the back of hasCap so scan doesn't reach them — they age to + * threshold+1 and get evicted. Never selects — selection is always + * {@code scanForCapacity}. + * + *

Case 1 — no eviction, an empty chunk gained capacity externally (common): + *

+     *   before (E* gained capacity since last purge):
+     *   [NE, NE, E*, E, _, _, _, _]
+     *    ^head            ^tail
+     *    notEmptyCount=2
+     *
+     *   pass 1: age idle chunks. None past threshold. No compaction needed.
+     *   pass 2 (partition): E* now has capacity → placed in notEmpty zone.
+     *
+     *   after:
+     *   [NE, NE, E*, E, _, _, _, _]
+     *    ^head            ^tail
+     *    notEmptyCount=3
+     * 
+ * + *

Case 2 — eviction (uncommon, burst wind-down): + *

+     *   before (ring wraps, IDLE* = idle past threshold):
+     *   [E, NE, _,  IDLE*, NE, E, E, NE]
+     *          ^tail ^head
+     *
+     *   pass 1: IDLE* evicted (markToDeallocate), survivors compacted, stale slots nulled.
+     *   [_, _, _,  NE, E, E, NE, E]
+     *     ^tail    ^head
+     *              |--- kept=6 ---|
+     *
+     *   pass 2 (partition): [epoch=0 hasCap | 0<epoch<T hasCap | epoch>=T hasCap | noCap].
+     *   [_, _, _,  NE, NE, E, E, E]
+     *     ^tail    ^head
+     *              notEmptyCount=2, count=6
+     * 
+ * Idle chunks ({@code remainingCapacity == capacity}) age via purgeEpoch and are evicted + * past threshold, but at least {@code purgeRetentionFloor} chunks are always retained. + */ + static final class ThreadLocalSizeClassedChunkCache extends SizeClassedChunkCache { + SizeClassedChunk[] chunks; // package-private for testing + int head; + int tail; + int count; + int notEmptyCount; + private long purgeBudget; + final int maxCachedChunks; // package-private for testing + final int purgeRetentionFloor; // package-private for testing + + ThreadLocalSizeClassedChunkCache(int chunkSize) { + chunks = new SizeClassedChunk[8]; + purgeBudget = CHUNK_PURGE_POLLS_THREAD_LOCAL; + maxCachedChunks = Math.max(1, THREAD_LOCAL_CACHE_MAX_BYTES / chunkSize); + purgeRetentionFloor = Math.min(maxCachedChunks, + Math.max(1, THREAD_LOCAL_CACHE_MIN_BYTES / chunkSize)); + } + + @Override + SizeClassedChunk forcePurge() { + purgeBudget = 1; + return pollChunk(0); + } + + @Override + public SizeClassedChunk pollChunk(int size) { + if (--purgeBudget == 0) { + runPurgeScan(); + } + return scanForCapacity(); + } + + private SizeClassedChunk scanForCapacity() { + if (notEmptyCount > 0) { + SizeClassedChunk chunk = chunks[head]; + assert chunk.hasRemainingCapacity(); + chunk.purgeEpoch = 0; + chunks[head] = null; + head = (head + 1) & (chunks.length - 1); + count--; + notEmptyCount--; + return chunk; + } + return scanForCapacityFallback(); + } + + private SizeClassedChunk scanForCapacityFallback() { + int mask = chunks.length - 1; + int emptyCount = count - notEmptyCount; + int pos = (head + notEmptyCount) & mask; + for (int i = 0; i < emptyCount; i++) { + SizeClassedChunk chunk = chunks[pos]; + if (chunk.hasRemainingCapacity()) { + chunk.purgeEpoch = 0; + int lastIdx = (tail - 1) & mask; + chunks[pos] = chunks[lastIdx]; + chunks[lastIdx] = null; + tail = lastIdx; + count--; + return chunk; + } + pos = (pos + 1) & mask; + } + return null; + } + + private void runPurgeScan() { + int mask = chunks.length - 1; + int kept = 0; + int survivors = count; + for (int i = 0; i < count; i++) { + int readIdx = (head + i) & mask; + SizeClassedChunk chunk = chunks[readIdx]; + if (chunk.purgeEpoch > 0) { + assert chunk.hasFullCapacity(); + chunk.purgeEpoch++; + if (chunk.purgeEpoch > CHUNK_PURGE_THRESHOLD && survivors > purgeRetentionFloor) { + chunk.markToDeallocate(); + chunks[readIdx] = null; + survivors--; + continue; + } + } else if (chunk.hasFullCapacity()) { + chunk.purgeEpoch = 1; + } + int writeIdx = (head + kept) & mask; + if (writeIdx != readIdx) { + chunks[writeIdx] = chunk; + chunks[readIdx] = null; + } + kept++; + } + tail = (head + kept) & mask; + count = kept; + partition(kept); + purgeBudget = CHUNK_PURGE_POLLS_THREAD_LOCAL; + } + + private void partition(int size) { + int mask = chunks.length - 1; + // Pass 1: hasCapacity to front, noCapacity to back. + int lo = 0; + int hi = size - 1; + while (lo <= hi) { + int loIdx = (head + lo) & mask; + if (chunks[loIdx].hasRemainingCapacity()) { + lo++; + } else { + int hiIdx = (head + hi) & mask; + SizeClassedChunk tmp = chunks[loIdx]; + chunks[loIdx] = chunks[hiIdx]; + chunks[hiIdx] = tmp; + hi--; + } + } + notEmptyCount = lo; + // Pass 2: three-way Dutch-flag within notEmpty: + // [epoch=0 | 0=threshold] + // + // Epoch=0 (recently used) at head — scan picks these first. + // Epoch>=threshold (about to be evicted) at back — scan doesn't reach them, + // so they age one more cycle to threshold+1 and get evicted. + // + // This ordering guarantees convergence regardless of count/polls ratio. + // Without it (e.g., a simple epoch=0/epoch>0 split with mid++), when + // count/polls == threshold the groups rotate perfectly and max epoch never + // exceeds threshold — eviction stalls at threshold * polls chunks. + int elo = 0; + int emid = 0; + int ehi = lo - 1; + while (emid <= ehi) { + int emidIdx = (head + emid) & mask; + SizeClassedChunk c = chunks[emidIdx]; + if (c.purgeEpoch == 0) { + if (elo != emid) { + int eloIdx = (head + elo) & mask; + chunks[emidIdx] = chunks[eloIdx]; + chunks[eloIdx] = c; + } + elo++; + emid++; + } else if (c.purgeEpoch < CHUNK_PURGE_THRESHOLD) { + emid++; + } else { + int ehiIdx = (head + ehi) & mask; + chunks[emidIdx] = chunks[ehiIdx]; + chunks[ehiIdx] = c; + ehi--; + } + } + } + + @Override + public boolean offerChunk(Chunk chunk) { + if (count >= maxCachedChunks) { + return false; + } + if (count == chunks.length) { + SizeClassedChunk[] newChunks = new SizeClassedChunk[chunks.length * 2]; + for (int i = 0; i < count; i++) { + newChunks[i] = chunks[(head + i) & (chunks.length - 1)]; + } + chunks = newChunks; + head = 0; + tail = count; + } + chunks[tail] = (SizeClassedChunk) chunk; + tail = (tail + 1) & (chunks.length - 1); + count++; + return true; + } + + @Override + public String toString() { + int mask = chunks.length - 1; + StringBuilder sb = new StringBuilder(); + sb.append("ThreadLocalCache[head=").append(head) + .append(", tail=").append(tail) + .append(", count=").append(count) + .append(", notEmpty=").append(notEmptyCount) + .append(", length=").append(chunks.length) + .append("]\n "); + for (int i = 0; i < count; i++) { + if (i > 0) { + sb.append(", "); + } + if (i == notEmptyCount) { + sb.append("| "); + } + SizeClassedChunk c = chunks[(head + i) & mask]; + String region = i < notEmptyCount ? "notEmpty" : "empty"; + String actual = c == null ? "null" : + c.hasRemainingCapacity() ? "hasCap" : "noCap"; + sb.append('[').append(region).append(':').append(actual) + .append(",ep=").append(c == null ? -1 : c.purgeEpoch).append(']'); + } + return sb.toString(); + } + + @Override + public void free() { + int mask = chunks.length - 1; + for (int i = 0; i < count; i++) { + int idx = (head + i) & mask; + chunks[idx].markToDeallocate(); + chunks[idx] = null; + } + head = 0; + tail = 0; + count = 0; + notEmptyCount = 0; + } + + @Override + public boolean isEmpty() { + return count == 0; + } + } + + /** + * MPMC queue cache for shared (cross-thread) chunk reuse. + * + *

scanForCapacity — LRU preference with fallback: + *

+     *   fast path: head chunk has purgeEpoch == 0 and capacity → return O(1)
+     *
+     *   slow path: scan for epoch=0 chunk, hold first idle (epoch > 0) as fallback
+     *     queue: [E>0, E>0, E=0, E>0, ...]
+     *             skip   skip  ↑ return (put fallback back)
+     *
+     *   no epoch=0 found → use fallback, reset its epoch to 0
+     * 
+ * + *

The LRU preference creates a natural separation: recently-used chunks (epoch=0, + * returned via {@link #offerChunk} after magazine use) cycle at the front. Idle chunks + * (epoch > 0, aged by purge) are scanned past but never returned — they age undisturbed. + * When no recently-used chunks exist, idle ones are reused (fallback) rather than + * allocating new chunks. + * + *

All re-offered chunks are stamped with {@code lastScanGeneration} for cycle detection. + * The {@code >=} check terminates the scan when encountering any chunk already processed + * by this or a later scan, preventing livelock under concurrent access. + * + *

runPurgeScan (every {@link #CHUNK_PURGE_POLLS_SHARED} polls): + * drains the queue, ages full chunks (epoch++), resets non-full (epoch=0). + * Non-candidate capacity chunks are re-offered inline. Eviction candidates (full, + * epoch past threshold) and no-capacity chunks are deferred to a buffer. After the drain, + * the buffer is walked with the known total: candidates are evicted while above + * {@link #CHUNK_REUSE_QUEUE}, remainder re-offered. No selection — that is + * {@code scanForCapacity}'s job (called after purge via {@code pollChunk}). + */ + static final class SharedSizeClassedChunkCache extends SizeClassedChunkCache { + // Must exceed CHUNK_REUSE_QUEUE (the retention floor) to leave room for burst absorption. + // TODO replace with an unbounded concurrent collection once available. + private static final int SHARED_CACHE_CAPACITY = Math.max(16, CHUNK_REUSE_QUEUE * 2); private final Queue queue; + private final AtomicLong purgeBudget; + private final ArrayList deferredBuffer = new ArrayList(); + private long purgeGeneration; + private final AtomicLong scanGeneration = new AtomicLong(); + + SharedSizeClassedChunkCache() { + queue = PlatformDependent.newFixedMpmcQueue(SHARED_CACHE_CAPACITY); + purgeBudget = new AtomicLong(CHUNK_PURGE_POLLS_SHARED); + } - private ConcurrentQueueChunkCache() { - queue = createSharedChunkQueue(); + @Override + SizeClassedChunk forcePurge() { + purgeBudget.set(1); + return pollChunk(0); } @Override public SizeClassedChunk pollChunk(int size) { - // we really don't care about size here since the sized class chunk q - // just care about segments of fixed size! - Queue queue = this.queue; - for (int i = 0; i < CHUNK_REUSE_QUEUE; i++) { - SizeClassedChunk chunk = queue.poll(); - if (chunk == null) { - return null; + long budget = purgeBudget.decrementAndGet(); + if (budget == 0) { + runPurgeScan(); + } + return scanForCapacity(); + } + + private SizeClassedChunk scanForCapacity() { + SizeClassedChunk first = queue.poll(); + if (first == null) { + return null; + } + if (first.purgeEpoch == 0 && first.hasRemainingCapacity()) { + return first; + } + long generation = scanGeneration.incrementAndGet(); + first.lastScanGeneration = generation; + if (first.hasRemainingCapacity()) { + return scanForCapacitySlow(generation, first); + } + offerOrDeallocate(first); + return scanForCapacitySlow(generation, null); + } + + private SizeClassedChunk scanForCapacitySlow(long generation, SizeClassedChunk fallback) { + SizeClassedChunk chunk; + while ((chunk = queue.poll()) != null) { + if (chunk.lastScanGeneration >= generation) { + offerOrDeallocate(chunk); + break; } if (chunk.hasRemainingCapacity()) { - return chunk; + if (chunk.purgeEpoch == 0) { + if (fallback != null) { + offerOrDeallocate(fallback); + } + return chunk; + } + if (fallback == null) { + fallback = chunk; + continue; + } } - queue.offer(chunk); + chunk.lastScanGeneration = generation; + offerOrDeallocate(chunk); + } + if (fallback != null) { + fallback.purgeEpoch = 0; + return fallback; } return null; } + private boolean offerOrDeallocate(SizeClassedChunk chunk) { + if (!queue.offer(chunk)) { + chunk.markToDeallocate(); + return false; + } + return true; + } + + private boolean offerOrDeallocate(SizeClassedChunk chunk, long generation) { + chunk.lastPurgeGeneration = generation; + return offerOrDeallocate(chunk); + } + + private void runPurgeScan() { + long generation = ++purgeGeneration; + int retained = 0; + ArrayList deferred = deferredBuffer; + SizeClassedChunk chunk; + while ((chunk = queue.poll()) != null) { + if (chunk.lastPurgeGeneration == generation) { + offerOrDeallocate(chunk, generation); + break; + } + retained++; + if (chunk.hasFullCapacity()) { + chunk.purgeEpoch++; + if (chunk.purgeEpoch > CHUNK_PURGE_THRESHOLD) { + deferred.add(chunk); + continue; + } + } else { + chunk.purgeEpoch = 0; + } + int remaining = chunk.remainingCapacity(); + if (remaining > 0) { + if (!offerOrDeallocate(chunk, generation)) { + retained--; + } + } else { + deferred.add(chunk); + } + } + for (int i = 0, size = deferred.size(); i < size; i++) { + chunk = deferred.get(i); + if (chunk.purgeEpoch > CHUNK_PURGE_THRESHOLD && retained > CHUNK_REUSE_QUEUE) { + chunk.markToDeallocate(); + retained--; + } else { + if (!offerOrDeallocate(chunk, generation)) { + retained--; + } + } + } + deferred.clear(); + purgeBudget.lazySet(CHUNK_PURGE_POLLS_SHARED); + } + @Override public boolean offerChunk(Chunk chunk) { return queue.offer((SizeClassedChunk) chunk); } + + @Override + public void free() { + SizeClassedChunk chunk; + while ((chunk = queue.poll()) != null) { + chunk.markToDeallocate(); + } + } + + @Override + public boolean isEmpty() { + return queue.isEmpty(); + } } private static final class ConcurrentSkipListChunkCache implements ChunkCache { @@ -619,10 +1133,20 @@ public boolean offerChunk(Chunk chunk) { Chunk toDeallocate = null; for (IntEntry entry : chunks) { Chunk candidate = entry.getValue(); - if (candidate != null && candidate.refCnt() == 1) { - toDeallocate = candidate; - key = entry.getKey(); - break; + if (candidate != null) { + if (toDeallocate == null) { + toDeallocate = candidate; + key = entry.getKey(); + } else { + int candidateRefCnt = candidate.refCnt(); + int toDeallocateRefCnt = toDeallocate.refCnt(); + if (candidateRefCnt < toDeallocateRefCnt || + candidateRefCnt == toDeallocateRefCnt && + candidate.capacity() < toDeallocate.capacity()) { + toDeallocate = candidate; + key = entry.getKey(); + } + } } } if (toDeallocate == null) { @@ -635,6 +1159,21 @@ public boolean offerChunk(Chunk chunk) { } return true; } + + @Override + public void free() { + for (IntEntry entry : chunks) { + Chunk chunk = entry.getValue(); + if (chunk != null && chunks.remove(entry.getKey(), chunk)) { + chunk.markToDeallocate(); + } + } + } + + @Override + public boolean isEmpty() { + return chunks.isEmpty(); + } } private interface ChunkManagementStrategy { @@ -675,7 +1214,7 @@ public ChunkController createController(MagazineGroup group) { @Override public ChunkCache createChunkCache(boolean isThreadLocal) { - return new ConcurrentQueueChunkCache(); + return SizeClassedChunkCache.create(isThreadLocal, chunkSize); } } @@ -784,9 +1323,11 @@ public Chunk newChunkAllocation(int promptingSize, Magazine magazine) { @SuppressJava6Requirement(reason = "Guarded by version check") private static final class Magazine { private static final AtomicReferenceFieldUpdater NEXT_IN_LINE; + static { NEXT_IN_LINE = AtomicReferenceFieldUpdater.newUpdater(Magazine.class, Chunk.class, "nextInLine"); } + private static final Chunk MAGAZINE_FREED = new Chunk(); private static final class AdaptiveRecycler extends Recycler { @@ -1036,7 +1577,7 @@ void free() { public AdaptiveByteBuf newBuffer() { AdaptiveRecycler recycler = this.recycler; - AdaptiveByteBuf buf = recycler == null? EVENT_LOOP_LOCAL_BUFFER_POOL.get() : recycler.get(); + AdaptiveByteBuf buf = recycler == null ? EVENT_LOOP_LOCAL_BUFFER_POOL.get() : recycler.get(); buf.resetRefCnt(); buf.discardMarks(); return buf; @@ -1049,10 +1590,10 @@ boolean offerToQueue(Chunk chunk) { @SuppressJava6Requirement(reason = "Guarded by version check") private static final class ChunkRegistry { - private final LongAdder totalCapacity = new LongAdder(); + private final LongCounter totalCapacity = PlatformDependent.newLongCounter(); public long totalCapacity() { - return totalCapacity.sum(); + return totalCapacity.value(); } public void add(Chunk chunk) { @@ -1064,12 +1605,11 @@ public void remove(Chunk chunk) { } } - private static class Chunk implements ReferenceCounted { + static class Chunk implements ReferenceCounted { private static final long REFCNT_FIELD_OFFSET = ReferenceCountUpdater.getUnsafeOffset(Chunk.class, "refCnt"); private static final AtomicIntegerFieldUpdater AIF_UPDATER = AtomicIntegerFieldUpdater.newUpdater(Chunk.class, "refCnt"); - protected final AbstractByteBuf delegate; protected Magazine magazine; private final AdaptivePoolingAllocator allocator; @@ -1112,7 +1652,7 @@ protected long unsafeOffset() { allocator = magazine.group.allocator; } - Magazine currentMagazine() { + Magazine currentMagazine() { return magazine; } @@ -1284,20 +1824,23 @@ public int size() { * StoreLoad barrier via its {@code offer()}), then reads {@code state} — this guarantees * visibility of any preceding {@link #markToDeallocate()} write. */ - private static final class SizeClassedChunk extends Chunk { + static class SizeClassedChunk extends Chunk { private static final int FREE_LIST_EMPTY = -1; private static final int AVAILABLE = -1; // Integer.MIN_VALUE so that `DEALLOCATED + externalFreeList.size()` can never equal `segments`, // making late-arriving releaseSegment calls on external threads arithmetically harmless. private static final int DEALLOCATED = Integer.MIN_VALUE; private static final AtomicIntegerFieldUpdater STATE = - AtomicIntegerFieldUpdater.newUpdater(SizeClassedChunk.class, "state"); + AtomicIntegerFieldUpdater.newUpdater(SizeClassedChunk.class, "state"); private volatile int state; private final int segments; private final int segmentSize; private final MpscIntQueue externalFreeList; private final IntStack localFreeList; private Thread ownerThread; + int purgeEpoch; + long lastPurgeGeneration; + long lastScanGeneration; SizeClassedChunk(AbstractByteBuf delegate, Magazine magazine, SizeClassChunkController controller) { @@ -1363,6 +1906,15 @@ public boolean hasRemainingCapacity() { return !externalFreeList.isEmpty(); } + boolean hasFullCapacity() { + int free = externalFreeList.size(); + IntStack local = localFreeList; + if (local != null) { + free += local.size(); + } + return free == segments; + } + @Override public int remainingCapacity() { int remaining = super.remainingCapacity(); @@ -1706,7 +2258,7 @@ public ByteBuf capacity(int newCapacity) { allocator.reallocate(newCapacity, maxCapacity(), this); oldRoot.getBytes(baseOldRootIndex, this, 0, oldLength); chunk.releaseSegment(baseOldRootIndex, oldCapacity); - assert oldCapacity < maxFastCapacity && newCapacity <= maxFastCapacity: + assert oldCapacity < maxFastCapacity && newCapacity <= maxFastCapacity : "Capacity increase failed"; this.readerIndex = readerIndex; this.writerIndex = writerIndex; @@ -2100,8 +2652,9 @@ protected void deallocate() { interface ChunkAllocator { /** * Allocate a buffer for a chunk. This can be any kind of {@link AbstractByteBuf} implementation. + * * @param initialCapacity The initial capacity of the returned {@link AbstractByteBuf}. - * @param maxCapacity The maximum capacity of the returned {@link AbstractByteBuf}. + * @param maxCapacity The maximum capacity of the returned {@link AbstractByteBuf}. * @return The buffer that represents the chunk memory. */ AbstractByteBuf allocate(int initialCapacity, int maxCapacity); diff --git a/buffer/src/test/java/io/netty/buffer/AdaptiveByteBufAllocatorTest.java b/buffer/src/test/java/io/netty/buffer/AdaptiveByteBufAllocatorTest.java index 4c212410d88..3253a9a9383 100644 --- a/buffer/src/test/java/io/netty/buffer/AdaptiveByteBufAllocatorTest.java +++ b/buffer/src/test/java/io/netty/buffer/AdaptiveByteBufAllocatorTest.java @@ -16,19 +16,22 @@ package io.netty.buffer; import io.netty.util.NettyRuntime; +import io.netty.util.concurrent.FastThreadLocalThread; +import io.netty.util.internal.PlatformDependent; import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.RepetitionInfo; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; - import java.lang.reflect.Array; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; -import java.util.SplittableRandom; +import java.util.List; +import java.util.Random; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.FutureTask; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -123,7 +126,7 @@ void adaptiveChunkMustDeallocateOrReuseWthBufferRelease() throws Exception { assertEquals(0, allocator.usedHeapMemory()); bufs.add(allocator.heapBuffer(256)); long usedHeapMemory = allocator.usedHeapMemory(); - int buffersPerChunk = Math.toIntExact(usedHeapMemory / 256); + int buffersPerChunk = (int) (usedHeapMemory / 256); for (int i = 0; i < buffersPerChunk; i++) { bufs.add(allocator.heapBuffer(256)); } @@ -190,7 +193,8 @@ public void run() { ByteBuf buffer = null; try { buffer = alloc.heapBuffer(128); - buffer.ensureWritable(ThreadLocalRandom.current().nextInt(512, 32769)); + Random rng = PlatformDependent.threadLocalRandom(); + buffer.ensureWritable(512 + rng.nextInt(32769 - 512)); } finally { if (buffer != null) { buffer.release(); @@ -213,7 +217,6 @@ public void run() { @RepeatedTest(100) void buddyAllocationConsistency(RepetitionInfo info) { - SplittableRandom rng = new SplittableRandom(info.getCurrentRepetition()); AdaptiveByteBufAllocator allocator = newAllocator(true); int small = 32768; int large = 2 * small; @@ -225,14 +228,14 @@ void buddyAllocationConsistency(RepetitionInfo info) { xlarge, xlarge, }; - shuffle(rng, allocationSizes); + shuffle(allocationSizes); ByteBuf[] bufs = new ByteBuf[allocationSizes.length]; for (int i = 0; i < bufs.length; i++) { bufs[i] = allocator.buffer(allocationSizes[i], allocationSizes[i]); } - shuffle(rng, bufs); + shuffle(bufs); int[] reallocations = new int[bufs.length / 2]; for (int i = 0; i < reallocations.length; i++) { @@ -253,7 +256,7 @@ void buddyAllocationConsistency(RepetitionInfo info) { try { for (int i = 0; i < bufs.length; i++) { while (bufs[i].isReadable()) { - int b = Byte.toUnsignedInt(bufs[i].readByte()); + int b = bufs[i].readByte() & 0xFF; if (b != i + 1) { fail("Expected byte " + (i + 1) + " at index " + (bufs[i].readerIndex() - 1) + @@ -268,10 +271,82 @@ void buddyAllocationConsistency(RepetitionInfo info) { } } - private static void shuffle(SplittableRandom rng, Object array) { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void purgeScanShouldEvictIdleChunks(boolean threadLocal) throws Exception { + // Thread-local magazines require FastThreadLocalThread + // (allocate() checks currentThreadWillCleanupFastThreadLocals) + final AdaptiveByteBufAllocator allocator = new AdaptiveByteBufAllocator(false, threadLocal); + final long purgePolls = threadLocal ? + AdaptivePoolingAllocator.CHUNK_PURGE_POLLS_THREAD_LOCAL : + AdaptivePoolingAllocator.CHUNK_PURGE_POLLS_SHARED; + Runnable test = new Runnable() { + @Override + public void run() { + assertPurgeScanEvictsIdleChunks(allocator, purgePolls); + } + }; + if (threadLocal) { + FutureTask task = new FutureTask(test, null); + FastThreadLocalThread thread = new FastThreadLocalThread(task); + thread.start(); + thread.join(); + task.get(); + } else { + test.run(); + } + } + + private static void assertPurgeScanEvictsIdleChunks(AdaptiveByteBufAllocator allocator, long purgePolls) { + ByteBuf probe = allocator.heapBuffer(256); + long chunkSize = allocator.usedHeapMemory(); + int buffersPerChunk = (int) (chunkSize / 256); + probe.release(); + + int totalChunks = (int) Math.max(purgePolls, AdaptivePoolingAllocator.CHUNK_REUSE_QUEUE) * 4 + 10; + int totalBuffers = totalChunks * buffersPerChunk; + List bufs = new ArrayList(totalBuffers); + for (int i = 0; i < totalBuffers; i++) { + bufs.add(allocator.heapBuffer(256)); + } + + for (ByteBuf buf : bufs) { + buf.release(); + } + bufs.clear(); + long memoryAfterRelease = allocator.usedHeapMemory(); + + int threshold = AdaptivePoolingAllocator.CHUNK_PURGE_THRESHOLD; + // Account for pollChunk calls burned during setup (one per chunk created) + // and partition shuffle: with N notEmpty and P polls, each chunk is polled + // P/N of the time. Epochs advance at rate 1-P/N per cycle. Need enough cycles + // for the slowest chunk to reach threshold. + int setupPolls = totalChunks; + int notEmpty = totalChunks - AdaptivePoolingAllocator.CHUNK_REUSE_QUEUE; + double advanceRate = 1.0 - (double) purgePolls / notEmpty; + int cyclesNeeded = advanceRate > 0 ? (int) Math.ceil((threshold + 1) / advanceRate) + 2 : threshold + 2; + int pollsNeeded = setupPolls + (int) (cyclesNeeded * purgePolls); + for (int poll = 0; poll < pollsNeeded; poll++) { + for (int i = 0; i < buffersPerChunk; i++) { + bufs.add(allocator.heapBuffer(256)); + } + for (ByteBuf buf : bufs) { + buf.release(); + } + bufs.clear(); + } + + long memoryAfterPurge = allocator.usedHeapMemory(); + assertTrue(memoryAfterPurge < memoryAfterRelease, + "Memory should decrease after purge scans evict idle chunks. " + + "Before purge: " + memoryAfterRelease + ", after purge: " + memoryAfterPurge); + } + + private static void shuffle(Object array) { + Random rng = PlatformDependent.threadLocalRandom(); int len = Array.getLength(array); for (int i = 0; i < len; i++) { - int n = rng.nextInt(i, len); + int n = i + rng.nextInt(len - i); Object value = Array.get(array, i); Array.set(array, i, Array.get(array, n)); Array.set(array, n, value); diff --git a/buffer/src/test/java/io/netty/buffer/SizeClassedChunkCacheTest.java b/buffer/src/test/java/io/netty/buffer/SizeClassedChunkCacheTest.java new file mode 100644 index 00000000000..83ae617de08 --- /dev/null +++ b/buffer/src/test/java/io/netty/buffer/SizeClassedChunkCacheTest.java @@ -0,0 +1,621 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project 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: + * + * https://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 io.netty.buffer; + +import io.netty.buffer.AdaptivePoolingAllocator.SizeClassedChunk; +import io.netty.buffer.AdaptivePoolingAllocator.SizeClassedChunkCache; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class SizeClassedChunkCacheTest { + + private static final int TL_CHUNK_SIZE = AdaptivePoolingAllocator.THREAD_LOCAL_CACHE_MAX_BYTES / 8; + + private static int threadLocalPurgeFloor() { + AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache cache = + new AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache(TL_CHUNK_SIZE); + return cache.purgeRetentionFloor; + } + + private static SizeClassedChunk chunkWithCapacity() { + SizeClassedChunk chunk = mock(SizeClassedChunk.class); + when(chunk.remainingCapacity()).thenReturn(512); + when(chunk.capacity()).thenReturn(4096); + when(chunk.hasRemainingCapacity()).thenReturn(true); + when(chunk.hasFullCapacity()).thenReturn(false); + return chunk; + } + + private static SizeClassedChunk chunkWithoutCapacity() { + SizeClassedChunk chunk = mock(SizeClassedChunk.class); + when(chunk.remainingCapacity()).thenReturn(0); + when(chunk.capacity()).thenReturn(4096); + when(chunk.hasRemainingCapacity()).thenReturn(false); + when(chunk.hasFullCapacity()).thenReturn(false); + return chunk; + } + + private static SizeClassedChunk fullChunk() { + // All segments available → purge ages it. + SizeClassedChunk chunk = mock(SizeClassedChunk.class); + when(chunk.remainingCapacity()).thenReturn(4096); + when(chunk.capacity()).thenReturn(4096); + when(chunk.hasRemainingCapacity()).thenReturn(true); + when(chunk.hasFullCapacity()).thenReturn(true); + return chunk; + } + + // --- purge: selection (both caches) --- + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void purgeSelectsFirstChunkWithCapacity(boolean threadLocal) { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(threadLocal, 1024); + + SizeClassedChunk noCap = chunkWithoutCapacity(); + SizeClassedChunk cap = chunkWithCapacity(); + cache.offerChunk(noCap); + cache.offerChunk(cap); + + assertSame(cap, cache.forcePurge()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void purgeReturnsNullWhenCacheIsEmpty(boolean threadLocal) { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(threadLocal, 1024); + assertNull(cache.forcePurge()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void purgeReturnsNullWhenNoChunkHasCapacity(boolean threadLocal) { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(threadLocal, 1024); + cache.offerChunk(chunkWithoutCapacity()); + cache.offerChunk(chunkWithoutCapacity()); + + assertNull(cache.forcePurge()); + } + + // --- purge: epoch aging and eviction (both caches) --- + + @Test + void fullChunkAgesEachPurgeAndIsEvictedPastThresholdThreadLocal() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(true, TL_CHUNK_SIZE); + + for (int i = 0; i < threadLocalPurgeFloor(); i++) { + cache.offerChunk(chunkWithoutCapacity()); + } + SizeClassedChunk workingSet = chunkWithCapacity(); + cache.offerChunk(workingSet); + SizeClassedChunk idle = fullChunk(); + cache.offerChunk(idle); + + for (int i = 0; i < AdaptivePoolingAllocator.CHUNK_PURGE_THRESHOLD; i++) { + SizeClassedChunk polled = cache.forcePurge(); + assertSame(workingSet, polled); + cache.offerChunk(workingSet); + assertEquals(i + 1, idle.purgeEpoch); + verify(idle, never()).markToDeallocate(); + } + SizeClassedChunk polled = cache.forcePurge(); + assertSame(workingSet, polled); + verify(idle).markToDeallocate(); + } + + @Test + void fullChunkAgesAndIsEventuallyEvictedShared() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(false, 1024); + + for (int i = 0; i < AdaptivePoolingAllocator.CHUNK_REUSE_QUEUE; i++) { + cache.offerChunk(chunkWithoutCapacity()); + } + SizeClassedChunk workingSet = chunkWithCapacity(); + cache.offerChunk(workingSet); + SizeClassedChunk idle = fullChunk(); + cache.offerChunk(idle); + + int maxCycles = (AdaptivePoolingAllocator.CHUNK_PURGE_THRESHOLD + 1) * 3; + for (int i = 0; i < maxCycles; i++) { + SizeClassedChunk polled = cache.forcePurge(); + if (polled != null) { + cache.offerChunk(polled); + } + } + verify(idle, atLeastOnce()).markToDeallocate(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void nonFullChunkDoesNotAge(boolean threadLocal) { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(threadLocal, 1024); + + SizeClassedChunk chunk = chunkWithCapacity(); + cache.offerChunk(chunk); + + cache.forcePurge(); + assertEquals(0, chunk.purgeEpoch); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void selectedFullChunkHasEpochReset(boolean threadLocal) { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(threadLocal, 1024); + + SizeClassedChunk chunk = fullChunk(); + cache.offerChunk(chunk); + + SizeClassedChunk selected = cache.forcePurge(); + assertSame(chunk, selected); + assertEquals(0, selected.purgeEpoch); + } + + // --- scanForCapacity: fallback (both caches) --- + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void scanForCapacityFallbackFindsChunkThatGainedCapacity(boolean threadLocal) { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(threadLocal, 1024); + + SizeClassedChunk chunk = chunkWithoutCapacity(); + cache.offerChunk(chunk); + + // Purge: no capacity, nothing selected + assertNull(cache.forcePurge()); + + // External segment return gives the chunk capacity + when(chunk.hasRemainingCapacity()).thenReturn(true); + + assertSame(chunk, cache.pollChunk(256)); + } + + // --- thread-local only: capacity-first ordering --- + + @Test + void purgeMovesCapacityChunksBeforeNoCapacityChunks() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(true, 1024); + + cache.offerChunk(chunkWithoutCapacity()); + cache.offerChunk(chunkWithCapacity()); + cache.offerChunk(chunkWithoutCapacity()); + cache.offerChunk(chunkWithCapacity()); + cache.offerChunk(chunkWithCapacity()); + + // Purge selects one capacity chunk, partitions the rest: [cap, cap | noCap, noCap] + SizeClassedChunk selected = cache.forcePurge(); + assertNotNull(selected); + assertTrue(selected.hasRemainingCapacity()); + + // Both remaining capacity chunks come out before any no-capacity chunk + assertTrue(cache.pollChunk(256).hasRemainingCapacity()); + assertTrue(cache.pollChunk(256).hasRemainingCapacity()); + assertNull(cache.pollChunk(256)); + } + + @Test + void scanForCapacityUsesO1FastPathAfterPurge() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(true, 1024); + + cache.offerChunk(chunkWithCapacity()); + cache.offerChunk(chunkWithCapacity()); + cache.offerChunk(chunkWithoutCapacity()); + + // Purge partitions: [cap | noCap], selects one cap + assertNotNull(cache.forcePurge()); + + // Next poll hits the O(1) fast path — capacity chunk is at head + SizeClassedChunk fast = cache.pollChunk(256); + assertNotNull(fast); + assertTrue(fast.hasRemainingCapacity()); + } + + // --- thread-local only: ring buffer mechanics --- + + @Test + void offerGrowsRingWhenFull() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(true, 1024); + + // Initial ring size is 8 — offer 9 to trigger growth + for (int i = 0; i < 9; i++) { + cache.offerChunk(chunkWithCapacity()); + } + + // Purge selects one, 8 remain — all should be retrievable + assertNotNull(cache.forcePurge()); + for (int i = 0; i < 8; i++) { + assertNotNull(cache.pollChunk(256)); + } + assertNull(cache.pollChunk(256)); + } + + @Test + void purgeHandlesWrappedRingCorrectly() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(true, 1024); + + // Fill with 4, purge (linearizes to head=0), consume 3 to advance head + for (int i = 0; i < 4; i++) { + cache.offerChunk(chunkWithCapacity()); + } + cache.forcePurge(); + cache.pollChunk(256); + cache.pollChunk(256); + cache.pollChunk(256); + + // Offer more — tail wraps around past the array end + cache.offerChunk(chunkWithoutCapacity()); + cache.offerChunk(chunkWithCapacity()); + cache.offerChunk(chunkWithoutCapacity()); + cache.offerChunk(chunkWithCapacity()); + + // Purge with wrapped ring should still partition correctly + SizeClassedChunk selected = cache.forcePurge(); + assertNotNull(selected); + assertTrue(selected.hasRemainingCapacity()); + + // Remaining capacity chunk at head + SizeClassedChunk next = cache.pollChunk(256); + if (next != null) { + assertTrue(next.hasRemainingCapacity()); + } + } + + @Test + void wrappedRingCompactionLeavesNoStaleReferences() { + AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache cache = + (AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache) + SizeClassedChunkCache.create(true, 1024); + + // Fill 6 slots of the initial ring (size=8), purge, drain to advance head + for (int i = 0; i < 6; i++) { + cache.offerChunk(chunkWithCapacity()); + } + cache.forcePurge(); + while (cache.pollChunk(256) != null) { + // drain + } + assertTrue(cache.head > 0, "head should have advanced past 0"); + + // Offer 4 chunks — wraps past the array boundary + cache.offerChunk(chunkWithCapacity()); + cache.offerChunk(chunkWithCapacity()); + cache.offerChunk(fullChunk()); + cache.offerChunk(fullChunk()); + assertTrue(cache.tail < cache.head, + "ring should wrap: tail=" + cache.tail + " < head=" + cache.head); + + // Purge partitions on the wrapped ring + SizeClassedChunk polled = cache.forcePurge(); + assertNotNull(polled); + + // Verify the backing array: exactly count non-null entries, no stale refs + int nonNull = 0; + for (int i = 0; i < cache.chunks.length; i++) { + if (cache.chunks[i] != null) { + nonNull++; + } + } + assertEquals(cache.count, nonNull, + "backing array should have exactly count=" + cache.count + + " non-null entries, but found " + nonNull); + } + + // --- bursty traffic: idle chunks are eventually evicted --- + + @Test + void cacheSettlesAtRetentionFloorAfterBurstThreadLocal() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(true, TL_CHUNK_SIZE); + + int floor = threadLocalPurgeFloor(); + int cap = Math.max(2, AdaptivePoolingAllocator.THREAD_LOCAL_CACHE_MAX_BYTES / TL_CHUNK_SIZE); + int excess = cap - floor; + assertTrue(excess > 0, "excess must be positive for the test to be meaningful"); + + SizeClassedChunk workingSet = chunkWithCapacity(); + cache.offerChunk(workingSet); + for (int i = 0; i < floor - 1; i++) { + cache.offerChunk(chunkWithoutCapacity()); + } + SizeClassedChunk[] excessChunks = new SizeClassedChunk[excess]; + for (int i = 0; i < excess; i++) { + excessChunks[i] = fullChunk(); + cache.offerChunk(excessChunks[i]); + } + + for (int i = 0; i < AdaptivePoolingAllocator.CHUNK_PURGE_THRESHOLD + 1; i++) { + SizeClassedChunk polled = cache.forcePurge(); + assertSame(workingSet, polled); + cache.offerChunk(workingSet); + } + + for (SizeClassedChunk chunk : excessChunks) { + verify(chunk, atLeastOnce()).markToDeallocate(); + } + verify(workingSet, never()).markToDeallocate(); + } + + @Test + void cacheSettlesAfterBurstShared() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(false, 1024); + + int crq = AdaptivePoolingAllocator.CHUNK_REUSE_QUEUE; + int capacity = Math.max(16, crq * 2); + int excess = Math.min(10, capacity - crq); + assertTrue(excess > 0, "excess must be positive for the test to be meaningful"); + SizeClassedChunk workingSet = chunkWithCapacity(); + cache.offerChunk(workingSet); + for (int i = 0; i < crq - 1; i++) { + cache.offerChunk(chunkWithoutCapacity()); + } + SizeClassedChunk[] excessChunks = new SizeClassedChunk[excess]; + for (int i = 0; i < excess; i++) { + excessChunks[i] = fullChunk(); + assertTrue(cache.offerChunk(excessChunks[i]), "all excess chunks must be accepted by the queue"); + } + + int maxCycles = (AdaptivePoolingAllocator.CHUNK_PURGE_THRESHOLD + 1) * 3; + for (int i = 0; i < maxCycles; i++) { + SizeClassedChunk polled = cache.forcePurge(); + if (polled != null) { + cache.offerChunk(polled); + } + } + + for (SizeClassedChunk chunk : excessChunks) { + verify(chunk, atLeastOnce()).markToDeallocate(); + } + } + + // --- epoch aging with working set --- + // Scan resets epoch on pick (the chunk is being used). Partition sub-ordering puts + // epoch=0 (recently used) at head, epoch>0 (idle) behind. Scan prefers head, so + // idle chunks age undisturbed behind the working set. + + @Test + void excessFullChunksAgeWhileWorkingSetIsPreferredThreadLocal() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(true, TL_CHUNK_SIZE); + + for (int i = 0; i < threadLocalPurgeFloor(); i++) { + cache.offerChunk(chunkWithoutCapacity()); + } + SizeClassedChunk workingSet = chunkWithCapacity(); + cache.offerChunk(workingSet); + int excess = 3; + SizeClassedChunk[] idleChunks = new SizeClassedChunk[excess]; + for (int i = 0; i < excess; i++) { + idleChunks[i] = fullChunk(); + cache.offerChunk(idleChunks[i]); + } + + for (int i = 0; i < AdaptivePoolingAllocator.CHUNK_PURGE_THRESHOLD + 1; i++) { + SizeClassedChunk polled = cache.forcePurge(); + assertSame(workingSet, polled, "cycle " + i + ": scan should prefer working-set chunk"); + cache.offerChunk(workingSet); + } + + for (SizeClassedChunk idle : idleChunks) { + verify(idle, atLeastOnce()).markToDeallocate(); + } + verify(workingSet, never()).markToDeallocate(); + } + + @Test + void excessFullChunksEventuallyEvictedShared() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(false, 1024); + + for (int i = 0; i < AdaptivePoolingAllocator.CHUNK_REUSE_QUEUE; i++) { + cache.offerChunk(chunkWithoutCapacity()); + } + SizeClassedChunk workingSet = chunkWithCapacity(); + cache.offerChunk(workingSet); + int excess = 3; + SizeClassedChunk[] idleChunks = new SizeClassedChunk[excess]; + for (int i = 0; i < excess; i++) { + idleChunks[i] = fullChunk(); + cache.offerChunk(idleChunks[i]); + } + + int maxCycles = (AdaptivePoolingAllocator.CHUNK_PURGE_THRESHOLD + 1) * 3; + for (int i = 0; i < maxCycles; i++) { + SizeClassedChunk polled = cache.forcePurge(); + if (polled != null) { + cache.offerChunk(polled); + } + } + + for (SizeClassedChunk idle : idleChunks) { + verify(idle, atLeastOnce()).markToDeallocate(); + } + } + + // --- full-but-active chunk must not be prematurely evicted --- + // A chunk that is polled every purge cycle but whose buffers are short-lived + // (all segments return before next purge) looks "full" (remaining == capacity) + // at purge time. Purge must not treat it as idle. + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void activeChunkWithShortLivedBuffersShouldNotBeEvicted(boolean threadLocal) { + int chunkSize = threadLocal ? TL_CHUNK_SIZE : 1024; + SizeClassedChunkCache cache = SizeClassedChunkCache.create(threadLocal, chunkSize); + + int floor = threadLocal ? threadLocalPurgeFloor() : AdaptivePoolingAllocator.CHUNK_REUSE_QUEUE; + for (int i = 0; i < floor; i++) { + cache.offerChunk(chunkWithoutCapacity()); + } + + // Full chunk: remaining==capacity>0, has capacity. + // Simulates short-lived buffers: chunk polled, used, all segments return before next purge. + SizeClassedChunk active = fullChunk(); + cache.offerChunk(active); + + int cycles = AdaptivePoolingAllocator.CHUNK_PURGE_THRESHOLD + 2; + for (int cycle = 0; cycle < cycles; cycle++) { + SizeClassedChunk polled = cache.forcePurge(); + assertSame(active, polled, "cycle " + cycle + ": chunk should be polled, not evicted"); + assertEquals(0, polled.purgeEpoch, + "cycle " + cycle + ": actively-used chunk epoch should be reset"); + cache.offerChunk(active); + } + + verify(active, never()).markToDeallocate(); + } + + // --- shared cache: concurrent scanForCapacity must not livelock --- + + @Test + void concurrentScansTerminateWhenNoCapacity() throws Exception { + final SizeClassedChunkCache cache = SizeClassedChunkCache.create(false, 1024); + + // Fill with no-capacity chunks — no scan can find anything + for (int i = 0; i < 10; i++) { + cache.offerChunk(chunkWithoutCapacity()); + } + + int threadCount = 4; + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(threadCount); + final AtomicReference error = new AtomicReference(); + + for (int t = 0; t < threadCount; t++) { + new Thread(new Runnable() { + @Override + public void run() { + try { + startLatch.await(); + for (int i = 0; i < 1000; i++) { + assertNull(cache.pollChunk(256)); + } + } catch (Throwable e) { + error.compareAndSet(null, e); + } finally { + doneLatch.countDown(); + } + } + }).start(); + } + + startLatch.countDown(); + // With the == sentinel check, threads could livelock here. + // With >= ordering, all scans terminate promptly. + boolean finished = doneLatch.await(30, java.util.concurrent.TimeUnit.SECONDS); + assertTrue(finished, "Concurrent scans should terminate within 30 seconds, not livelock"); + assertNull(error.get()); + } + + // --- free: draining all chunks (thread-local) --- + + @Test + void pollChunkCannotDrainNoCapChunksThreadLocal() { + AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache cache = + new AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache(1024); + + cache.offerChunk(chunkWithCapacity()); + cache.offerChunk(chunkWithoutCapacity()); + cache.offerChunk(chunkWithCapacity()); + cache.offerChunk(chunkWithoutCapacity()); + + int drained = 0; + while (cache.pollChunk(0) != null) { + drained++; + if (drained > 100) { + break; + } + } + + // pollChunk uses scanForCapacity which skips noCap chunks — they're stuck. + // This is why free() is needed instead of a pollChunk drain loop. + assertEquals(2, cache.count); + verify(cache.chunks[cache.head], never()).markToDeallocate(); + } + + @Test + void freeDrainsAllChunksIncludingNoCapThreadLocal() { + AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache cache = + new AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache(1024); + + SizeClassedChunk cap1 = chunkWithCapacity(); + SizeClassedChunk cap2 = chunkWithCapacity(); + SizeClassedChunk noCap1 = chunkWithoutCapacity(); + SizeClassedChunk noCap2 = chunkWithoutCapacity(); + + cache.offerChunk(cap1); + cache.offerChunk(noCap1); + cache.offerChunk(cap2); + cache.offerChunk(noCap2); + + cache.free(); + + assertTrue(cache.isEmpty()); + verify(cap1, atLeastOnce()).markToDeallocate(); + verify(cap2, atLeastOnce()).markToDeallocate(); + verify(noCap1, atLeastOnce()).markToDeallocate(); + verify(noCap2, atLeastOnce()).markToDeallocate(); + } + + @Test + void freeDrainsAllChunksShared() { + SizeClassedChunkCache cache = SizeClassedChunkCache.create(false, 1024); + + SizeClassedChunk cap = chunkWithCapacity(); + SizeClassedChunk noCap = chunkWithoutCapacity(); + + cache.offerChunk(cap); + cache.offerChunk(noCap); + + cache.free(); + + assertTrue(cache.isEmpty()); + verify(cap, atLeastOnce()).markToDeallocate(); + verify(noCap, atLeastOnce()).markToDeallocate(); + } + + @Test + void threadLocalCacheRejectsChunksWhenCapReached() { + int chunkSize = TL_CHUNK_SIZE; + int maxCached = Math.max(1, + AdaptivePoolingAllocator.THREAD_LOCAL_CACHE_MAX_BYTES / chunkSize); + + AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache cache = + new AdaptivePoolingAllocator.ThreadLocalSizeClassedChunkCache(chunkSize); + + // Fill to capacity + for (int i = 0; i < maxCached; i++) { + assertTrue(cache.offerChunk(chunkWithoutCapacity()), + "offer should succeed for chunk " + i); + } + + // Next offer should be rejected + SizeClassedChunk excess = chunkWithoutCapacity(); + boolean accepted = cache.offerChunk(excess); + assertFalse(accepted, "offer should be rejected when cache is at capacity"); + } +} From b75a281cadef48981d14d6b95c56b869ff4f83dc Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Mon, 3 Aug 2026 23:25:11 -0700 Subject: [PATCH 58/64] Weakly reference engines from the OpenSSL engine map (#17199) (#17205) Motivation: ReferenceCountedOpenSslContext strongly references every engine through its `engines` map (the SSL* -> engine reverse lookup used by the OpenSSL callbacks). As an SslContext is typically a long-lived singleton, a leaked engine stays reachable for the context's whole lifetime, so OpenSslEngine.finalize() - the backstop meant for that case - never runs and its native memory is freed only when the context is destroyed. Modification: Reintroduce OpenSslEngineMap as a class holding the engines as WeakReferences. A live engine is always strongly reachable via its SslHandler, so only a leaked one becomes collectable. The abstraction removed in #15444 was a strong wrapper that rightly added no value; weak values give it a reason to exist. Add OpenSslEngineTest.leakedEngineIsReclaimedWhileContextAlive. Result: A leaked OPENSSL engine is reclaimed per-engine independently of its context. For OPENSSL_REFCNT a leaked engine now also becomes collectable, so the ResourceLeakDetector reports it instead of it being pinned silently. (cherry picked from commit 26255b123f7c699cd88cbdb42f6ecc6ed5cb7843) This also backports https://github.com/netty/netty/pull/15444 which is harmless because neither of those types are public. --------- Co-authored-by: Bryce Anderson --- .../handler/ssl/OpenSslClientContext.java | 2 +- .../ssl/OpenSslClientSessionCache.java | 4 +- .../netty/handler/ssl/OpenSslEngineMap.java | 56 +++++++++++---- .../handler/ssl/OpenSslServerContext.java | 2 +- .../ssl/OpenSslServerSessionContext.java | 2 +- .../handler/ssl/OpenSslSessionCache.java | 10 +-- .../ReferenceCountedOpenSslClientContext.java | 36 +++++----- .../ssl/ReferenceCountedOpenSslContext.java | 69 +++++++------------ .../ssl/ReferenceCountedOpenSslEngine.java | 16 ++--- .../ReferenceCountedOpenSslServerContext.java | 43 ++++++------ .../netty/handler/ssl/OpenSslEngineTest.java | 36 ++++++++++ .../ReferenceCountedOpenSslEngineTest.java | 7 ++ 12 files changed, 171 insertions(+), 112 deletions(-) diff --git a/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java b/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java index 697dc0ae8ec..78582a73b19 100644 --- a/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/OpenSslClientContext.java @@ -208,7 +208,7 @@ public OpenSslClientContext(File trustCertCollectionFile, TrustManagerFactory tr boolean success = false; try { OpenSslKeyMaterialProvider.validateKeyMaterialSupported(keyCertChain, key, keyPassword); - sessionContext = newSessionContext(this, ctx, engineMap, trustCertCollection, trustManagerFactory, + sessionContext = newSessionContext(this, ctx, engines, trustCertCollection, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, keyStore, sessionCacheSize, sessionTimeout, resumptionController); success = true; diff --git a/handler/src/main/java/io/netty/handler/ssl/OpenSslClientSessionCache.java b/handler/src/main/java/io/netty/handler/ssl/OpenSslClientSessionCache.java index e660b80e388..e12cfea8b4c 100644 --- a/handler/src/main/java/io/netty/handler/ssl/OpenSslClientSessionCache.java +++ b/handler/src/main/java/io/netty/handler/ssl/OpenSslClientSessionCache.java @@ -31,8 +31,8 @@ final class OpenSslClientSessionCache extends OpenSslSessionCache { private final Map> sessions = new HashMap>(); - OpenSslClientSessionCache(OpenSslEngineMap engineMap) { - super(engineMap); + OpenSslClientSessionCache(OpenSslEngineMap engines) { + super(engines); } @Override diff --git a/handler/src/main/java/io/netty/handler/ssl/OpenSslEngineMap.java b/handler/src/main/java/io/netty/handler/ssl/OpenSslEngineMap.java index 68e2df57132..00dbb68de10 100644 --- a/handler/src/main/java/io/netty/handler/ssl/OpenSslEngineMap.java +++ b/handler/src/main/java/io/netty/handler/ssl/OpenSslEngineMap.java @@ -1,5 +1,5 @@ /* - * Copyright 2014 The Netty Project + * Copyright 2026 The Netty Project * * The Netty Project licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -15,21 +15,47 @@ */ package io.netty.handler.ssl; -interface OpenSslEngineMap { +import java.lang.ref.WeakReference; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; - /** - * Remove the {@link OpenSslEngine} with the given {@code ssl} address and - * return it. - */ - ReferenceCountedOpenSslEngine remove(long ssl); +/** + * Maps a native {@code SSL*} pointer to its {@link ReferenceCountedOpenSslEngine} so native OpenSSL callbacks + * (certificate verification, private-key operations, certificate (de)compression) can recover the engine from the + * raw pointer they are handed. + *

+ * Engines are held weakly so a leaked engine is not pinned by the long-lived parent + * {@link ReferenceCountedOpenSslContext} (a live engine is always strongly reachable via its {@link SslHandler}, and + * on the stack during a callback, so weak retention never collects a usable one). For {@link SslProvider#OPENSSL} + * this lets {@link OpenSslEngine#finalize()} reclaim the native {@code SSL*} without waiting for the whole context to + * be collected; a leaked {@link SslProvider#OPENSSL_REFCNT} engine has no finalizer so its native memory still leaks, + * but it becomes collectable and so is reported by the {@code ResourceLeakDetector} rather than pinned silently. + *

+ * Entries are removed in {@link ReferenceCountedOpenSslEngine#shutdown()}, so the map does not grow in steady state. + * A cleared {@link WeakReference} lingers only for a leaked {@code OPENSSL_REFCNT} engine (whose {@code SSL*} is never + * freed, hence never reused); such a husk is tiny, dwarfed by the native memory it marks, and is deliberately left as + * a heap-inspectable leak signal. Do not reap it (e.g. via a {@code ReferenceQueue}): {@link #get(long)} already + * yields {@code null} for a cleared reference, so reaping would only erase that signal. + */ +final class OpenSslEngineMap { + + private final Map> engines = + new ConcurrentHashMap>(); + + void add(long ssl, ReferenceCountedOpenSslEngine engine) { + // A fresh SSL_new() pointer maps to nothing yet: an SSL* is reused only after shutdown() removed its entry + // (remove-before-freeSSL), and a husk survives only for a never-freed, never-reused SSL*. + WeakReference prev = + engines.put(ssl, new WeakReference(engine)); + assert prev == null : "OpenSslEngineMap already had an entry for SSL* 0x" + Long.toHexString(ssl); + } - /** - * Add a {@link OpenSslEngine} to this {@link OpenSslEngineMap}. - */ - void add(ReferenceCountedOpenSslEngine engine); + void remove(long ssl) { + engines.remove(ssl); + } - /** - * Get the {@link OpenSslEngine} for the given {@code ssl} address. - */ - ReferenceCountedOpenSslEngine get(long ssl); + ReferenceCountedOpenSslEngine get(long ssl) { + WeakReference ref = engines.get(ssl); + return ref == null ? null : ref.get(); + } } diff --git a/handler/src/main/java/io/netty/handler/ssl/OpenSslServerContext.java b/handler/src/main/java/io/netty/handler/ssl/OpenSslServerContext.java index feca8d1b9e5..3ba4d44298d 100644 --- a/handler/src/main/java/io/netty/handler/ssl/OpenSslServerContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/OpenSslServerContext.java @@ -356,7 +356,7 @@ private OpenSslServerContext( boolean success = false; try { OpenSslKeyMaterialProvider.validateKeyMaterialSupported(keyCertChain, key, keyPassword); - sessionContext = newSessionContext(this, ctx, engineMap, trustCertCollection, trustManagerFactory, + sessionContext = newSessionContext(this, ctx, engines, trustCertCollection, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, keyStore, sessionCacheSize, sessionTimeout, resumptionController); success = true; diff --git a/handler/src/main/java/io/netty/handler/ssl/OpenSslServerSessionContext.java b/handler/src/main/java/io/netty/handler/ssl/OpenSslServerSessionContext.java index eba161f3618..6be8f6bbb17 100644 --- a/handler/src/main/java/io/netty/handler/ssl/OpenSslServerSessionContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/OpenSslServerSessionContext.java @@ -26,7 +26,7 @@ */ public final class OpenSslServerSessionContext extends OpenSslSessionContext { OpenSslServerSessionContext(ReferenceCountedOpenSslContext context, OpenSslKeyMaterialProvider provider) { - super(context, provider, SSL.SSL_SESS_CACHE_SERVER, new OpenSslSessionCache(context.engineMap)); + super(context, provider, SSL.SSL_SESS_CACHE_SERVER, new OpenSslSessionCache(context.engines)); } /** diff --git a/handler/src/main/java/io/netty/handler/ssl/OpenSslSessionCache.java b/handler/src/main/java/io/netty/handler/ssl/OpenSslSessionCache.java index b2339758fbb..d567412368c 100644 --- a/handler/src/main/java/io/netty/handler/ssl/OpenSslSessionCache.java +++ b/handler/src/main/java/io/netty/handler/ssl/OpenSslSessionCache.java @@ -49,7 +49,7 @@ class OpenSslSessionCache implements SSLSessionCache { DEFAULT_CACHE_SIZE = 20480; } } - private final OpenSslEngineMap engineMap; + private final OpenSslEngineMap engines; private final Map sessions = new LinkedHashMap() { @@ -74,8 +74,8 @@ protected boolean removeEldestEntry(Map.Entry engines = PlatformDependent.newConcurrentHashMap(); - - @Override - public ReferenceCountedOpenSslEngine remove(long ssl) { - return engines.remove(ssl); - } - - @Override - public void add(ReferenceCountedOpenSslEngine engine) { - engines.put(engine.sslPointer(), engine); - } - - @Override - public ReferenceCountedOpenSslEngine get(long ssl) { - return engines.get(ssl); - } - } - static void setKeyMaterial(long ctx, X509Certificate[] keyCertChain, PrivateKey key, String keyPassword) throws SSLException { /* Load the certificate file and private key. */ @@ -1078,16 +1059,16 @@ private static ReferenceCountedOpenSslEngine retrieveEngine(OpenSslEngineMap eng private static final class PrivateKeyMethod implements SSLPrivateKeyMethod { - private final OpenSslEngineMap engineMap; + private final OpenSslEngineMap engines; private final OpenSslPrivateKeyMethod keyMethod; - PrivateKeyMethod(OpenSslEngineMap engineMap, OpenSslPrivateKeyMethod keyMethod) { - this.engineMap = engineMap; + PrivateKeyMethod(OpenSslEngineMap engines, OpenSslPrivateKeyMethod keyMethod) { + this.engines = engines; this.keyMethod = keyMethod; } @Override public byte[] sign(long ssl, int signatureAlgorithm, byte[] digest) throws Exception { - ReferenceCountedOpenSslEngine engine = retrieveEngine(engineMap, ssl); + ReferenceCountedOpenSslEngine engine = retrieveEngine(engines, ssl); try { return verifyResult(keyMethod.sign(engine, signatureAlgorithm, digest)); } catch (Exception e) { @@ -1098,7 +1079,7 @@ public byte[] sign(long ssl, int signatureAlgorithm, byte[] digest) throws Excep @Override public byte[] decrypt(long ssl, byte[] input) throws Exception { - ReferenceCountedOpenSslEngine engine = retrieveEngine(engineMap, ssl); + ReferenceCountedOpenSslEngine engine = retrieveEngine(engines, ssl); try { return verifyResult(keyMethod.decrypt(engine, input)); } catch (Exception e) { @@ -1110,18 +1091,19 @@ public byte[] decrypt(long ssl, byte[] input) throws Exception { private static final class AsyncPrivateKeyMethod implements AsyncSSLPrivateKeyMethod { - private final OpenSslEngineMap engineMap; + private final OpenSslEngineMap engines; private final OpenSslAsyncPrivateKeyMethod keyMethod; - AsyncPrivateKeyMethod(OpenSslEngineMap engineMap, OpenSslAsyncPrivateKeyMethod keyMethod) { - this.engineMap = engineMap; + AsyncPrivateKeyMethod(OpenSslEngineMap engines, + OpenSslAsyncPrivateKeyMethod keyMethod) { + this.engines = engines; this.keyMethod = keyMethod; } @Override public void sign(long ssl, int signatureAlgorithm, byte[] bytes, ResultCallback resultCallback) { try { - ReferenceCountedOpenSslEngine engine = retrieveEngine(engineMap, ssl); + ReferenceCountedOpenSslEngine engine = retrieveEngine(engines, ssl); keyMethod.sign(engine, signatureAlgorithm, bytes) .addListener(new ResultCallbackListener(engine, ssl, resultCallback)); } catch (SSLException e) { @@ -1132,7 +1114,7 @@ public void sign(long ssl, int signatureAlgorithm, byte[] bytes, ResultCallback< @Override public void decrypt(long ssl, byte[] bytes, ResultCallback resultCallback) { try { - ReferenceCountedOpenSslEngine engine = retrieveEngine(engineMap, ssl); + ReferenceCountedOpenSslEngine engine = retrieveEngine(engines, ssl); keyMethod.decrypt(engine, bytes) .addListener(new ResultCallbackListener(engine, ssl, resultCallback)); } catch (SSLException e) { @@ -1178,23 +1160,24 @@ private static byte[] verifyResult(byte[] result) throws SignatureException { } private static final class CompressionAlgorithm implements CertificateCompressionAlgo { - private final OpenSslEngineMap engineMap; + private final OpenSslEngineMap engines; private final OpenSslCertificateCompressionAlgorithm compressionAlgorithm; - CompressionAlgorithm(OpenSslEngineMap engineMap, OpenSslCertificateCompressionAlgorithm compressionAlgorithm) { - this.engineMap = engineMap; + CompressionAlgorithm(OpenSslEngineMap engines, + OpenSslCertificateCompressionAlgorithm compressionAlgorithm) { + this.engines = engines; this.compressionAlgorithm = compressionAlgorithm; } @Override public byte[] compress(long ssl, byte[] bytes) throws Exception { - ReferenceCountedOpenSslEngine engine = retrieveEngine(engineMap, ssl); + ReferenceCountedOpenSslEngine engine = retrieveEngine(engines, ssl); return compressionAlgorithm.compress(engine, bytes); } @Override public byte[] decompress(long ssl, int len, byte[] bytes) throws Exception { - ReferenceCountedOpenSslEngine engine = retrieveEngine(engineMap, ssl); + ReferenceCountedOpenSslEngine engine = retrieveEngine(engines, ssl); return compressionAlgorithm.decompress(engine, len, bytes); } diff --git a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslEngine.java b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslEngine.java index faee3f098ab..4b7083a339e 100644 --- a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslEngine.java +++ b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslEngine.java @@ -201,7 +201,7 @@ protected void deallocate() { final boolean jdkCompatibilityMode; private final boolean clientMode; final ByteBufAllocator alloc; - private final OpenSslEngineMap engineMap; + private final OpenSslEngineMap engines; private final OpenSslApplicationProtocolNegotiator apn; private final ReferenceCountedOpenSslContext parentContext; private final OpenSslInternalSession session; @@ -229,7 +229,7 @@ protected void deallocate() { String endpointIdentificationAlgorithm) { super(peerHost, peerPort); OpenSsl.ensureAvailability(); - engineMap = context.engineMap; + engines = context.engines; enableOcsp = context.enableOcsp; this.jdkCompatibilityMode = jdkCompatibilityMode; this.alloc = checkNotNull(alloc, "alloc"); @@ -415,6 +415,9 @@ public List getStatusResponses() { } parentContext = context; + // Register for the SSL* -> engine reverse lookup used by native callbacks; held weakly (see OpenSslEngineMap). + engines.add(ssl, this); + // Only create the leak after everything else was executed and so ensure we don't produce a false-positive for // the ResourceLeakDetector. leak = leakDetection ? leakDetector.track(this) : null; @@ -574,11 +577,11 @@ public final synchronized long sslPointer() { public final synchronized void shutdown() { if (!destroyed) { destroyed = true; - // Let's check if engineMap is null as it could be in theory if we throw an OOME during the construction of + // Let's check if engines is null as it could be in theory if we throw an OOME during the construction of // ReferenceCountedOpenSslEngine (before we assign the field). This is needed as shutdown() is called from // the finalizer as well. - if (engineMap != null) { - engineMap.remove(ssl); + if (engines != null) { + engines.remove(ssl); } SSL.freeSSL(ssl); ssl = networkBIO = 0; @@ -1974,9 +1977,6 @@ private SSLEngineResult.HandshakeStatus handshake() throws SSLException { return handshakeException(); } - // Adding the OpenSslEngine to the OpenSslEngineMap so it can be used in the AbstractCertificateVerifier. - engineMap.add(this); - if (!sessionSet) { if (!parentContext.sessionContext().setSessionFromCache(ssl, session, getPeerHost(), getPeerPort())) { // The session was not reused via the cache. Call prepareHandshake() to ensure we remove all previous diff --git a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslServerContext.java b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslServerContext.java index bc09a86d1b8..e2ce46228ce 100644 --- a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslServerContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslServerContext.java @@ -78,7 +78,7 @@ cipherFilter, toNegotiator(apn), sessionCacheSize, sessionTimeout, clientAuth, p // Create a new SSL_CTX and configure it. boolean success = false; try { - sessionContext = newSessionContext(this, ctx, engineMap, trustCertCollection, trustManagerFactory, + sessionContext = newSessionContext(this, ctx, engines, trustCertCollection, trustManagerFactory, keyCertChain, key, keyPassword, keyManagerFactory, keyStore, sessionCacheSize, sessionTimeout, resumptionController); if (SERVER_ENABLE_SESSION_TICKET) { @@ -98,7 +98,7 @@ public OpenSslServerSessionContext sessionContext() { } static OpenSslServerSessionContext newSessionContext(ReferenceCountedOpenSslContext thiz, long ctx, - OpenSslEngineMap engineMap, + OpenSslEngineMap engines, X509Certificate[] trustCertCollection, TrustManagerFactory trustManagerFactory, X509Certificate[] keyCertChain, PrivateKey key, @@ -135,7 +135,7 @@ static OpenSslServerSessionContext newSessionContext(ReferenceCountedOpenSslCont keyMaterialProvider = providerFor(keyManagerFactory, keyPassword); SSLContext.setCertificateCallback(ctx, new OpenSslServerCertificateCallback( - engineMap, new OpenSslKeyMaterialManager(keyMaterialProvider, thiz.hasTmpDhKeys))); + engines, new OpenSslKeyMaterialManager(keyMaterialProvider, thiz.hasTmpDhKeys))); } } catch (Exception e) { throw new SSLException("failed to set certificate and key", e); @@ -159,7 +159,7 @@ static OpenSslServerSessionContext newSessionContext(ReferenceCountedOpenSslCont // // See https://github.com/netty/netty/issues/5372 - setVerifyCallback(ctx, engineMap, manager); + setVerifyCallback(ctx, engines, manager); X509Certificate[] issuers = manager.getAcceptedIssuers(); if (issuers != null && issuers.length > 0) { @@ -184,7 +184,7 @@ static OpenSslServerSessionContext newSessionContext(ReferenceCountedOpenSslCont // IMPORTANT: The callbacks set for hostname matching must be static to prevent memory leak as // otherwise the context can never be collected. This is because the JNI code holds // a global reference to the matcher. - SSLContext.setSniHostnameMatcher(ctx, new OpenSslSniHostnameMatcher(engineMap)); + SSLContext.setSniHostnameMatcher(ctx, new OpenSslSniHostnameMatcher(engines)); } } catch (SSLException e) { throw e; @@ -214,28 +214,29 @@ static OpenSslServerSessionContext newSessionContext(ReferenceCountedOpenSslCont } @SuppressJava6Requirement(reason = "Guarded by java version check") - private static void setVerifyCallback(long ctx, OpenSslEngineMap engineMap, X509TrustManager manager) { + private static void setVerifyCallback(long ctx, OpenSslEngineMap engines, X509TrustManager manager) { // Use this to prevent an error when running on java < 7 if (useExtendedTrustManager(manager)) { SSLContext.setCertVerifyCallback(ctx, new ExtendedTrustManagerVerifyCallback( - engineMap, (X509ExtendedTrustManager) manager)); + engines, (X509ExtendedTrustManager) manager)); } else { - SSLContext.setCertVerifyCallback(ctx, new TrustManagerVerifyCallback(engineMap, manager)); + SSLContext.setCertVerifyCallback(ctx, new TrustManagerVerifyCallback(engines, manager)); } } private static final class OpenSslServerCertificateCallback implements CertificateCallback { - private final OpenSslEngineMap engineMap; + private final OpenSslEngineMap engines; private final OpenSslKeyMaterialManager keyManagerHolder; - OpenSslServerCertificateCallback(OpenSslEngineMap engineMap, OpenSslKeyMaterialManager keyManagerHolder) { - this.engineMap = engineMap; + OpenSslServerCertificateCallback(OpenSslEngineMap engines, + OpenSslKeyMaterialManager keyManagerHolder) { + this.engines = engines; this.keyManagerHolder = keyManagerHolder; } @Override public void handle(long ssl, byte[] keyTypeBytes, byte[][] asn1DerEncodedPrincipals) throws Exception { - final ReferenceCountedOpenSslEngine engine = engineMap.get(ssl); + final ReferenceCountedOpenSslEngine engine = engines.get(ssl); if (engine == null) { // Maybe null if destroyed in the meantime. return; @@ -258,8 +259,9 @@ public void handle(long ssl, byte[] keyTypeBytes, byte[][] asn1DerEncodedPrincip private static final class TrustManagerVerifyCallback extends AbstractCertificateVerifier { private final X509TrustManager manager; - TrustManagerVerifyCallback(OpenSslEngineMap engineMap, X509TrustManager manager) { - super(engineMap); + TrustManagerVerifyCallback(OpenSslEngineMap engines, + X509TrustManager manager) { + super(engines); this.manager = manager; } @@ -274,8 +276,9 @@ void verify(ReferenceCountedOpenSslEngine engine, X509Certificate[] peerCerts, S private static final class ExtendedTrustManagerVerifyCallback extends AbstractCertificateVerifier { private final X509ExtendedTrustManager manager; - ExtendedTrustManagerVerifyCallback(OpenSslEngineMap engineMap, X509ExtendedTrustManager manager) { - super(engineMap); + ExtendedTrustManagerVerifyCallback(OpenSslEngineMap engines, + X509ExtendedTrustManager manager) { + super(engines); this.manager = manager; } @@ -287,15 +290,15 @@ void verify(ReferenceCountedOpenSslEngine engine, X509Certificate[] peerCerts, S } private static final class OpenSslSniHostnameMatcher implements SniHostNameMatcher { - private final OpenSslEngineMap engineMap; + private final OpenSslEngineMap engines; - OpenSslSniHostnameMatcher(OpenSslEngineMap engineMap) { - this.engineMap = engineMap; + OpenSslSniHostnameMatcher(OpenSslEngineMap engines) { + this.engines = engines; } @Override public boolean match(long ssl, String hostname) { - ReferenceCountedOpenSslEngine engine = engineMap.get(ssl); + ReferenceCountedOpenSslEngine engine = engines.get(ssl); if (engine != null) { // TODO: In the next release of tcnative we should pass the byte[] directly in and not use a String. return engine.checkSniHostnameMatch(hostname.getBytes(CharsetUtil.UTF_8)); diff --git a/handler/src/test/java/io/netty/handler/ssl/OpenSslEngineTest.java b/handler/src/test/java/io/netty/handler/ssl/OpenSslEngineTest.java index 59b68fb7813..6f009560719 100644 --- a/handler/src/test/java/io/netty/handler/ssl/OpenSslEngineTest.java +++ b/handler/src/test/java/io/netty/handler/ssl/OpenSslEngineTest.java @@ -24,12 +24,14 @@ import io.netty.handler.ssl.util.SelfSignedCertificate; import io.netty.internal.tcnative.SSL; import io.netty.util.CharsetUtil; +import io.netty.util.ReferenceCountUtil; import io.netty.util.internal.EmptyArrays; import io.netty.util.internal.PlatformDependent; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.condition.DisabledIf; import org.junit.jupiter.api.condition.EnabledIf; import org.junit.jupiter.api.function.Executable; @@ -50,6 +52,7 @@ import java.util.Arrays; import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; @@ -1484,6 +1487,39 @@ public void execute() throws Throwable { } } + // Pins OPENSSL (not sslClientProvider()) because only the OPENSSL engine has a finalizer to drive the reclaim + // asserted here; the OPENSSL_REFCNT subclass overrides this to a no-op (it has no finalizer). Verifies that a + // leaked engine is collected and releases its parent context while the context is still alive -- only possible + // because OpenSslEngineMap holds engines weakly rather than pinning them for the context's lifetime. + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + public void leakedEngineIsReclaimedWhileContextAlive() throws Exception { + assumeTrue(OpenSsl.isAvailable()); + + SslContext ctx = SslContextBuilder.forClient() + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .sslProvider(OPENSSL) + .build(); + try { + // newEngine retains the context, so its refCnt goes from 1 to 2. + SSLEngine engine = ctx.newEngine(UnpooledByteBufAllocator.DEFAULT); + assertEquals(2, ReferenceCountUtil.refCnt(ctx)); + + // Drop the engine without releasing it, simulating a leak (e.g. handlerRemoved0 never firing). + engine = null; + + // Collection triggers OpenSslEngine.finalize(), which releases the context (2 -> 1). + while (ReferenceCountUtil.refCnt(ctx) != 1) { + System.gc(); + System.runFinalization(); + Thread.sleep(50); + } + assertEquals(1, ReferenceCountUtil.refCnt(ctx)); + } finally { + ReferenceCountUtil.release(ctx); + } + } + @Override protected SslProvider sslClientProvider() { return OPENSSL; diff --git a/handler/src/test/java/io/netty/handler/ssl/ReferenceCountedOpenSslEngineTest.java b/handler/src/test/java/io/netty/handler/ssl/ReferenceCountedOpenSslEngineTest.java index 8ee594c5e6c..a47172dd393 100644 --- a/handler/src/test/java/io/netty/handler/ssl/ReferenceCountedOpenSslEngineTest.java +++ b/handler/src/test/java/io/netty/handler/ssl/ReferenceCountedOpenSslEngineTest.java @@ -59,6 +59,13 @@ protected void cleanupServerSslEngine(SSLEngine engine) { ReferenceCountUtil.release(unwrapEngine(engine)); } + // OPENSSL_REFCNT has no finalizer, so the superclass's OPENSSL-only reclaim test does not apply here. + // Overriding it without @Test disables it for this subclass. + @Override + public void leakedEngineIsReclaimedWhileContextAlive() { + // noop + } + @MethodSource("newTestParams") @ParameterizedTest public void testNotLeakOnException(SSLEngineTestParam param) throws Exception { From 576765b224e2bd6ce8034dff9051e2f1601fbfd5 Mon Sep 17 00:00:00 2001 From: Netty Project Bot <78738768+netty-project-bot@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:49:28 +0200 Subject: [PATCH 59/64] Auto-port 4.1: Add `.editorconfig` to enforce consistent coding style (#17209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-port of #17052 to 4.1 Cherry-picked commit: a96226cb54e87e963e1e341cd7121f2217fc7c2e --- ## Motivation The Netty project has well-established coding conventions defined in its checkstyle configuration, but these are only enforced at build time and require specific tooling setup. Without an `.editorconfig` file, each IDE and editor must be configured individually to match the project's style, leading to inconsistent formatting, unnecessary whitespace changes in commits, and friction for contributors. `.editorconfig` is auto-detected by IntelliJ IDEA, VS Code, and many other editors — no plugin or configuration required. This lowers the barrier for new contributors and ensures consistent style out of the box. ## Modification Added `.editorconfig` at the project root with settings derived from the project's existing conventions, verified against actual source files and the canonical checkstyle configuration at [`netty-build/common/src/main/resources/io/netty/checkstyle.xml`](https://github.com/netty/netty-build/blob/master/common/src/main/resources/io/netty/checkstyle.xml) — `LineLength = 120`, `FileTabCharacter` (tab prohibition), `NewlineCheck` (LF), `NewlineAtEndOfFile`, trailing whitespace prohibition, UTF-8 charset: | File type | Indent | Details | |-----------|--------|---------| | `*.java` | 4 spaces | 120-char max line width | | `*.c`, `*.h` | 4 spaces | Native transport code | | `*.xml` | 4 spaces | POM and config files | | `*.{yml,yaml,json,js,css}` | 2 spaces | Build configs, web assets | | `*.{md,txt}` | 2 spaces | Documentation | | `*.properties` | 2 spaces | Maven wrapper, native-image config | | `*.sh` | 4 spaces | Build and CI scripts | | `Makefile` | tab | Required by GNU make | Global defaults for all files: UTF-8 encoding, LF line endings, final newline at end of file, no trailing whitespace. ## Result This is a formatting-only convention change with no behavioral impact. It ensures every editor shows code with the correct style automatically and provides the necessary foundation for automatic code formatters to enforce consistent style in CI pipelines. Addresses [netty#15642](https://github.com/netty/netty/discussions/15642). Co-authored-by: Vasily Pelikh Co-authored-by: Norman Maurer --- .editorconfig | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..066cd463c7d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,42 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.java] +indent_style = space +indent_size = 4 +max_line_length = 120 + +[*.c] +indent_style = space +indent_size = 4 + +[*.h] +indent_style = space +indent_size = 4 + +[*.xml] +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json,js,css}] +indent_style = space +indent_size = 2 + +[*.{md,txt}] +indent_style = space +indent_size = 2 + +[*.properties] +indent_style = space +indent_size = 2 + +[*.sh] +indent_style = space +indent_size = 4 +[Makefile] +indent_style = tab From cba44200b8f3502b5eab239e2d8089fecabfee9b Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Tue, 4 Aug 2026 12:26:55 -0700 Subject: [PATCH 60/64] Update surefire plugin to latest version (#17210) (#17212) Motivation: We used an outdated version Modifications: Update to latest release Result: Use latest release --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 80fcd6f9ba0..4d0790ab89e 100644 --- a/pom.xml +++ b/pom.xml @@ -2213,7 +2213,7 @@ maven-surefire-plugin - 3.5.3 + 3.5.6 From c60221988538437d28c7ec47b205f4612231dd6e Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Wed, 5 Aug 2026 21:55:30 -0700 Subject: [PATCH 61/64] Update to latest netty-tcnative release (#17056) (#17122) Motivation: We released a new version of tcnative. Modifications: Update to latest version Result: Use latest tcnative version --------- Co-authored-by: Chris Vest (cherry picked from commit 4670762b6991151b5dc5dd7f24d01096d65bf237) --------- Co-authored-by: Norman Maurer --- bom/pom.xml | 2 +- docker/Dockerfile.centos6 | 43 ++++++++++++++++++- .../java/io/netty/handler/ssl/OpenSsl.java | 2 +- .../io/netty/handler/ssl/SSLEngineTest.java | 33 ++++++++++---- pom.xml | 8 ++-- 5 files changed, 71 insertions(+), 17 deletions(-) diff --git a/bom/pom.xml b/bom/pom.xml index 5e85cfc1bc7..2ab23573b2c 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -73,7 +73,7 @@ - 2.0.78.Final + 2.0.81.Final diff --git a/docker/Dockerfile.centos6 b/docker/Dockerfile.centos6 index 8c2e1bc64bc..c73a222a024 100644 --- a/docker/Dockerfile.centos6 +++ b/docker/Dockerfile.centos6 @@ -1,7 +1,12 @@ FROM --platform=linux/amd64 centos:6.10 +ARG openssl_version=3.6.1 +ARG openssl_sha256=b1bfedcd5b289ff22aee87c9d600f515767ebf45f77168cb6d64f231f518a82e +ENV OPENSSL_VERSION $openssl_version +ENV OPENSSL_SHA256 $openssl_sha256 + # Update as we need to use the vault now. -RUN sed -i -e 's/^mirrorlist/#mirrorlist/g' -e 's/^#baseurl=http:\/\/mirror.centos.org\/centos\/$releasever\//baseurl=https:\/\/linuxsoft.cern.ch\/centos-vault\/\/6.10\//g' /etc/yum.repos.d/CentOS-Base.repo +RUN sed -i -e 's/^mirrorlist/#mirrorlist/g' -e 's/^#baseurl=http:\/\/mirror.centos.org\/centos\/$releasever\//baseurl=https:\/\/linuxsoft.cern.ch\/centos-vault\/6.10\//g' /etc/yum.repos.d/CentOS-Base.repo # install dependencies RUN yum install -y \ @@ -18,8 +23,37 @@ RUN yum install -y \ tar \ unzip \ wget \ - zip + zip \ + zlib-devel \ + perl \ + perl-IPC-Cmd \ + perl-Time-Piece \ + perl-parent \ + perl-devel \ + centos-release-SCL + +# Update the SCL repo as well. +RUN sed -i -e 's/^mirrorlist/#mirrorlist/g' \ + -e 's/^#baseurl=http:\/\/mirror.centos.org\/centos\/6\//baseurl=https:\/\/linuxsoft.cern.ch\/centos-vault\/6.10\//g' \ + /etc/yum.repos.d/CentOS-SCLo-scl-rh.repo + +RUN yum -y install devtoolset-9-gcc devtoolset-9-gcc-c++ +RUN echo 'source /opt/rh/devtoolset-9/enable' >> ~/.bashrc +# Build OpenSSL 3.x from source using devtoolset-9 +RUN set -x && \ + source /opt/rh/devtoolset-9/enable && \ + # --no-check-certificate: CentOS 6 ships with outdated CA bundles that can't verify modern GitHub TLS certs + wget --no-check-certificate https://github.com/openssl/openssl/releases/download/openssl-$OPENSSL_VERSION/openssl-$OPENSSL_VERSION.tar.gz && \ + echo "$OPENSSL_SHA256 openssl-$OPENSSL_VERSION.tar.gz" | sha256sum -c - && \ + tar xvf openssl-$OPENSSL_VERSION.tar.gz && \ + (cd openssl-$OPENSSL_VERSION && \ + # no-asm: devtoolset-9 on CentOS 6 cannot reliably compile OpenSSL's hand-tuned x86_64 assembly + ./Configure linux-x86_64 --prefix=/opt/openssl-$OPENSSL_VERSION --libdir=lib shared no-asm no-apps && \ + make -j1 install_sw) && \ + rm -rf openssl-$OPENSSL_VERSION openssl-$OPENSSL_VERSION.tar.gz + +RUN echo 'export LD_LIBRARY_PATH=/opt/openssl-$OPENSSL_VERSION/lib' >> ~/.bashrc # Downloading and installing SDKMAN! RUN curl -s "https://get.sdkman.io?ci=true" | bash @@ -36,6 +70,11 @@ RUN bash -c "source $HOME/.sdkman/bin/sdkman-init.sh && \ RUN echo 'export JAVA_HOME="/root/.sdkman/candidates/java/current"' >> ~/.bashrc RUN echo 'PATH=/jdk/bin:$PATH' >> ~/.bashrc +# Prepare our own build +ENV PATH=/root/.sdkman/candidates/maven/current:$PATH +ENV JAVA_HOME=/root/.sdkman/candidates/java/current +ENV LD_LIBRARY_PATH=/opt/openssl-$OPENSSL_VERSION/lib + # Cleanup RUN yum clean all && \ rm -rf /var/cache/yum diff --git a/handler/src/main/java/io/netty/handler/ssl/OpenSsl.java b/handler/src/main/java/io/netty/handler/ssl/OpenSsl.java index af4bcf6779a..6edf4c4964b 100644 --- a/handler/src/main/java/io/netty/handler/ssl/OpenSsl.java +++ b/handler/src/main/java/io/netty/handler/ssl/OpenSsl.java @@ -517,7 +517,7 @@ static X509Certificate selfSignedCertificate() throws CertificateException { private static boolean doesSupportOcsp() { boolean supportsOcsp = false; - if (version() >= 0x10002000L) { + if (isBoringSSL() || isAWSLC()) { long sslCtx = -1; try { sslCtx = SSLContext.make(SSL.SSL_PROTOCOL_TLSV1_2, SSL.SSL_MODE_SERVER); diff --git a/handler/src/test/java/io/netty/handler/ssl/SSLEngineTest.java b/handler/src/test/java/io/netty/handler/ssl/SSLEngineTest.java index 2bb66673a48..878af8e4161 100644 --- a/handler/src/test/java/io/netty/handler/ssl/SSLEngineTest.java +++ b/handler/src/test/java/io/netty/handler/ssl/SSLEngineTest.java @@ -1321,20 +1321,35 @@ private static void verifyApplicationLevelProtocol(Channel channel, String expec private static void writeAndVerifyReceived(ByteBuf message, Channel sendChannel, CountDownLatch receiverLatch, MessageReceiver receiver) throws Exception { - List dataCapture = null; + ByteBuf buf = null; try { - assertTrue(sendChannel.writeAndFlush(message).await(10, TimeUnit.SECONDS)); - receiverLatch.await(5, TimeUnit.SECONDS); - message.resetReaderIndex(); + assertTrue(sendChannel.writeAndFlush(message.duplicate()) + .await(10, TimeUnit.SECONDS)); + receiverLatch.await(10, TimeUnit.SECONDS); assertFalse(receiver.messages.isEmpty()); - dataCapture = new ArrayList(); - receiver.messages.drainTo(dataCapture); - assertEquals(message, dataCapture.get(0)); + + buf = Unpooled.buffer(); + ByteBuf buffer = receiver.messages.take(); + for (;;) { + buf.writeBytes(buffer); + buffer.release(); + if (buf.readableBytes() < message.readableBytes()) { + buffer = receiver.messages.poll(1, TimeUnit.SECONDS); + if (buffer == null) { + break; + } + } else { + break; + } + } + assertEquals(message, buf); } finally { - if (dataCapture != null) { - for (ByteBuf data : dataCapture) { + if (buf != null) { + buf.release(); + for (ByteBuf data : receiver.messages) { data.release(); } + receiver.messages.clear(); } } } diff --git a/pom.xml b/pom.xml index 4d0790ab89e..8d2bf80fef1 100644 --- a/pom.xml +++ b/pom.xml @@ -203,7 +203,7 @@ - ${argLine.java9.extras} + ${argLine.java9.extras} -Djdk.tls.SunX509KeyManager.certChecking=false true 2.0.5.Final @@ -680,7 +680,7 @@ boringssl-snapshot netty-tcnative-boringssl-static - 2.0.79.Final-SNAPSHOT + 2.0.82.Final-SNAPSHOT ${os.detected.classifier} @@ -823,7 +823,7 @@ -XX:+PrintGCDetails - -D_ + -Djdk.tls.ephemeralDHKeySize=2048 1.7.1 @@ -831,7 +831,7 @@ fedora,suse,arch netty-tcnative - 2.0.78.Final + 2.0.81.Final ${os.detected.classifier} org.conscrypt conscrypt-openjdk-uber From 9e0519239108a69b7e9bbc5e9182ee139a0d7961 Mon Sep 17 00:00:00 2001 From: Norman Maurer Date: Thu, 6 Aug 2026 00:29:45 -0700 Subject: [PATCH 62/64] Merge changes from forks (#17213) (#17217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [CORS: Don't override vary header if it already exists](https://github.com/netty/netty/commit/1a896ebe1eeeda8408e026b8059347eb65fc9157) Motivation: Netty's CorsHandler silently overwrites existing Vary headers, enabling cache poisoning and sensitive information disclosure. Modifications: - Only set vary header if it not already exists - Add unit test Result: No more cache poisoning possible --- [Encoding-side validation of MQTT fields](https://github.com/netty/netty/commit/99755538744cee063b4e4dc18aa2e4eaa002c860) Motivation: The MqttEncoder should not produce malformed messages if client id, topics, or usernames contain illegal characters. Modification: Add validation of client identifier, will topic, and username to the encoding path of CONNECT messages, and also to the topic name when encoding PUBLISH messages. Result: The encoder will now throw an exception if any of these fields contain a NUL byte. --- [Bound SCTP fragmented message buffering](https://github.com/netty/netty/commit/c07b97c693674d720b2b881a2b0052bc916f0b5d) --- [Correctly handle ClientHello with a handshake header split across TLS…](https://github.com/netty/netty/commit/a8f53f2a1486b8eb16862a818a606e9f3c308045) … records Motivation: The handshake header (HandshakeType + 3-byte length = 4 bytes) may be delivered across multiple TLS records. SslClientHelloHandler assumed these 4 bytes were always contained in the first record and read them directly from it, which is incorrect when the header spans records. Modification: - Read the handshake header directly from the record only when the full 4 bytes are contained in it. - Otherwise aggregate the record payloads into handshakeBuffer and read the handshakeType and handshakeLength from the buffer once at least 4 bytes are available. - Aggregate the handshake header together with the body and slice past the 4-byte header once the full ClientHello has been buffered. - Add SniHandlerTest cases for fragment sizes 1-4. Result: ClientHello messages whose handshake header is fragmented across multiple TLS records are parsed correctly. --- [Harden the SOCKS4/5 input validation at encoding time](https://github.com/netty/netty/commit/46254065878fed061d8619b90cc871593cfc6bcf) Motivation: SOCKS4 is a delimiter-based protocol, and does not support NUL bytes in string or byte-sequence fields. SOCKS5 is a Tag-Length-Value protocol, and does not support lengths greater than 255. Modification: Add validations for NUL bytes and lengths for SOCKS4 and 5, respectively, and ensure both client- and server-side encoders behave correctly. Add tests to verify the correct handling of boundary conditions. Result: Correct delimiter and length handling in the SOCKS4/5 encoders, even when integrators use custom implementations of the message types. --- [Do not re-aggregate the ClientHello on every received TLS record](https://github.com/netty/netty/commit/bb741549f31c878cdd492eda6c0eccb3e02a978c) --- [Validate trust manager configuration](https://github.com/netty/netty/commit/5f1888e6384ddcdd95ecae4f921e3c8fd61612fa) Motivation: Certain trust manager configurations were not being validated against the configured endpoint identification algorithm. Modification: Add a check that fails fast when the configured trust manager does not support the required verification mode. Result: Misconfigured trust manager setups are now rejected explicitly instead of failing silently. --------- Co-authored-by: Norman Maurer Co-authored-by: yawkat Co-authored-by: multicode Co-authored-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --------- Co-authored-by: Chris Vest Co-authored-by: yawkat Co-authored-by: multicode Co-authored-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../handler/codec/http/cors/CorsHandler.java | 4 +- .../codec/http/cors/CorsHandlerTest.java | 27 ++ .../handler/codec/mqtt/MqttCodecUtil.java | 32 ++- .../netty/handler/codec/mqtt/MqttDecoder.java | 4 +- .../netty/handler/codec/mqtt/MqttEncoder.java | 15 +- .../mqtt/MqttIdentifierRejectedException.java | 3 +- .../handler/codec/mqtt/MqttCodecTest.java | 79 ++++++ .../codec/socksx/v4/Socks4ClientEncoder.java | 19 +- .../codec/socksx/v5/Socks5ClientEncoder.java | 23 +- .../codec/socksx/v5/Socks5ServerEncoder.java | 12 +- .../socksx/v4/Socks4ClientEncoderTest.java | 99 +++++++ .../socksx/v5/Socks5ClientEncoderTest.java | 243 ++++++++++++++++++ .../socksx/v5/Socks5CommonTestUtils.java | 19 ++ .../socksx/v5/Socks5ServerEncoderTest.java | 144 +++++++++++ .../ReferenceCountedOpenSslClientContext.java | 8 + .../handler/ssl/SslClientHelloHandler.java | 100 ++++--- .../io/netty/handler/ssl/SniHandlerTest.java | 91 ++++++- .../sctp/SctpMessageCompletionHandler.java | 35 +++ .../SctpMessageCompletionHandlerTest.java | 56 ++++ 19 files changed, 950 insertions(+), 63 deletions(-) create mode 100644 codec-socks/src/test/java/io/netty/handler/codec/socksx/v4/Socks4ClientEncoderTest.java create mode 100644 codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5ClientEncoderTest.java create mode 100644 codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5ServerEncoderTest.java diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java index 6972647a071..d141b426ed8 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java @@ -193,7 +193,9 @@ private void echoRequestOrigin(final HttpResponse response) { } private static void setVaryHeader(final HttpResponse response) { - response.headers().set(HttpHeaderNames.VARY, HttpHeaderNames.ORIGIN); + if (!response.headers().containsValue(HttpHeaderNames.VARY, HttpHeaderNames.ORIGIN, true)) { + response.headers().add(HttpHeaderNames.VARY, HttpHeaderNames.ORIGIN); + } } private static void setAnyOrigin(final HttpResponse response) { diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java index dc0d962b6a8..fbc4f8b5497 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java @@ -25,6 +25,7 @@ import io.netty.handler.codec.http.FullHttpRequest; import io.netty.handler.codec.http.DefaultHttpHeadersFactory; import io.netty.handler.codec.http.DefaultHttpContent; +import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.LastHttpContent; import io.netty.handler.codec.http.HttpMethod; @@ -683,6 +684,32 @@ public void shortCircuitWithNullOriginAllowedShouldSucceed() { assertTrue(ReferenceCountUtil.release(response)); } + @Test + public void varyHeaderIsAppended() { + CorsConfig config = forAnyOrigin().allowCredentials().build(); + EmbeddedChannel channel = new EmbeddedChannel(new CorsHandler(config), + new SimpleChannelInboundHandler() { + @Override + protected void channelRead0(ChannelHandlerContext ctx, Object msg) { + HttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK, Unpooled.buffer(0)); + response.headers().set(VARY, ACCEPT_ENCODING); + ctx.writeAndFlush(response); + } + }); + FullHttpRequest request = createHttpRequest(GET); + request.headers().set(ORIGIN, "http://localhost:7777"); + assertFalse(channel.writeInbound(request)); + + FullHttpResponse response = channel.readOutbound(); + + List varyHeaders = response.headers().getAll(VARY); + assertThat(varyHeaders).contains(ACCEPT_ENCODING.toString()); + assertThat(varyHeaders).contains(ORIGIN.toString()); + + response.release(); + assertFalse(channel.finish()); + } + private static HttpResponse simpleRequest(final CorsConfig config, final String origin) { return simpleRequest(config, origin, null); } diff --git a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttCodecUtil.java b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttCodecUtil.java index 788b6a41853..681182185f1 100644 --- a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttCodecUtil.java +++ b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttCodecUtil.java @@ -44,9 +44,13 @@ static void setMqttVersion(ChannelHandlerContext ctx, MqttVersion version) { } static boolean isValidPublishTopicName(String topicName) { + if (topicName == null) { + return false; + } // publish topic name must not contain any wildcard - for (char c : TOPIC_WILDCARDS) { - if (topicName.indexOf(c) >= 0) { + for (int i = 0; i < topicName.length(); i++) { + char c = topicName.charAt(i); + if (c == '#' || c == '+' || c == '\0') { return false; } } @@ -57,15 +61,31 @@ static boolean isValidMessageId(int messageId) { return messageId != 0; } - static boolean isValidClientId(MqttVersion mqttVersion, int maxClientIdLength, String clientId) { + static boolean isValidUserName(String userName) { + return userName == null || userName.indexOf('\0') == -1; + } + + /** + * Determine if a client identifier is valid. + * @param mqttVersion The MQTT version semantics to use. + * @param maxClientIdLength The max client id length. + * @param clientId The client id value. + * @param acceptNulBytes MQTT normally does not allow NUL bytes in client identifiers. + * Set this to {@code true} to enable "legacy"/"lenient" mode, otherwise {@code false} for strict spec compliance. + * @return {@code true} if the client id is valid, otherwise {@code false}. + */ + static boolean isValidClientId(MqttVersion mqttVersion, int maxClientIdLength, String clientId, + boolean acceptNulBytes) { + if (clientId == null || (!acceptNulBytes && clientId.indexOf('\0') != -1)) { + return false; + } if (mqttVersion == MqttVersion.MQTT_3_1) { - return clientId != null && clientId.length() >= MIN_CLIENT_ID_LENGTH && - clientId.length() <= maxClientIdLength; + return clientId.length() >= MIN_CLIENT_ID_LENGTH && clientId.length() <= maxClientIdLength; } if (mqttVersion == MqttVersion.MQTT_3_1_1 || mqttVersion == MqttVersion.MQTT_5) { // In 3.1.3.1 Client Identifier of MQTT 3.1.1 and 5.0 specifications, The Server MAY allow ClientId’s // that contain more than 23 encoded bytes. And, The Server MAY allow zero-length ClientId. - return clientId != null; + return true; } throw new IllegalArgumentException(mqttVersion + " is unknown mqtt version"); } diff --git a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java index 8612a15de9d..1cadabf2e33 100644 --- a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java +++ b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttDecoder.java @@ -552,8 +552,8 @@ private MqttConnectPayload decodeConnectionPayload( final String decodedClientIdValue = decodedClientId.value; final MqttVersion mqttVersion = MqttVersion.fromProtocolNameAndLevel(mqttConnectVariableHeader.name(), (byte) mqttConnectVariableHeader.version()); - if (!isValidClientId(mqttVersion, maxClientIdLength, decodedClientIdValue)) { - throw new MqttIdentifierRejectedException("invalid clientIdentifier: " + decodedClientIdValue); + if (!isValidClientId(mqttVersion, maxClientIdLength, decodedClientIdValue, !strictUtf8Validation)) { + throw new MqttIdentifierRejectedException("invalid clientIdentifier"); } int numberOfBytesConsumed = decodedClientId.numberOfBytesConsumed; diff --git a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttEncoder.java b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttEncoder.java index 89653403a97..8b7e6e5f36a 100644 --- a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttEncoder.java +++ b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttEncoder.java @@ -30,6 +30,8 @@ import static io.netty.buffer.ByteBufUtil.*; import static io.netty.handler.codec.mqtt.MqttCodecUtil.getMqttVersion; import static io.netty.handler.codec.mqtt.MqttCodecUtil.isValidClientId; +import static io.netty.handler.codec.mqtt.MqttCodecUtil.isValidPublishTopicName; +import static io.netty.handler.codec.mqtt.MqttCodecUtil.isValidUserName; import static io.netty.handler.codec.mqtt.MqttCodecUtil.setMqttVersion; import static io.netty.handler.codec.mqtt.MqttConstant.DEFAULT_MAX_CLIENT_ID_LENGTH; @@ -126,8 +128,8 @@ private static ByteBuf encodeConnectMessage( // Client id String clientIdentifier = payload.clientIdentifier(); - if (!isValidClientId(mqttVersion, DEFAULT_MAX_CLIENT_ID_LENGTH, clientIdentifier)) { - throw new MqttIdentifierRejectedException("invalid clientIdentifier: " + clientIdentifier); + if (!isValidClientId(mqttVersion, DEFAULT_MAX_CLIENT_ID_LENGTH, clientIdentifier, false)) { + throw new MqttIdentifierRejectedException("invalid clientIdentifier"); } int clientIdentifierBytes = utf8Bytes(clientIdentifier); payloadBufferSize += 2 + clientIdentifierBytes; @@ -138,6 +140,9 @@ private static ByteBuf encodeConnectMessage( byte[] willMessage = payload.willMessageInBytes(); byte[] willMessageBytes = willMessage != null ? willMessage : EmptyArrays.EMPTY_BYTES; if (variableHeader.isWillFlag()) { + if (!isValidPublishTopicName(willTopic)) { + throw new MqttIdentifierRejectedException("invalid willTopic"); + } payloadBufferSize += 2 + willTopicBytes; payloadBufferSize += 2 + willMessageBytes.length; } @@ -145,6 +150,9 @@ private static ByteBuf encodeConnectMessage( String userName = payload.userName(); int userNameBytes = nullableUtf8Bytes(userName); if (variableHeader.hasUserName()) { + if (!isValidUserName(userName)) { + throw new MqttIdentifierRejectedException("invalid userName"); + } payloadBufferSize += 2 + userNameBytes; } @@ -433,6 +441,9 @@ private static ByteBuf encodePublishMessage( ByteBuf payload = message.payload().duplicate(); String topicName = variableHeader.topicName(); + if (!isValidPublishTopicName(topicName)) { + throw new MqttIdentifierRejectedException("invalid topicName"); + } int topicNameBytes = utf8Bytes(topicName); ByteBuf propertiesBuf = encodePropertiesIfNeeded(mqttVersion, diff --git a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttIdentifierRejectedException.java b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttIdentifierRejectedException.java index 72639cdd7c2..eca843c1815 100644 --- a/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttIdentifierRejectedException.java +++ b/codec-mqtt/src/main/java/io/netty/handler/codec/mqtt/MqttIdentifierRejectedException.java @@ -18,7 +18,8 @@ import io.netty.handler.codec.DecoderException; /** - * A {@link MqttIdentifierRejectedException} which is thrown when a CONNECT request contains invalid client identifier. + * A {@link MqttIdentifierRejectedException} which is thrown when a CONNECT request contains invalid client identifier, + * will topic name, or username, or when a PUBLISH message contains an invalid topic name. */ public final class MqttIdentifierRejectedException extends DecoderException { diff --git a/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java b/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java index dd979bdfb19..2fdcdbbbc5a 100644 --- a/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java +++ b/codec-mqtt/src/test/java/io/netty/handler/codec/mqtt/MqttCodecTest.java @@ -34,6 +34,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.mockito.invocation.InvocationOnMock; @@ -1517,4 +1519,81 @@ public void legacyModeAcceptsEmbeddedNullCharacter() { ReferenceCountUtil.release(msg); } } + + @ParameterizedTest + @EnumSource(MqttVersion.class) + public void encodeConnectMessageWithNulInClientIdIsRejected(MqttVersion version) { + final MqttConnectMessage message = MqttMessageBuilders.connect() + .clientId("client\u0000id") + .protocolVersion(version) + .cleanSession(true) + .keepAlive(KEEP_ALIVE_SECONDS) + .build(); + assertThrows(MqttIdentifierRejectedException.class, new Executable() { + @Override + public void execute() throws Throwable { + MqttEncoder.doEncode(ctx, message); + } + }); + } + + @ParameterizedTest + @EnumSource(MqttVersion.class) + public void encodeConnectMessageWithNulInWillTopicIsRejected(MqttVersion version) { + final MqttConnectMessage message = MqttMessageBuilders.connect() + .clientId(CLIENT_ID) + .protocolVersion(version) + .willFlag(true) + .willQoS(MqttQoS.AT_LEAST_ONCE) + .willTopic("will\u0000topic") + .willMessage(WILL_MESSAGE.getBytes(CharsetUtil.UTF_8)) + .cleanSession(true) + .keepAlive(KEEP_ALIVE_SECONDS) + .build(); + assertThrows(MqttIdentifierRejectedException.class, new Executable() { + @Override + public void execute() throws Throwable { + MqttEncoder.doEncode(ctx, message); + } + }); + } + + @ParameterizedTest + @EnumSource(MqttVersion.class) + public void encodeConnectMessageWithNulInUserNameIsRejected(MqttVersion version) { + final MqttConnectMessage message = MqttMessageBuilders.connect() + .clientId(CLIENT_ID) + .protocolVersion(version) + .username("user\u0000name") + .password(PASSWORD_BYTES) + .cleanSession(true) + .keepAlive(KEEP_ALIVE_SECONDS) + .build(); + assertThrows(MqttIdentifierRejectedException.class, new Executable() { + @Override + public void execute() throws Throwable { + MqttEncoder.doEncode(ctx, message); + } + }); + } + + @Test + public void encodePublishMessageWithNulInTopicIsRejected() { + MqttFixedHeader fixedHeader = + new MqttFixedHeader(MqttMessageType.PUBLISH, false, MqttQoS.AT_LEAST_ONCE, false, 0); + MqttPublishVariableHeader variableHeader = new MqttPublishVariableHeader("home/\u0000/sensor", 1); + ByteBuf payload = ALLOCATOR.buffer(); + payload.writeBytes("data".getBytes(CharsetUtil.UTF_8)); + final MqttPublishMessage message = new MqttPublishMessage(fixedHeader, variableHeader, payload); + try { + assertThrows(MqttIdentifierRejectedException.class, new Executable() { + @Override + public void execute() throws Throwable { + MqttEncoder.doEncode(ctx, message); + } + }); + } finally { + payload.release(); + } + } } diff --git a/codec-socks/src/main/java/io/netty/handler/codec/socksx/v4/Socks4ClientEncoder.java b/codec-socks/src/main/java/io/netty/handler/codec/socksx/v4/Socks4ClientEncoder.java index 2989203cf81..06fe3ec51d3 100644 --- a/codec-socks/src/main/java/io/netty/handler/codec/socksx/v4/Socks4ClientEncoder.java +++ b/codec-socks/src/main/java/io/netty/handler/codec/socksx/v4/Socks4ClientEncoder.java @@ -20,7 +20,9 @@ import io.netty.buffer.ByteBufUtil; import io.netty.channel.ChannelHandler.Sharable; import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.EncoderException; import io.netty.handler.codec.MessageToByteEncoder; +import io.netty.util.AsciiString; import io.netty.util.NetUtil; /** @@ -45,14 +47,25 @@ protected void encode(ChannelHandlerContext ctx, Socks4CommandRequest msg, ByteB ByteBufUtil.writeShortBE(out, msg.dstPort()); if (NetUtil.isValidIpV4Address(msg.dstAddr())) { out.writeBytes(NetUtil.createByteArrayFromIpAddressString(msg.dstAddr())); - ByteBufUtil.writeAscii(out, msg.userId()); + ByteBufUtil.writeAscii(out, sanitize("userId", msg.userId())); out.writeByte(0); } else { out.writeBytes(IPv4_DOMAIN_MARKER); - ByteBufUtil.writeAscii(out, msg.userId()); + ByteBufUtil.writeAscii(out, sanitize("userId", msg.userId())); out.writeByte(0); - ByteBufUtil.writeAscii(out, msg.dstAddr()); + ByteBufUtil.writeAscii(out, sanitize("dstAddr", msg.dstAddr())); out.writeByte(0); } } + + private CharSequence sanitize(String fieldName, String strValue) { + for (int i = 0, len = strValue.length(); i < len; i++) { + char c = strValue.charAt(i); + // SOCKS4 uses NUL-bytes as field delimiters. + if (AsciiString.c2b(c) == 0) { + throw new EncoderException("Illegal character in " + fieldName + " field."); + } + } + return strValue; + } } diff --git a/codec-socks/src/main/java/io/netty/handler/codec/socksx/v5/Socks5ClientEncoder.java b/codec-socks/src/main/java/io/netty/handler/codec/socksx/v5/Socks5ClientEncoder.java index 8f47a14eecd..d02f0cb7dc1 100644 --- a/codec-socks/src/main/java/io/netty/handler/codec/socksx/v5/Socks5ClientEncoder.java +++ b/codec-socks/src/main/java/io/netty/handler/codec/socksx/v5/Socks5ClientEncoder.java @@ -77,7 +77,7 @@ private static void encodeAuthMethodRequest(Socks5InitialRequest msg, ByteBuf ou final List authMethods = msg.authMethods(); final int numAuthMethods = authMethods.size(); - out.writeByte(numAuthMethods); + writeFieldLength(out, numAuthMethods); if (authMethods instanceof RandomAccess) { for (int i = 0; i < numAuthMethods; i ++) { @@ -94,11 +94,11 @@ private static void encodePasswordAuthRequest(Socks5PasswordAuthRequest msg, Byt out.writeByte(0x01); final String username = msg.username(); - out.writeByte(username.length()); + writeFieldLength(out, username.length()); ByteBufUtil.writeAscii(out, username); final String password = msg.password(); - out.writeByte(password.length()); + writeFieldLength(out, password.length()); ByteBufUtil.writeAscii(out, password); } @@ -109,7 +109,22 @@ private void encodeCommandRequest(Socks5CommandRequest msg, ByteBuf out) throws final Socks5AddressType dstAddrType = msg.dstAddrType(); out.writeByte(dstAddrType.byteValue()); - addressEncoder.encodeAddress(dstAddrType, msg.dstAddr(), out); + String addrValue = msg.dstAddr(); + if (addrValue != null && dstAddrType == Socks5AddressType.DOMAIN) { + checkFieldLength(addrValue.length()); + } + addressEncoder.encodeAddress(dstAddrType, addrValue, out); ByteBufUtil.writeShortBE(out, msg.dstPort()); } + + private static void writeFieldLength(ByteBuf out, int length) { + checkFieldLength(length); + out.writeByte(length); + } + + private static void checkFieldLength(int length) { + if (length > 255 || length < 0) { + throw new EncoderException("Invalid field length value: " + length); + } + } } diff --git a/codec-socks/src/main/java/io/netty/handler/codec/socksx/v5/Socks5ServerEncoder.java b/codec-socks/src/main/java/io/netty/handler/codec/socksx/v5/Socks5ServerEncoder.java index 0edd425c933..335f1bd55d6 100644 --- a/codec-socks/src/main/java/io/netty/handler/codec/socksx/v5/Socks5ServerEncoder.java +++ b/codec-socks/src/main/java/io/netty/handler/codec/socksx/v5/Socks5ServerEncoder.java @@ -86,8 +86,18 @@ private void encodeCommandResponse(Socks5CommandResponse msg, ByteBuf out) throw final Socks5AddressType bndAddrType = msg.bndAddrType(); out.writeByte(bndAddrType.byteValue()); - addressEncoder.encodeAddress(bndAddrType, msg.bndAddr(), out); + String addrValue = msg.bndAddr(); + if (addrValue != null && bndAddrType == Socks5AddressType.DOMAIN) { + checkFieldLength(addrValue.length()); + } + addressEncoder.encodeAddress(bndAddrType, addrValue, out); ByteBufUtil.writeShortBE(out, msg.bndPort()); } + + private static void checkFieldLength(int length) { + if (length > 255 || length < 0) { + throw new EncoderException("Invalid field length value: " + length); + } + } } diff --git a/codec-socks/src/test/java/io/netty/handler/codec/socksx/v4/Socks4ClientEncoderTest.java b/codec-socks/src/test/java/io/netty/handler/codec/socksx/v4/Socks4ClientEncoderTest.java new file mode 100644 index 00000000000..a87d6f9b9b6 --- /dev/null +++ b/codec-socks/src/test/java/io/netty/handler/codec/socksx/v4/Socks4ClientEncoderTest.java @@ -0,0 +1,99 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project 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: + * + * https://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 io.netty.handler.codec.socksx.v4; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.EncoderException; +import org.assertj.core.api.ThrowableAssert; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Socks4ClientEncoderTest { + @Test + public void mustEncodeCommandRequestToIPv4() { + EmbeddedChannel encoder = new EmbeddedChannel(Socks4ClientEncoder.INSTANCE); + DefaultSocks4CommandRequest request = new DefaultSocks4CommandRequest( + Socks4CommandType.CONNECT, "127.0.0.1", 8008, "user"); + assertTrue(encoder.writeOutbound(request)); + ByteBuf buf = encoder.readOutbound(); + assertNotNull(buf); + buf.release(); + assertFalse(encoder.finish()); + } + @Test + public void mustEncodeCommandRequestToDomain() { + EmbeddedChannel encoder = new EmbeddedChannel(Socks4ClientEncoder.INSTANCE); + DefaultSocks4CommandRequest request = new DefaultSocks4CommandRequest( + Socks4CommandType.CONNECT, "unix://uds.sock", 8008, "user"); + assertTrue(encoder.writeOutbound(request)); + ByteBuf buf = encoder.readOutbound(); + assertNotNull(buf); + buf.release(); + assertFalse(encoder.finish()); + } + + @Test + public void mustRejectNulByteInUserIdWithIPv4Destination() { + final EmbeddedChannel encoder = new EmbeddedChannel(Socks4ClientEncoder.INSTANCE); + final DefaultSocks4CommandRequest request = new DefaultSocks4CommandRequest( + Socks4CommandType.CONNECT, "127.0.0.1", 8008, "use\0r"); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + encoder.writeOutbound(request); + } + }) + .isInstanceOf(EncoderException.class) + .hasMessageContaining("Illegal character"); + assertFalse(encoder.finish()); + } + + @Test + public void mustRejectNulByteInUserIdWithDomainDestination() { + final EmbeddedChannel encoder = new EmbeddedChannel(Socks4ClientEncoder.INSTANCE); + final DefaultSocks4CommandRequest request = new DefaultSocks4CommandRequest( + Socks4CommandType.CONNECT, "unix://uds.sock", 8008, "use\0r"); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + encoder.writeOutbound(request); + } + }).isInstanceOf(EncoderException.class) + .hasMessageContaining("Illegal character"); + assertFalse(encoder.finish()); + } + + @Test + public void mustRejectNulByteInDstAddr() { + final EmbeddedChannel encoder = new EmbeddedChannel(Socks4ClientEncoder.INSTANCE); + final DefaultSocks4CommandRequest request = new DefaultSocks4CommandRequest( + Socks4CommandType.CONNECT, "unix://uds\0.sock", 8008, "user"); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + encoder.writeOutbound(request); + } + }) + .isInstanceOf(EncoderException.class) + .hasMessageContaining("Illegal character"); + assertFalse(encoder.finish()); + } +} diff --git a/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5ClientEncoderTest.java b/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5ClientEncoderTest.java new file mode 100644 index 00000000000..7244601bc99 --- /dev/null +++ b/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5ClientEncoderTest.java @@ -0,0 +1,243 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project 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: + * + * https://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 io.netty.handler.codec.socksx.v5; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.DecoderResult; +import io.netty.handler.codec.EncoderException; +import io.netty.handler.codec.socksx.SocksVersion; +import org.assertj.core.api.ThrowableAssert; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Socks5ClientEncoderTest { + + @Test + public void initialRequestEncodingMustAcceptMaxNumberOfAuthMethods() { + EmbeddedChannel encoder = new EmbeddedChannel(Socks5ClientEncoder.DEFAULT); + assertTrue(encoder.writeOutbound( + new DefaultSocks5InitialRequest(Socks5CommonTestUtils.generateList(Socks5AuthMethod.PASSWORD, 255)))); + ByteBuf buf = encoder.readOutbound(); + assertNotNull(buf); + buf.release(); + assertFalse(encoder.finish()); + } + + @Test + public void initialRequestEncodingMustRejectTooManyAuthMethods() { + final EmbeddedChannel encoder = new EmbeddedChannel(Socks5ClientEncoder.DEFAULT); + assertThatThrownBy( + new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + encoder.writeOutbound( + new DefaultSocks5InitialRequest(Socks5CommonTestUtils.generateList( + Socks5AuthMethod.PASSWORD, 256))); + } + } + ).isInstanceOf(EncoderException.class) + .hasMessageContaining("Invalid field length"); + assertFalse(encoder.finish()); + } + + @Test + public void passwordAuthRequestEncodingMustAcceptMaxLengthUsername() { + EmbeddedChannel encoder = new EmbeddedChannel(Socks5ClientEncoder.DEFAULT); + + // max length username + assertTrue(encoder.writeOutbound( + new DefaultSocks5PasswordAuthRequest("user", "pass") { + @Override + public String username() { + return Socks5CommonTestUtils.generateString("a", 255); + } + } + )); + ByteBuf buf = encoder.readOutbound(); + assertNotNull(buf); + buf.release(); + + // max length password + assertTrue(encoder.writeOutbound( + new DefaultSocks5PasswordAuthRequest("user", "pass") { + @Override + public String password() { + return Socks5CommonTestUtils.generateString("a", 255); + } + } + )); + buf = encoder.readOutbound(); + assertNotNull(buf); + buf.release(); + + assertFalse(encoder.finish()); + } + + @Test + public void passwordAuthRequestEncodingMustRejectTooLongUsernameOrPassword() { + final EmbeddedChannel encoder = new EmbeddedChannel(Socks5ClientEncoder.DEFAULT); + + // too long username + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + encoder.writeOutbound( + new DefaultSocks5PasswordAuthRequest("user", "pass") { + @Override + public String username() { + return Socks5CommonTestUtils.generateString("a", 256); + } + } + ); + } + }) + .isInstanceOf(EncoderException.class) + .hasMessageContaining("Invalid field length"); + + // too long password + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + encoder.writeOutbound( + new DefaultSocks5PasswordAuthRequest("user", "pass") { + @Override + public String password() { + return Socks5CommonTestUtils.generateString("a", 256); + } + } + ); + } + }) + .isInstanceOf(EncoderException.class) + .hasMessageContaining("Invalid field length"); + + assertFalse(encoder.finish()); + } + + @Test + public void commandRequestEncodingMustAcceptMaxLengthDstAddr() { + EmbeddedChannel encoder = new EmbeddedChannel(Socks5ClientEncoder.DEFAULT); + String dstAddr = "aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa" + + ".aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa" + + ".aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa"; + assertThat(dstAddr).hasSize(255); + assertTrue(encoder.writeOutbound(new DefaultSocks5CommandRequest( + Socks5CommandType.CONNECT, Socks5AddressType.DOMAIN, + dstAddr, 8080))); + ByteBuf buf = encoder.readOutbound(); + assertNotNull(buf); + buf.release(); + assertFalse(encoder.finish()); + } + + @Test + public void commandRequestEncodingMustAcceptNullDstAddr() { + EmbeddedChannel encoder = new EmbeddedChannel(Socks5ClientEncoder.DEFAULT); + assertTrue(encoder.writeOutbound(new Socks5CommandRequest() { + @Override + public DecoderResult decoderResult() { + return DecoderResult.SUCCESS; + } + + @Override + public void setDecoderResult(DecoderResult result) { + } + + @Override + public SocksVersion version() { + return SocksVersion.SOCKS5; + } + + @Override + public Socks5CommandType type() { + return Socks5CommandType.CONNECT; + } + + @Override + public Socks5AddressType dstAddrType() { + return Socks5AddressType.DOMAIN; + } + + @Override + public String dstAddr() { + return null; + } + + @Override + public int dstPort() { + return 8080; + } + })); + ByteBuf buf = encoder.readOutbound(); + assertNotNull(buf); + buf.release(); + assertFalse(encoder.finish()); + } + + @Test + public void commandRequestEncodingMustRejectTooLongDstAddr() { + final EmbeddedChannel encoder = new EmbeddedChannel(Socks5ClientEncoder.DEFAULT); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + encoder.writeOutbound(new Socks5CommandRequest() { + @Override + public DecoderResult decoderResult() { + return DecoderResult.SUCCESS; + } + + @Override + public void setDecoderResult(DecoderResult result) { + } + + @Override + public SocksVersion version() { + return SocksVersion.SOCKS5; + } + + @Override + public Socks5CommandType type() { + return Socks5CommandType.CONNECT; + } + + @Override + public Socks5AddressType dstAddrType() { + return Socks5AddressType.DOMAIN; + } + + @Override + public String dstAddr() { + return Socks5CommonTestUtils.generateString("a", 256); + } + + @Override + public int dstPort() { + return 8080; + } + }); + } + }) + .isInstanceOf(EncoderException.class) + .hasMessageContaining("Invalid field length"); + assertFalse(encoder.finish()); + } +} diff --git a/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5CommonTestUtils.java b/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5CommonTestUtils.java index fe7a220dd71..beaa9b622d4 100755 --- a/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5CommonTestUtils.java +++ b/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5CommonTestUtils.java @@ -18,6 +18,9 @@ import io.netty.buffer.ByteBuf; import io.netty.channel.embedded.EmbeddedChannel; +import java.util.ArrayList; +import java.util.List; + final class Socks5CommonTestUtils { /** * A constructor to stop this class being constructed. @@ -53,4 +56,20 @@ public static ByteBuf encodeServer(Socks5Message msg) { return encoded; } + + static List generateList(T obj, int count) { + List list = new ArrayList(count); + for (int i = 0; i < count; i++) { + list.add(obj); + } + return list; + } + + static String generateString(String str, int count) { + StringBuilder sb = new StringBuilder(str.length() * count); + for (int i = 0; i < count; i++) { + sb.append(str); + } + return sb.toString(); + } } diff --git a/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5ServerEncoderTest.java b/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5ServerEncoderTest.java new file mode 100644 index 00000000000..1f755247201 --- /dev/null +++ b/codec-socks/src/test/java/io/netty/handler/codec/socksx/v5/Socks5ServerEncoderTest.java @@ -0,0 +1,144 @@ +/* + * Copyright 2026 The Netty Project + * + * The Netty Project 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: + * + * https://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 io.netty.handler.codec.socksx.v5; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.DecoderResult; +import io.netty.handler.codec.EncoderException; +import io.netty.handler.codec.socksx.SocksVersion; +import org.assertj.core.api.ThrowableAssert; +import org.junit.jupiter.api.Test; + +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Socks5ServerEncoderTest { + @Test + public void commandResponseEncodingMustAcceptMaxLengthDstAddr() { + EmbeddedChannel encoder = new EmbeddedChannel(Socks5ServerEncoder.DEFAULT); + String dstAddr = "aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa" + + ".aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa" + + ".aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa"; + assertThat(dstAddr).hasSize(255); + assertTrue(encoder.writeOutbound(new DefaultSocks5CommandResponse( + Socks5CommandStatus.SUCCESS, Socks5AddressType.DOMAIN, + dstAddr, 8080))); + ByteBuf buf = encoder.readOutbound(); + assertNotNull(buf); + buf.release(); + assertFalse(encoder.finish()); + } + + @Test + public void commandResponseEncodingMustAcceptNullDstAddr() { + EmbeddedChannel encoder = new EmbeddedChannel(Socks5ServerEncoder.DEFAULT); + assertTrue(encoder.writeOutbound(new Socks5CommandResponse() { + @Override + public DecoderResult decoderResult() { + return DecoderResult.SUCCESS; + } + + @Override + public void setDecoderResult(DecoderResult result) { + } + + @Override + public SocksVersion version() { + return SocksVersion.SOCKS5; + } + + @Override + public Socks5CommandStatus status() { + return Socks5CommandStatus.SUCCESS; + } + + @Override + public Socks5AddressType bndAddrType() { + return Socks5AddressType.DOMAIN; + } + + @Override + public String bndAddr() { + return null; + } + + @Override + public int bndPort() { + return 8080; + } + })); + ByteBuf buf = encoder.readOutbound(); + assertNotNull(buf); + buf.release(); + assertFalse(encoder.finish()); + } + + @Test + public void commandResponseEncodingMustRejectTooLongDstAddr() { + final EmbeddedChannel encoder = new EmbeddedChannel(Socks5ServerEncoder.DEFAULT); + assertThatThrownBy(new ThrowableAssert.ThrowingCallable() { + @Override + public void call() throws Throwable { + encoder.writeOutbound(new Socks5CommandResponse() { + @Override + public DecoderResult decoderResult() { + return DecoderResult.SUCCESS; + } + + @Override + public void setDecoderResult(DecoderResult result) { + } + + @Override + public SocksVersion version() { + return SocksVersion.SOCKS5; + } + + @Override + public Socks5CommandStatus status() { + return Socks5CommandStatus.SUCCESS; + } + + @Override + public Socks5AddressType bndAddrType() { + return Socks5AddressType.DOMAIN; + } + + @Override + public String bndAddr() { + return Socks5CommonTestUtils.generateString("a", 256); + } + + @Override + public int bndPort() { + return 8080; + } + }); + } + }) + .isInstanceOf(EncoderException.class) + .hasMessageContaining("Invalid field length"); + assertFalse(encoder.finish()); + } +} diff --git a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java index bc1124a577d..f698bbf2ab2 100644 --- a/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java +++ b/handler/src/main/java/io/netty/handler/ssl/ReferenceCountedOpenSslClientContext.java @@ -178,6 +178,14 @@ static OpenSslSessionContext newSessionContext(ReferenceCountedOpenSslContext th // // See https://github.com/netty/netty/issues/5372 + if (thiz.endpointIdentificationAlgorithm != null && !thiz.endpointIdentificationAlgorithm.isEmpty() && + !useExtendedTrustManager(manager)) { + throw new UnsupportedOperationException( + "Endpoint identification algorithm '" + thiz.endpointIdentificationAlgorithm + "' is " + + "configured but the trust manager does not support extended trust manager verification. " + + "Please provide an X509ExtendedTrustManager or use the SslProvider.JDK."); + } + setVerifyCallback(ctx, engines, manager); } catch (Exception e) { if (keyMaterialProvider != null) { diff --git a/handler/src/main/java/io/netty/handler/ssl/SslClientHelloHandler.java b/handler/src/main/java/io/netty/handler/ssl/SslClientHelloHandler.java index 46eee9512bd..13155e6df5d 100644 --- a/handler/src/main/java/io/netty/handler/ssl/SslClientHelloHandler.java +++ b/handler/src/main/java/io/netty/handler/ssl/SslClientHelloHandler.java @@ -56,6 +56,8 @@ public abstract class SslClientHelloHandler extends ByteToMessageDecoder impl private boolean suppressRead; private boolean readPending; private ByteBuf handshakeBuffer; + private int aggregatedBytes; + private int handshakeLength = -1; public SslClientHelloHandler() { this(DEFAULT_MAX_CLIENT_HELLO_LENGTH); @@ -72,9 +74,8 @@ protected SslClientHelloHandler(int maxClientHelloLength) { protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { if (!suppressRead && !handshakeFailed) { try { - int readerIndex = in.readerIndex(); - int readableBytes = in.readableBytes(); - int handshakeLength = -1; + int readerIndex = in.readerIndex() + aggregatedBytes; + int readableBytes = in.readableBytes() - aggregatedBytes; // Check if we have enough data to determine the record type and length. while (readableBytes >= SslUtils.SSL_RECORD_HEADER_LENGTH) { @@ -121,13 +122,62 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t // Let's check if we already parsed the handshake length or not. if (handshakeLength == -1) { - if (readerIndex + 4 > endOffset) { - // Need more data to read HandshakeType and handshakeLength (4 bytes) - return; + if (handshakeBuffer == null && + readerIndex + SslUtils.SSL_RECORD_HEADER_LENGTH + 4 <= endOffset) { + final int handshakeType = in.getUnsignedByte(readerIndex + + SslUtils.SSL_RECORD_HEADER_LENGTH); + + // Check if this is a clientHello(1) + // See https://tools.ietf.org/html/rfc5246#section-7.4 + if (handshakeType != 1) { + select(ctx, null); + return; + } + + // Read the length of the handshake as it may arrive in fragments + // See https://tools.ietf.org/html/rfc5246#section-7.4 + handshakeLength = in.getUnsignedMedium(readerIndex + + SslUtils.SSL_RECORD_HEADER_LENGTH + 1); + + if (handshakeLength > maxClientHelloLength && maxClientHelloLength != 0) { + TooLongFrameException e = new TooLongFrameException( + "ClientHello length exceeds " + maxClientHelloLength + + ": " + handshakeLength); + in.skipBytes(in.readableBytes()); + ctx.fireUserEventTriggered(new SniCompletionEvent(e)); + SslUtils.handleHandshakeFailure(ctx, e, true); + throw e; + } + + if (handshakeLength + 4 + SslUtils.SSL_RECORD_HEADER_LENGTH <= packetLength) { + // We have everything we need in one packet. + // Skip the record header and handshake header (this sums up as 4 bytes) + readerIndex += SslUtils.SSL_RECORD_HEADER_LENGTH + 4; + final int clientHelloLength = handshakeLength; + handshakeLength = -1; + select(ctx, in.retainedSlice(readerIndex, clientHelloLength)); + return; + } } + } - final int handshakeType = in.getUnsignedByte(readerIndex + - SslUtils.SSL_RECORD_HEADER_LENGTH); + if (handshakeBuffer == null) { + handshakeBuffer = ctx.alloc().buffer(); + } + + // Combine the encapsulated data in one buffer but not include the SSL_RECORD_HEADER + handshakeBuffer.writeBytes(in, readerIndex + SslUtils.SSL_RECORD_HEADER_LENGTH, + packetLength - SslUtils.SSL_RECORD_HEADER_LENGTH); + readerIndex += packetLength; + readableBytes -= packetLength; + aggregatedBytes += packetLength; + if (handshakeLength == -1) { + if (handshakeBuffer.readableBytes() < 4) { + continue; + } + + final int handshakeType = handshakeBuffer.getUnsignedByte(0); + handshakeLength = handshakeBuffer.getUnsignedMedium(1); // Check if this is a clientHello(1) // See https://tools.ietf.org/html/rfc5246#section-7.4 @@ -136,11 +186,6 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t return; } - // Read the length of the handshake as it may arrive in fragments - // See https://tools.ietf.org/html/rfc5246#section-7.4 - handshakeLength = in.getUnsignedMedium(readerIndex + - SslUtils.SSL_RECORD_HEADER_LENGTH + 1); - if (handshakeLength > maxClientHelloLength && maxClientHelloLength != 0) { TooLongFrameException e = new TooLongFrameException( "ClientHello length exceeds " + maxClientHelloLength + @@ -150,34 +195,12 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t SslUtils.handleHandshakeFailure(ctx, e, true); throw e; } - // Consume handshakeType and handshakeLength (this sums up as 4 bytes) - readerIndex += 4; - packetLength -= 4; - - if (handshakeLength + 4 + SslUtils.SSL_RECORD_HEADER_LENGTH <= packetLength) { - // We have everything we need in one packet. - // Skip the record header - readerIndex += SslUtils.SSL_RECORD_HEADER_LENGTH; - select(ctx, in.retainedSlice(readerIndex, handshakeLength)); - return; - } else { - if (handshakeBuffer == null) { - handshakeBuffer = ctx.alloc().buffer(handshakeLength); - } else { - // Clear the buffer so we can aggregate into it again. - handshakeBuffer.clear(); - } - } } - // Combine the encapsulated data in one buffer but not include the SSL_RECORD_HEADER - handshakeBuffer.writeBytes(in, readerIndex + SslUtils.SSL_RECORD_HEADER_LENGTH, - packetLength - SslUtils.SSL_RECORD_HEADER_LENGTH); - readerIndex += packetLength; - readableBytes -= packetLength; - if (handshakeLength <= handshakeBuffer.readableBytes()) { - ByteBuf clientHello = handshakeBuffer.setIndex(0, handshakeLength); + if (handshakeBuffer.readableBytes() >= handshakeLength + 4) { + ByteBuf clientHello = handshakeBuffer.setIndex(4, handshakeLength + 4).slice(); handshakeBuffer = null; + handshakeLength = -1; select(ctx, clientHello); return; @@ -210,6 +233,7 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t private void releaseHandshakeBuffer() { releaseIfNotNull(handshakeBuffer); handshakeBuffer = null; + handshakeLength = -1; } private static void releaseIfNotNull(ByteBuf buffer) { diff --git a/handler/src/test/java/io/netty/handler/ssl/SniHandlerTest.java b/handler/src/test/java/io/netty/handler/ssl/SniHandlerTest.java index 3a85f24fdc6..01da306b4c7 100644 --- a/handler/src/test/java/io/netty/handler/ssl/SniHandlerTest.java +++ b/handler/src/test/java/io/netty/handler/ssl/SniHandlerTest.java @@ -23,16 +23,19 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLException; +import io.netty.buffer.DuplicatedByteBuf; import io.netty.handler.codec.TooLongFrameException; import io.netty.handler.ssl.util.CachedSelfSignedCertificate; import io.netty.util.concurrent.Future; import io.netty.bootstrap.Bootstrap; import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.AbstractByteBufAllocator; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufAllocator; import io.netty.buffer.Unpooled; @@ -654,7 +657,83 @@ public void testFragmented(SslProvider provider) throws Exception { testWithFragmentSize(provider, 50); } + static List tinyFragmentData() { + List args = new ArrayList(); + for (Object provider : data()) { + // Fragment sizes smaller than the 4-byte handshake header, so the header itself is + // split across multiple TLS records. + for (int size = 1; size <= 4; size++) { + args.add(new Object[] { provider, size }); + } + } + return args; + } + + @ParameterizedTest(name = "{index}: sslProvider={0}, fragmentSize={1}") + @MethodSource("tinyFragmentData") + public void testTinyFragments(SslProvider provider, int fragmentSize) throws Exception { + testWithFragmentSize(provider, fragmentSize); + } + + @ParameterizedTest(name = "{index}: sslProvider={0}") + @MethodSource("data") + @SuppressWarnings("unchecked") + public void testTinyFragmentsAreAggregatedOnlyOnce(SslProvider provider) throws Exception { + final AtomicLong copiedBytes = new AtomicLong(); + EmbeddedChannel server = new EmbeddedChannel(new SniHandler(mock(DomainNameMapping.class))); + server.config().setAllocator(new AbstractByteBufAllocator() { + @Override + public boolean isDirectBufferPooled() { + return false; + } + + @Override + protected ByteBuf newHeapBuffer(int initialCapacity, int maxCapacity) { + return countingBuffer(Unpooled.buffer(initialCapacity, maxCapacity), copiedBytes); + } + + @Override + protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) { + return countingBuffer(Unpooled.directBuffer(initialCapacity, maxCapacity), copiedBytes); + } + }); + + try { + List fragments = clientHelloInMultipleFragments(provider, "netty.io", 1, 1); + // Hold back the last fragment on purpose, so the handler keeps aggregating the ClientHello. + ReferenceCountUtil.release(fragments.remove(fragments.size() - 1)); + for (ByteBuf fragment : fragments) { + assertFalse(server.writeInbound(fragment)); + } + + assertEquals(fragments.size(), copiedBytes.get()); + } finally { + server.finishAndReleaseAll(); + } + } + + private static ByteBuf countingBuffer(ByteBuf buffer, final AtomicLong copiedBytes) { + return new DuplicatedByteBuf(buffer) { + @Override + public ByteBuf writeBytes(ByteBuf src, int srcIndex, int length) { + copiedBytes.addAndGet(length); + return super.writeBytes(src, srcIndex, length); + } + }; + } + + @ParameterizedTest(name = "{index}: sslProvider={0}") + @MethodSource("data") + public void testTinyFirstFragment(SslProvider provider) throws Exception { + testWithFragmentSize(provider, 1, Integer.MAX_VALUE); + } + private void testWithFragmentSize(SslProvider provider, final int maxFragmentSize) throws Exception { + testWithFragmentSize(provider, maxFragmentSize, maxFragmentSize); + } + + private void testWithFragmentSize(SslProvider provider, final int firstFragmentSize, final int maxFragmentSize) + throws Exception { final String sni = "netty.io"; SelfSignedCertificate cert = CachedSelfSignedCertificate.getCachedCertificate(); final SslContext context = SslContextBuilder.forServer(cert.key(), cert.cert()) @@ -670,7 +749,8 @@ protected Future lookup(final ChannelHandlerContext ctx, final Strin } }); - final List buffers = clientHelloInMultipleFragments(provider, sni, maxFragmentSize); + final List buffers = + clientHelloInMultipleFragments(provider, sni, firstFragmentSize, maxFragmentSize); for (ByteBuf buffer : buffers) { server.writeInbound(buffer); } @@ -681,7 +761,8 @@ protected Future lookup(final ChannelHandlerContext ctx, final Strin } private static List clientHelloInMultipleFragments( - SslProvider provider, String hostname, int maxTlsPlaintextSize) throws SSLException { + SslProvider provider, String hostname, int firstTlsPlaintextSize, int maxTlsPlaintextSize) + throws SSLException { final EmbeddedChannel client = new EmbeddedChannel(); final SslContext ctx = SslContextBuilder.forClient() .sslProvider(provider) @@ -691,7 +772,7 @@ private static List clientHelloInMultipleFragments( final SslHandler sslHandler = ctx.newHandler(client.alloc(), hostname, -1); client.pipeline().addLast(sslHandler); final ByteBuf clientHello = client.readOutbound(); - List buffers = split(clientHello, maxTlsPlaintextSize); + List buffers = split(clientHello, firstTlsPlaintextSize, maxTlsPlaintextSize); assertTrue(client.finishAndReleaseAll()); return buffers; } finally { @@ -699,7 +780,7 @@ private static List clientHelloInMultipleFragments( } } - private static List split(ByteBuf clientHello, int maxSize) { + private static List split(ByteBuf clientHello, int firstSize, int maxSize) { final int type = clientHello.readUnsignedByte(); final int version = clientHello.readUnsignedShort(); final int length = clientHello.readUnsignedShort(); @@ -707,7 +788,7 @@ private static List split(ByteBuf clientHello, int maxSize) { final List result = new ArrayList(); while (clientHello.readableBytes() > 0) { - final int toRead = Math.min(maxSize, clientHello.readableBytes()); + final int toRead = Math.min(result.isEmpty() ? firstSize : maxSize, clientHello.readableBytes()); final ByteBuf bb = clientHello.alloc().buffer(SslUtils.SSL_RECORD_HEADER_LENGTH + toRead); bb.writeByte(type); bb.writeShort(version); diff --git a/transport-sctp/src/main/java/io/netty/handler/codec/sctp/SctpMessageCompletionHandler.java b/transport-sctp/src/main/java/io/netty/handler/codec/sctp/SctpMessageCompletionHandler.java index f6c6669f4cf..c34935cb35d 100644 --- a/transport-sctp/src/main/java/io/netty/handler/codec/sctp/SctpMessageCompletionHandler.java +++ b/transport-sctp/src/main/java/io/netty/handler/codec/sctp/SctpMessageCompletionHandler.java @@ -37,9 +37,13 @@ * {@link ChannelInboundHandler}. */ public class SctpMessageCompletionHandler extends MessageToMessageDecoder { + private static final int DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024; + private final IntObjectMap> incompleteSctpMessages = new IntObjectHashMap>(); private final int maxIncompleteSctpMessages; private final int maxFragments; + private final int maxBufferedBytes; + private long bufferedBytes; public SctpMessageCompletionHandler() { this(128, 128); @@ -52,9 +56,21 @@ public SctpMessageCompletionHandler() { * @param maxFragments the maximum number of fragments per sctp message. */ public SctpMessageCompletionHandler(int maxIncompleteSctpMessages, int maxFragments) { + this(maxIncompleteSctpMessages, maxFragments, DEFAULT_MAX_BUFFERED_BYTES); + } + + /** + * Create a new instance. + * + * @param maxIncompleteSctpMessages the maximum number of incomplete sctp message inflight. + * @param maxFragments the maximum number of fragments per sctp message. + * @param maxBufferedBytes the maximum number of bytes buffered by incomplete sctp messages. + */ + public SctpMessageCompletionHandler(int maxIncompleteSctpMessages, int maxFragments, int maxBufferedBytes) { super(SctpMessage.class); this.maxIncompleteSctpMessages = checkPositive(maxIncompleteSctpMessages, "maxIncompleteSctpMessages"); this.maxFragments = checkPositive(maxFragments, "maxFragments"); + this.maxBufferedBytes = checkPositive(maxBufferedBytes, "maxBufferedBytes"); } @Override @@ -75,16 +91,20 @@ protected void decode(ChannelHandlerContext ctx, SctpMessage msg, List o throw new CodecException( "Too many incomplete sctp messages in flight: " + maxIncompleteSctpMessages); } + checkBufferedBytes(byteBuf); //first incomplete message frag = new ArrayList(); frag.add(byteBuf.retain()); + bufferedBytes += byteBuf.readableBytes(); incompleteSctpMessages.put(streamIdentifier, frag); } } else { if (maxFragments <= frag.size()) { throw new CodecException("Too many fragments for sctp message: " + maxFragments); } + checkBufferedBytes(byteBuf); frag.add(byteBuf.retain()); + bufferedBytes += byteBuf.readableBytes(); if (isComplete) { // Is complete so remove it. incompleteSctpMessages.remove(streamIdentifier); @@ -100,10 +120,24 @@ protected void decode(ChannelHandlerContext ctx, SctpMessage msg, List o isUnordered, composite); out.add(assembledMsg); + removeBufferedBytes(frag); } } } + private void checkBufferedBytes(ByteBuf byteBuf) { + int readableBytes = byteBuf.readableBytes(); + if (readableBytes > maxBufferedBytes - bufferedBytes) { + throw new CodecException("Too many buffered bytes for incomplete sctp messages: " + maxBufferedBytes); + } + } + + private void removeBufferedBytes(List buffers) { + for (ByteBuf buffer : buffers) { + bufferedBytes -= buffer.readableBytes(); + } + } + @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { for (List buffers: incompleteSctpMessages.values()) { @@ -112,6 +146,7 @@ public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { } } incompleteSctpMessages.clear(); + bufferedBytes = 0; super.handlerRemoved(ctx); } diff --git a/transport-sctp/src/test/java/io/netty/handler/codec/sctp/SctpMessageCompletionHandlerTest.java b/transport-sctp/src/test/java/io/netty/handler/codec/sctp/SctpMessageCompletionHandlerTest.java index 299811c3a39..7e97c5dd07e 100644 --- a/transport-sctp/src/test/java/io/netty/handler/codec/sctp/SctpMessageCompletionHandlerTest.java +++ b/transport-sctp/src/test/java/io/netty/handler/codec/sctp/SctpMessageCompletionHandlerTest.java @@ -99,6 +99,62 @@ public void execute() throws Throwable { assertEquals(0, buffer2.refCnt()); } + @Test + public void testBufferedBytesLimited() { + final EmbeddedChannel channel = new EmbeddedChannel(new SctpMessageCompletionHandler(2, 2, 8)); + ByteBuf buffer = Unpooled.wrappedBuffer(new byte[] { 1, 2, 3, 4 }); + ByteBuf buffer2 = Unpooled.wrappedBuffer(new byte[] { 1, 2, 3, 4 }); + final ByteBuf buffer3 = Unpooled.wrappedBuffer(new byte[] { 1 }); + + assertFalse(channel.writeInbound(new SctpMessage(new TestMessageInfo(false, 1), buffer))); + assertEquals(1, buffer.refCnt()); + + assertFalse(channel.writeInbound(new SctpMessage(new TestMessageInfo(false, 2), buffer2))); + assertEquals(1, buffer2.refCnt()); + + assertThrows(CodecException.class, new Executable() { + @Override + public void execute() throws Throwable { + channel.writeInbound(new SctpMessage(new TestMessageInfo(false, 1), buffer3)); + } + }); + assertEquals(0, buffer.refCnt()); + assertEquals(0, buffer2.refCnt()); + assertEquals(0, buffer3.refCnt()); + + assertFalse(channel.finish()); + } + + @Test + public void testBufferedBytesReleasedAfterCompletion() { + EmbeddedChannel channel = new EmbeddedChannel(new SctpMessageCompletionHandler(2, 2, 8)); + ByteBuf buffer = Unpooled.wrappedBuffer(new byte[] { 1, 2, 3, 4 }); + ByteBuf buffer2 = Unpooled.wrappedBuffer(new byte[] { 5, 6, 7, 8 }); + ByteBuf buffer3 = Unpooled.wrappedBuffer(new byte[] { 9, 10, 11, 12, 13, 14, 15, 16 }); + + assertFalse(channel.writeInbound(new SctpMessage(new TestMessageInfo(false, 1), buffer))); + assertTrue(channel.writeInbound(new SctpMessage(new TestMessageInfo(true, 1), buffer2))); + SctpMessage read = channel.readInbound(); + assertEquals(8, read.content().readableBytes()); + read.release(); + + assertFalse(channel.writeInbound(new SctpMessage(new TestMessageInfo(false, 2), buffer3))); + assertEquals(1, buffer3.refCnt()); + + assertFalse(channel.finish()); + assertEquals(0, buffer3.refCnt()); + } + + @Test + public void testBufferedBytesLimitMustBePositive() { + assertThrows(IllegalArgumentException.class, new Executable() { + @Override + public void execute() throws Throwable { + new SctpMessageCompletionHandler(1, 1, 0); + } + }); + } + @Test public void testNotFragmented() { EmbeddedChannel channel = new EmbeddedChannel(new SctpMessageCompletionHandler()); From 5471f0a6ffa4ab39db22c468c6c851af4ecae709 Mon Sep 17 00:00:00 2001 From: Netty Project Bot Date: Thu, 6 Aug 2026 11:59:38 +0000 Subject: [PATCH 63/64] [maven-release-plugin] prepare release netty-4.1.137.Final --- all/pom.xml | 2 +- bom/pom.xml | 4 ++-- buffer/pom.xml | 2 +- codec-dns/pom.xml | 2 +- codec-haproxy/pom.xml | 2 +- codec-http/pom.xml | 2 +- codec-http2/pom.xml | 2 +- codec-memcache/pom.xml | 2 +- codec-mqtt/pom.xml | 2 +- codec-redis/pom.xml | 2 +- codec-smtp/pom.xml | 2 +- codec-socks/pom.xml | 2 +- codec-stomp/pom.xml | 2 +- codec-xml/pom.xml | 2 +- codec/pom.xml | 2 +- common/pom.xml | 2 +- dev-tools/pom.xml | 2 +- example/pom.xml | 2 +- handler-proxy/pom.xml | 2 +- handler-ssl-ocsp/pom.xml | 2 +- handler/pom.xml | 2 +- microbench/pom.xml | 2 +- pom.xml | 4 ++-- resolver-dns-classes-macos/pom.xml | 2 +- resolver-dns-native-macos/pom.xml | 2 +- resolver-dns/pom.xml | 2 +- resolver/pom.xml | 2 +- testsuite-autobahn/pom.xml | 2 +- testsuite-http2/pom.xml | 2 +- testsuite-native-image-client-runtime-init/pom.xml | 2 +- testsuite-native-image-client/pom.xml | 2 +- testsuite-native-image/pom.xml | 2 +- testsuite-native/pom.xml | 2 +- testsuite-osgi/pom.xml | 2 +- testsuite-shading/pom.xml | 2 +- testsuite/pom.xml | 2 +- transport-blockhound-tests/pom.xml | 2 +- transport-classes-epoll/pom.xml | 2 +- transport-classes-kqueue/pom.xml | 2 +- transport-native-epoll/pom.xml | 2 +- transport-native-kqueue/pom.xml | 2 +- transport-native-unix-common-tests/pom.xml | 2 +- transport-native-unix-common/pom.xml | 2 +- transport-rxtx/pom.xml | 2 +- transport-sctp/pom.xml | 2 +- transport-udt/pom.xml | 2 +- transport/pom.xml | 2 +- 47 files changed, 49 insertions(+), 49 deletions(-) diff --git a/all/pom.xml b/all/pom.xml index 338fab77596..417d79b0590 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-all diff --git a/bom/pom.xml b/bom/pom.xml index 2ab23573b2c..e540508fd66 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -25,7 +25,7 @@ io.netty netty-bom - 4.1.137.Final-SNAPSHOT + 4.1.137.Final pom Netty/BOM @@ -49,7 +49,7 @@ https://github.com/netty/netty scm:git:git://github.com/netty/netty.git scm:git:ssh://git@github.com/netty/netty.git - HEAD + netty-4.1.137.Final diff --git a/buffer/pom.xml b/buffer/pom.xml index 865bb5e291e..a3ad7bf73b3 100644 --- a/buffer/pom.xml +++ b/buffer/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-buffer diff --git a/codec-dns/pom.xml b/codec-dns/pom.xml index e3f4b693e72..75ad1662fb7 100644 --- a/codec-dns/pom.xml +++ b/codec-dns/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-dns diff --git a/codec-haproxy/pom.xml b/codec-haproxy/pom.xml index 7dbae4be620..cccc46a0d26 100644 --- a/codec-haproxy/pom.xml +++ b/codec-haproxy/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-haproxy diff --git a/codec-http/pom.xml b/codec-http/pom.xml index b5f814888bd..146d050c3b3 100644 --- a/codec-http/pom.xml +++ b/codec-http/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-http diff --git a/codec-http2/pom.xml b/codec-http2/pom.xml index e169e89ad82..d200ef83a93 100644 --- a/codec-http2/pom.xml +++ b/codec-http2/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-http2 diff --git a/codec-memcache/pom.xml b/codec-memcache/pom.xml index 2bebfb0d495..7738ae0c0aa 100644 --- a/codec-memcache/pom.xml +++ b/codec-memcache/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-memcache diff --git a/codec-mqtt/pom.xml b/codec-mqtt/pom.xml index c9e2b8168ec..0d9b743f6cc 100644 --- a/codec-mqtt/pom.xml +++ b/codec-mqtt/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-mqtt diff --git a/codec-redis/pom.xml b/codec-redis/pom.xml index 33b22f861b7..202961634af 100644 --- a/codec-redis/pom.xml +++ b/codec-redis/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-redis diff --git a/codec-smtp/pom.xml b/codec-smtp/pom.xml index 0a456f01820..327461fe8be 100644 --- a/codec-smtp/pom.xml +++ b/codec-smtp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-smtp diff --git a/codec-socks/pom.xml b/codec-socks/pom.xml index cb2d3a210c6..34171c85650 100644 --- a/codec-socks/pom.xml +++ b/codec-socks/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-socks diff --git a/codec-stomp/pom.xml b/codec-stomp/pom.xml index bee37764ba0..02e9e579e79 100644 --- a/codec-stomp/pom.xml +++ b/codec-stomp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-stomp diff --git a/codec-xml/pom.xml b/codec-xml/pom.xml index 60e90d39ff1..875537673e8 100644 --- a/codec-xml/pom.xml +++ b/codec-xml/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec-xml diff --git a/codec/pom.xml b/codec/pom.xml index 3e8ee32b1e5..698a4df638c 100644 --- a/codec/pom.xml +++ b/codec/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-codec diff --git a/common/pom.xml b/common/pom.xml index a0bf8e3228c..5b96ed09773 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-common diff --git a/dev-tools/pom.xml b/dev-tools/pom.xml index 8868d74454d..c04c485b083 100644 --- a/dev-tools/pom.xml +++ b/dev-tools/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-dev-tools diff --git a/example/pom.xml b/example/pom.xml index ed76f3d66e2..821895fb9ce 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-example diff --git a/handler-proxy/pom.xml b/handler-proxy/pom.xml index 615c5f9abcf..9bfd4ae2f17 100644 --- a/handler-proxy/pom.xml +++ b/handler-proxy/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-handler-proxy diff --git a/handler-ssl-ocsp/pom.xml b/handler-ssl-ocsp/pom.xml index 14e9179b056..47504b1c671 100644 --- a/handler-ssl-ocsp/pom.xml +++ b/handler-ssl-ocsp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-handler-ssl-ocsp diff --git a/handler/pom.xml b/handler/pom.xml index eb1cb0ca338..48f99754244 100644 --- a/handler/pom.xml +++ b/handler/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-handler diff --git a/microbench/pom.xml b/microbench/pom.xml index 9120c82db7d..766462b3cb5 100644 --- a/microbench/pom.xml +++ b/microbench/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-microbench diff --git a/pom.xml b/pom.xml index 8d2bf80fef1..f524fe86650 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ io.netty netty-parent pom - 4.1.137.Final-SNAPSHOT + 4.1.137.Final Netty https://netty.io/ @@ -53,7 +53,7 @@ https://github.com/netty/netty scm:git:git://github.com/netty/netty.git scm:git:ssh://git@github.com/netty/netty.git - HEAD + netty-4.1.137.Final diff --git a/resolver-dns-classes-macos/pom.xml b/resolver-dns-classes-macos/pom.xml index 84ff71cef29..3b5c8395272 100644 --- a/resolver-dns-classes-macos/pom.xml +++ b/resolver-dns-classes-macos/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-resolver-dns-classes-macos diff --git a/resolver-dns-native-macos/pom.xml b/resolver-dns-native-macos/pom.xml index 3b729c98ded..9575a43efc8 100644 --- a/resolver-dns-native-macos/pom.xml +++ b/resolver-dns-native-macos/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-resolver-dns-native-macos diff --git a/resolver-dns/pom.xml b/resolver-dns/pom.xml index a232555744b..551f3a0d701 100644 --- a/resolver-dns/pom.xml +++ b/resolver-dns/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-resolver-dns diff --git a/resolver/pom.xml b/resolver/pom.xml index a71e22c6160..31b085c8675 100644 --- a/resolver/pom.xml +++ b/resolver/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-resolver diff --git a/testsuite-autobahn/pom.xml b/testsuite-autobahn/pom.xml index cf7129085dd..9ea62a9391c 100644 --- a/testsuite-autobahn/pom.xml +++ b/testsuite-autobahn/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-testsuite-autobahn diff --git a/testsuite-http2/pom.xml b/testsuite-http2/pom.xml index d1422cff66e..e92f3df73a4 100644 --- a/testsuite-http2/pom.xml +++ b/testsuite-http2/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-testsuite-http2 diff --git a/testsuite-native-image-client-runtime-init/pom.xml b/testsuite-native-image-client-runtime-init/pom.xml index a07bfd43124..678bb64a054 100644 --- a/testsuite-native-image-client-runtime-init/pom.xml +++ b/testsuite-native-image-client-runtime-init/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-testsuite-native-image-client-runtime-init diff --git a/testsuite-native-image-client/pom.xml b/testsuite-native-image-client/pom.xml index 1071b54314b..fd2201d4784 100644 --- a/testsuite-native-image-client/pom.xml +++ b/testsuite-native-image-client/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-testsuite-native-image-client diff --git a/testsuite-native-image/pom.xml b/testsuite-native-image/pom.xml index 3757b720504..4d92f52f178 100644 --- a/testsuite-native-image/pom.xml +++ b/testsuite-native-image/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-testsuite-native-image diff --git a/testsuite-native/pom.xml b/testsuite-native/pom.xml index 306b46d69e0..e372a0400f3 100644 --- a/testsuite-native/pom.xml +++ b/testsuite-native/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-testsuite-native diff --git a/testsuite-osgi/pom.xml b/testsuite-osgi/pom.xml index d7c40c44256..11f1336829c 100644 --- a/testsuite-osgi/pom.xml +++ b/testsuite-osgi/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-testsuite-osgi diff --git a/testsuite-shading/pom.xml b/testsuite-shading/pom.xml index 005b479897e..345940b27eb 100644 --- a/testsuite-shading/pom.xml +++ b/testsuite-shading/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-testsuite-shading diff --git a/testsuite/pom.xml b/testsuite/pom.xml index e963f6d635f..0ece72e6783 100644 --- a/testsuite/pom.xml +++ b/testsuite/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-testsuite diff --git a/transport-blockhound-tests/pom.xml b/transport-blockhound-tests/pom.xml index 15a729da9b9..971aa5b7be0 100644 --- a/transport-blockhound-tests/pom.xml +++ b/transport-blockhound-tests/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-blockhound-tests diff --git a/transport-classes-epoll/pom.xml b/transport-classes-epoll/pom.xml index da7b79aa5ab..a4af038dc6e 100644 --- a/transport-classes-epoll/pom.xml +++ b/transport-classes-epoll/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-classes-epoll diff --git a/transport-classes-kqueue/pom.xml b/transport-classes-kqueue/pom.xml index 6af6a290431..8d5a5126eec 100644 --- a/transport-classes-kqueue/pom.xml +++ b/transport-classes-kqueue/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-classes-kqueue diff --git a/transport-native-epoll/pom.xml b/transport-native-epoll/pom.xml index 0176294101b..111d22adf22 100644 --- a/transport-native-epoll/pom.xml +++ b/transport-native-epoll/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-native-epoll diff --git a/transport-native-kqueue/pom.xml b/transport-native-kqueue/pom.xml index e5c8a514fe2..58f619d890f 100644 --- a/transport-native-kqueue/pom.xml +++ b/transport-native-kqueue/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-native-kqueue diff --git a/transport-native-unix-common-tests/pom.xml b/transport-native-unix-common-tests/pom.xml index de9d1698e7b..8f56408ff31 100644 --- a/transport-native-unix-common-tests/pom.xml +++ b/transport-native-unix-common-tests/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-native-unix-common-tests diff --git a/transport-native-unix-common/pom.xml b/transport-native-unix-common/pom.xml index 8c14ad892b2..09a034d85fe 100644 --- a/transport-native-unix-common/pom.xml +++ b/transport-native-unix-common/pom.xml @@ -19,7 +19,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-native-unix-common diff --git a/transport-rxtx/pom.xml b/transport-rxtx/pom.xml index 31ae29ecd60..2b03ea0be6c 100644 --- a/transport-rxtx/pom.xml +++ b/transport-rxtx/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-rxtx diff --git a/transport-sctp/pom.xml b/transport-sctp/pom.xml index c76950b3216..b3602238e86 100644 --- a/transport-sctp/pom.xml +++ b/transport-sctp/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-sctp diff --git a/transport-udt/pom.xml b/transport-udt/pom.xml index 184e4ebae9c..7c697f785d3 100644 --- a/transport-udt/pom.xml +++ b/transport-udt/pom.xml @@ -21,7 +21,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport-udt diff --git a/transport/pom.xml b/transport/pom.xml index 32ddbe7bbfd..839a8101494 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ io.netty netty-parent - 4.1.137.Final-SNAPSHOT + 4.1.137.Final netty-transport From c8b8ea89e9d23620a9cbaa973e1332538b2ece91 Mon Sep 17 00:00:00 2001 From: Erik Merkle Date: Wed, 12 Aug 2026 16:14:51 -0500 Subject: [PATCH 64/64] Update Maven publishing This patch updates the build-and-publish workflow to use a script to correctly publish packages to GitHub. For publishing to Datastax artifactory, you will need to download the merged-local-satging bundle from the GitHub build and then publish using approrpiate credentials, possibly behind the AWS VPN fro lab Artifactory. --- .github/scripts/deploy_maven_packages.sh | 101 ++++++++++++++++++ .github/workflows/README-build-and-publish.md | 77 +++++++------ .github/workflows/build-and-publish.yml | 82 +++++++++++--- 3 files changed, 209 insertions(+), 51 deletions(-) create mode 100755 .github/scripts/deploy_maven_packages.sh diff --git a/.github/scripts/deploy_maven_packages.sh b/.github/scripts/deploy_maven_packages.sh new file mode 100755 index 00000000000..955f479b7fd --- /dev/null +++ b/.github/scripts/deploy_maven_packages.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# ---------------------------------------------------------------------------- +# Deploy pre-built artifacts from a local Maven repository layout directory +# to a remote Maven registry using deploy:deploy-file. +# +# Usage: +# deploy_maven_packages.sh +# +# Arguments: +# staging-dir - Path to a directory in standard Maven repository layout +# (e.g. ~/local-staging containing io/netty/...) +# repository-url - Full URL of the target Maven registry +# (e.g. https://maven.pkg.github.com/org/repo +# https://your-org.jfrog.io/artifactory/libs-release-local) +# repository-id - Server ID matching an entry in ~/.m2/settings.xml +# that holds the credentials for the target registry +# +# Each .pom file found under is treated as one artifact. +# The corresponding .jar (and any classified jars) alongside it are +# uploaded together. Files that are themselves classifiers of a pom +# already handled are skipped to avoid double-uploading. +# ---------------------------------------------------------------------------- +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " + exit 1 +fi + +STAGING_DIR="$1" +REPO_URL="$2" +REPO_ID="$3" + +if [ ! -d "$STAGING_DIR" ]; then + echo "Error: staging directory '$STAGING_DIR' does not exist" + exit 1 +fi + +echo "Deploying artifacts from '$STAGING_DIR' to '$REPO_URL' (repositoryId=$REPO_ID)" + +# Find every .pom in the staging directory. Each .pom represents one +# artifact coordinate (groupId:artifactId:version[:classifier]). +find "$STAGING_DIR" -name "*.pom" | sort | while read -r POM_FILE; do + DIR="$(dirname "$POM_FILE")" + BASENAME="$(basename "$POM_FILE" .pom)" + + # Derive the main jar alongside this pom (same basename, no classifier). + MAIN_JAR="$DIR/$BASENAME.jar" + + # Build the -Dfiles= and -Dclassifiers= and -Dtypes= lists for any + # additional classified artifacts sitting next to this pom. + EXTRA_FILES="" + EXTRA_CLASSIFIERS="" + EXTRA_TYPES="" + + for EXTRA in "$DIR/$BASENAME"-*.jar; do + [ -f "$EXTRA" ] || continue + # Extract the classifier from the filename: strip prefix "-" and suffix ".jar" + CLASSIFIER="${EXTRA#$DIR/$BASENAME-}" + CLASSIFIER="${CLASSIFIER%.jar}" + if [ -n "$EXTRA_FILES" ]; then + EXTRA_FILES="$EXTRA_FILES,$EXTRA" + EXTRA_CLASSIFIERS="$EXTRA_CLASSIFIERS,$CLASSIFIER" + EXTRA_TYPES="$EXTRA_TYPES,jar" + else + EXTRA_FILES="$EXTRA" + EXTRA_CLASSIFIERS="$CLASSIFIER" + EXTRA_TYPES="jar" + fi + done + + # Build the deploy:deploy-file command. + DEPLOY_ARGS=( + --batch-mode + deploy:deploy-file + "-Durl=$REPO_URL" + "-DrepositoryId=$REPO_ID" + "-DpomFile=$POM_FILE" + "-DgeneratePom=false" + ) + + if [ -f "$MAIN_JAR" ]; then + DEPLOY_ARGS+=("-Dfile=$MAIN_JAR") + else + # pom-only artifact (e.g. parent pom, BOM) + DEPLOY_ARGS+=("-Dfile=$POM_FILE" "-Dpackaging=pom") + fi + + if [ -n "$EXTRA_FILES" ]; then + DEPLOY_ARGS+=("-Dfiles=$EXTRA_FILES") + DEPLOY_ARGS+=("-Dclassifiers=$EXTRA_CLASSIFIERS") + DEPLOY_ARGS+=("-Dtypes=$EXTRA_TYPES") + fi + + echo "--- Deploying: $BASENAME" + if ! mvn "${DEPLOY_ARGS[@]}"; then + echo "WARNING: Failed to deploy $BASENAME (may already exist in registry — skipping)" + fi +done + +echo "Deployment complete." diff --git a/.github/workflows/README-build-and-publish.md b/.github/workflows/README-build-and-publish.md index 4440763b116..85e4593c1a6 100644 --- a/.github/workflows/README-build-and-publish.md +++ b/.github/workflows/README-build-and-publish.md @@ -6,41 +6,37 @@ This GitHub Actions workflow (`build-and-publish.yml`) builds the Netty library ## Workflow Architecture -The workflow consists of 4 stages that run in sequence: +The workflow consists of 5 stages. Stages 2, 3, and 4 run in parallel after Stage 1 completes: ``` -┌─────────────────────────────────────────────────────────────┐ -│ Stage 1: Linux x86_64 Full Build │ -│ - Builds all Netty modules │ -│ - Uses Docker with CentOS 6 for compatibility │ -│ - Produces complete JAR artifacts │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Stage 2: macOS Intel x86_64 Native Libraries │ -│ - Builds native modules only: │ -│ • resolver-dns-native-macos │ -│ • transport-native-unix-common │ -│ • transport-native-kqueue │ -│ - Runs on GitHub-hosted Intel Mac │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Stage 3: macOS ARM aarch64 Native Libraries │ -│ - Builds same native modules as Stage 2 │ -│ - Runs on GitHub-hosted Apple Silicon Mac │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Stage 4: Merge and Publish │ -│ - Downloads all artifacts from previous stages │ -│ - Merges staging repositories │ -│ - Generates netty-all module │ -│ - Publishes to GitHub Packages │ -└─────────────────────────────────────────────────────────────┘ + Stage 1: Linux x86_64 Full Build + - Builds all Netty modules + - Uses Docker with CentOS 6 for compatibility + - Produces complete JAR artifacts + + +------------------+------------------+ + | | | + Stage 2: Stage 3: Stage 4: + Linux aarch64 macOS Intel macOS ARM aarch64 + Native Libs x86_64 Native Libs + Native Libs + - transport- - resolver-dns - resolver-dns + native-epoll -native-macos -native-macos + - transport- - transport- - transport- + native-unix- native-unix- native-unix- + common common common + - transport- - transport- + ubuntu-24.04-arm native-kqueue native-kqueue + runner + GitHub Intel Mac GitHub Apple + Silicon Mac + +------------------+------------------+ + | + Stage 5: Merge and Publish + - Downloads all artifacts from previous stages + - Merges staging repositories + - Generates netty-all module + - Publishes to GitHub Packages ``` ## Triggers @@ -98,7 +94,8 @@ The workflow will automatically start building and publishing. ### Intermediate Artifacts Each build stage uploads its artifacts to GitHub Actions: -- `linux-x86_64-local-staging` - Linux build artifacts +- `linux-x86_64-local-staging` - Linux x86_64 build artifacts +- `linux-aarch64-local-staging` - Linux aarch64 native libraries - `macos-x86_64-local-staging` - Intel Mac native libraries - `macos-aarch64-local-staging` - ARM Mac native libraries - `merged-local-staging` - Final merged artifacts (for debugging) @@ -156,11 +153,13 @@ Add to your `~/.m2/settings.xml`: ## Build Times Approximate build times (may vary): -- **Stage 1 (Linux)**: 15-25 minutes -- **Stage 2 (macOS Intel)**: 10-15 minutes -- **Stage 3 (macOS ARM)**: 10-15 minutes -- **Stage 4 (Merge & Publish)**: 5-10 minutes -- **Total**: ~40-65 minutes +- **Stage 1 (Linux x86_64)**: 15-25 minutes +- **Stages 2-4 run in parallel after Stage 1 completes** +- **Stage 2 (Linux aarch64)**: 10-15 minutes +- **Stage 3 (macOS Intel)**: 10-15 minutes +- **Stage 4 (macOS ARM)**: 10-15 minutes +- **Stage 5 (Merge & Publish)**: 5-10 minutes +- **Total**: ~30-50 minutes ## Troubleshooting diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index a5a4a1efc8d..6a86a850f87 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -7,7 +7,7 @@ on: # Trigger on version tags push: branches: - - dse-netty-4.1.135.2 + - dse-netty-4.1.137 tags: - '*.dse' - 'dse-netty-*' @@ -87,7 +87,57 @@ jobs: if-no-files-found: error include-hidden-files: true - # Stage 2: Build macOS Intel x86_64 native libraries + # Stage 2: Build Linux aarch64 native libraries on a native ARM64 runner + build-linux-aarch64: + runs-on: ubuntu-24.04-arm + name: Build Linux aarch64 (Native Libraries) + needs: [build-linux-x64] + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '8' + + # Cache .m2/repository + - name: Cache local Maven repository + uses: actions/cache@v4 + continue-on-error: true + with: + path: ~/.m2/repository + key: cache-maven-linux-aarch64-${{ hashFiles('**/pom.xml') }} + restore-keys: | + cache-maven-linux-aarch64-${{ hashFiles('**/pom.xml') }} + cache-maven- + + - name: Install native build toolchain + run: | + sudo apt-get update -q + sudo apt-get install -y autoconf automake libtool make gcc libaio-dev + + - name: Create local staging directory + run: mkdir -p ~/local-staging + + - name: Build and stage Linux aarch64 native libraries + run: | + ./mvnw -B \ + -pl transport-native-unix-common,transport-native-epoll \ + -am deploy \ + -DskipTests=true \ + -DaltDeploymentRepository=local-staging::default::file://$(pwd)/local-staging + + - name: Upload local staging directory + uses: actions/upload-artifact@v4 + with: + name: linux-aarch64-local-staging + path: ${{ github.workspace }}/local-staging + if-no-files-found: error + include-hidden-files: true + + # Stage 3: Build macOS Intel x86_64 native libraries build-macos-intel: runs-on: macos-15-intel name: Build macOS x86_64 (Native Libraries) @@ -139,7 +189,7 @@ jobs: if-no-files-found: error include-hidden-files: true - # Stage 3: Build macOS ARM aarch64 native libraries + # Stage 4: Build macOS ARM aarch64 native libraries build-macos-arm: runs-on: macos-15 name: Build macOS aarch64 (Native Libraries) @@ -190,11 +240,11 @@ jobs: if-no-files-found: error include-hidden-files: true - # Stage 4: Merge artifacts and publish to GitHub Packages + # Stage 5: Merge artifacts and publish to GitHub Packages publish-to-github-packages: runs-on: ubuntu-latest name: Merge and Publish to GitHub Packages - needs: [build-linux-x64, build-macos-intel, build-macos-arm] + needs: [build-linux-x64, build-linux-aarch64, build-macos-intel, build-macos-arm] steps: - uses: actions/checkout@v4 @@ -216,7 +266,7 @@ jobs: cache-maven-${{ hashFiles('**/pom.xml') }} cache-maven- - # Configure Maven settings for GitHub Packages + # Configure Maven settings for all registries - name: Configure Maven settings uses: s4u/maven-settings-action@v3.0.0 with: @@ -244,6 +294,12 @@ jobs: name: linux-x86_64-local-staging path: ~/linux-x86_64-local-staging + - name: Download Linux aarch64 staging directory + uses: actions/download-artifact@v4 + with: + name: linux-aarch64-local-staging + path: ~/linux-aarch64-local-staging + - name: Download macOS x86_64 staging directory uses: actions/download-artifact@v4 with: @@ -262,6 +318,7 @@ jobs: bash ./.github/scripts/local_staging_install_release.sh \ ~/.m2/repository \ ~/linux-x86_64-local-staging \ + ~/linux-aarch64-local-staging \ ~/macos-x86_64-local-staging \ ~/macos-aarch64-local-staging @@ -278,6 +335,7 @@ jobs: bash ./.github/scripts/local_staging_install_release.sh \ ~/local-staging \ ~/linux-x86_64-local-staging \ + ~/linux-aarch64-local-staging \ ~/macos-x86_64-local-staging \ ~/macos-aarch64-local-staging @@ -289,12 +347,12 @@ jobs: # Deploy to GitHub Packages - name: Deploy to GitHub Packages run: | - ./mvnw -B --file pom.xml \ - org.sonatype.plugins:nexus-staging-maven-plugin:deploy-staged \ - -DaltStagingDirectory=$HOME/local-staging \ - -DserverId=github \ - -DnexusUrl=https://maven.pkg.github.com/${{ github.repository }} \ - -DrepositoryId=github + bash ./.github/scripts/deploy_maven_packages.sh \ + ~/local-staging \ + https://maven.pkg.github.com/${{ github.repository }} \ + github + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload merged staging directory (for debugging) uses: actions/upload-artifact@v4