This is an automated email from the ASF dual-hosted git repository.

markt-asf pushed a commit to branch 10.1.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/10.1.x by this push:
     new 4a5e286032 Add test case for CVE-2026-78437
4a5e286032 is described below

commit 4a5e28603260df00b454e4669ce41a9bf8bc17ee
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 dc87c54b12..2d1f34fba0 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 jakarta.servlet.ServletException;
 import jakarta.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]

Reply via email to