This is an automated email from the ASF dual-hosted git repository. markt-asf pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit e5a5d87495699f1e5accedf116fd5d45ecb5f313 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]
