This is an automated email from the ASF dual-hosted git repository.
markt-asf pushed a commit to branch 9.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/9.0.x by this push:
new ee76504275 Add test case for CVE-2026-78437
ee76504275 is described below
commit ee765042755b6236c8092c581b1aa8d8d0c3933a
Author: Mark Thomas <[email protected]>
AuthorDate: Mon Aug 24 17:12:33 2026 +0100
Add test case for CVE-2026-78437
---
.../org/apache/coyote/http2/TestHttp2Security.java | 127 +++++++++++++++++++++
1 file changed, 127 insertions(+)
diff --git a/test/org/apache/coyote/http2/TestHttp2Security.java
b/test/org/apache/coyote/http2/TestHttp2Security.java
index 1f1c2c5330..61ca93adb0 100644
--- a/test/org/apache/coyote/http2/TestHttp2Security.java
+++ b/test/org/apache/coyote/http2/TestHttp2Security.java
@@ -18,6 +18,8 @@ package org.apache.coyote.http2;
import java.io.IOException;
import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
@@ -31,6 +33,7 @@ import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.startup.Tomcat;
import org.apache.coyote.http11.AbstractHttp11Protocol;
+import org.apache.tomcat.util.http.Method;
import org.apache.tomcat.util.http.MimeHeaders;
/*
@@ -41,6 +44,11 @@ public class TestHttp2Security extends Http2TestBase {
private static final String INJECTED_TRAILER_VALUE =
"stolen-from-reset-stream-3";
+ // Number of poison(+reset)/probe pairs used by
testStreamProcessorPoolPollutionViaResetRace(). The pool is a
+ // stack, so probing immediately behind a burst of poisoning attempts
maximises the chance that a probe stream
+ // pops a Request poisoned earlier in the same burst.
+ private static final int POOL_POLLUTION_PAIRS = 100;
+
/*
* Reproduces a stale HPACK HeaderEmitter reference surviving a
server-side stream reset.
* <p>
@@ -210,6 +218,121 @@ public class TestHttp2Security extends Http2TestBase {
}
+ /*
+ * Reproduces (best-effort - the underlying defect is a scheduler race, so
a single run is not guaranteed to
+ * trigger it) cross-request state pollution of the shared {@link
Http2Protocol} Request/Response pool.
+ * <p>
+ * {@link Stream#compareAuthority(String)} sets {@code
Request.NOTE_BAD_REQUEST} when a {@code host} header fails
+ * to parse, without setting a {@code headerException} - and since a valid
{@code :authority} was already seen,
+ * the request's server name is non-null, so {@link
Stream#receivedEndOfHeaders()} does not reject the frame for
+ * a missing header. {@link Http2UpgradeHandler#headersEnd(int, boolean)}
therefore still queues a normal
+ * {@code SocketEvent.OPEN_READ} dispatch for the stream as if nothing
were wrong. If the client also sends
+ * RST_STREAM for that same stream before the container thread pool gets
to run the queued OPEN_READ task,
+ * {@link Stream#receiveReset(long)} queues a second, independent {@code
SocketEvent.ERROR} dispatch. Nothing
+ * serializes the two dispatches relative to each other, so if the ERROR
task happens to run first,
+ * {@code AbstractProcessorLight.process()} returns CLOSED without ever
calling
+ * {@code StreamProcessor.service()}/{@code
StreamProcessor.validateRequest()} - the only place that clears the
+ * note - and {@code StreamProcessor.process()} recycles the (still
poisoned) Request back into the pool
+ * regardless. The next, unrelated stream that pops that Request from the
pool then fails the stale-note check in
+ * {@code validateRequest()} and is answered 400, even though its own
headers were perfectly valid.
+ * <p>
+ * To have a realistic chance of winning the scheduler race, this sends a
whole burst of poison+reset pairs back
+ * to back (so their ERROR/OPEN_READ dispatches genuinely contend for
container threads) before sending a burst
+ * of well-formed probe requests that follow immediately behind them.
+ * <p>
+ * Generated by Claude Code with Sonnet 5
+ */
+ @Test
+ public void testCVE_2026_78437() throws Exception {
+ // Higher than the 200 default: with PAIRS poison streams and PAIRS
probe streams in flight close together,
+ // the default limit could otherwise cause some to be refused with
REFUSED_STREAM, which is unrelated to the
+ // defect under test.
+ enableHttp2(POOL_POLLUTION_PAIRS * 4L);
+ configureAndStartWebApplication();
+
+ openClientConnection();
+ doHttpUpgrade();
+ sendClientPreface();
+ validateHttp2InitialResponse(POOL_POLLUTION_PAIRS * 4L);
+
+ // Disable the RST_STREAM abuse/overhead protection. It is unrelated
to the defect under test and would
+ // otherwise trigger a connection-level GOAWAY partway through the
burst below.
+ http2Protocol.setOverheadResetFactor(0);
+
+ byte[] frameHeader = new byte[9];
+ ByteBuffer headersPayload = ByteBuffer.allocate(128);
+
+ int[] poisonStreamIds = new int[POOL_POLLUTION_PAIRS];
+ int[] probeStreamIds = new int[POOL_POLLUTION_PAIRS];
+ int streamId = 3;
+ for (int i = 0; i < POOL_POLLUTION_PAIRS; i++) {
+ poisonStreamIds[i] = streamId;
+ streamId += 2;
+ }
+ for (int i = 0; i < POOL_POLLUTION_PAIRS; i++) {
+ probeStreamIds[i] = streamId;
+ streamId += 2;
+ }
+
+ // Burst 1: for every poison stream, HEADERS (with a valid :authority
so Stream.receivedEndOfHeaders() sees a
+ // non-null server name, plus a malformed "host" header) immediately
followed by RST_STREAM. Sent back to
+ // back, with no reads in between, to maximise contention between the
resulting ERROR and OPEN_READ
+ // dispatches on the container thread pool.
+ for (int poisonStreamId : poisonStreamIds) {
+ List<Header> badHeaders = new ArrayList<>(5);
+ badHeaders.add(new Header(":method", Method.GET));
+ badHeaders.add(new Header(":scheme", "http"));
+ badHeaders.add(new Header(":path", "/simple"));
+ badHeaders.add(new Header(":authority", "localhost:" + getPort()));
+ badHeaders.add(new Header("host", "a:b:c"));
+
+ headersPayload.clear();
+ buildGetRequest(frameHeader, headersPayload, null, badHeaders,
poisonStreamId);
+ writeFrame(frameHeader, headersPayload);
+
+ sendRst(poisonStreamId, Http2Error.CANCEL.getCode());
+ }
+
+ // Burst 2: well-formed probe requests, sent immediately behind the
poisoning burst above so that, if any
+ // poison stream won the race, the freshly poisoned Request in the
pool is likely to be handed to one of
+ // these.
+ for (int probeStreamId : probeStreamIds) {
+ headersPayload.clear();
+ buildGetRequest(frameHeader, headersPayload, null, probeStreamId,
"/noContent");
+ writeFrame(frameHeader, headersPayload);
+ }
+
+ // Drain frames until every probe stream has a recorded response
(ignoring whatever, if anything, came back
+ // for the poison streams - irrelevant here, since RST_STREAM means
the server may not respond to them at
+ // all). Bounded generously since each poison/probe pair produces at
most a couple of frames.
+ int maxReads = POOL_POLLUTION_PAIRS * 6 + 10;
+ for (int reads = 0; reads < maxReads &&
!allProbesComplete(probeStreamIds); reads++) {
+ parser.readFrame();
+ }
+
+ String trace = output.getTrace();
+ for (int probeStreamId : probeStreamIds) {
+ Assert.assertFalse(
+ "tomcat-f017 reproduced: stream " + probeStreamId +
+ " was handed a Request poisoned by an earlier
stream's stale Request.NOTE_BAD_REQUEST.",
+ trace.contains(probeStreamId + "-Header-[:status]-[400]"));
+ Assert.assertTrue("No 204 response seen for probe stream " +
probeStreamId,
+ trace.contains(probeStreamId + "-Header-[:status]-[204]"));
+ }
+ }
+
+
+ private boolean allProbesComplete(int[] probeStreamIds) {
+ String trace = output.getTrace();
+ for (int probeStreamId : probeStreamIds) {
+ if (!trace.contains(probeStreamId + "-HeadersEnd")) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+
@Override
protected void configureAndStartWebApplication() throws LifecycleException
{
Tomcat tomcat = getTomcatInstance();
@@ -219,6 +342,10 @@ public class TestHttp2Security extends Http2TestBase {
ctxt.addServletMapping("/simple", "simple");
Tomcat.addServlet(ctxt, "noread", new NoReadServlet());
ctxt.addServletMapping("/noread", "noread");
+ // No response body, so probing
testStreamProcessorPoolPollutionViaResetRace() below does not exhaust the
+ // connection's HTTP/2 flow control window (the test client never
reads response bodies back).
+ Tomcat.addServlet(ctxt, "noContent", new NoContentServlet());
+ ctxt.addServletMapping("/noContent", "noContent");
tomcat.start();
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]