You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@tomcat.apache.org by ma...@apache.org on 2026/09/29 09:12:21 UTC

(tomcat) branch main updated (19a8077cd3 -> e5a5d87495)

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

markt-asf pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/tomcat.git


    from 19a8077cd3 Merge pull request #1056 from apache/dependabot/github_actions/actions/setup-java-6
     new 0eae5c69d9 Add test case for CVE-2026-77762
     new 595d260825 Add test case for CVE-2026-77791
     new e5a5d87495 Add test case for CVE-2026-78437

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../org/apache/coyote/http2/TestHttp2Security.java | 373 +++++++++++++++++++++
 .../tomcat/security/TestSecurity2026WebSocket.java | 233 +++++++++++++
 2 files changed, 606 insertions(+)
 create mode 100644 test/org/apache/coyote/http2/TestHttp2Security.java
 create mode 100644 test/org/apache/tomcat/security/TestSecurity2026WebSocket.java


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org


(tomcat) 03/03: Add test case for CVE-2026-78437

Posted by ma...@apache.org.
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 <ma...@apache.org>
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: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org


(tomcat) 01/03: Add test case for CVE-2026-77762

Posted by ma...@apache.org.
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 0eae5c69d99620e0becfce32cea67c18fe177c52
Author: Mark Thomas <ma...@apache.org>
AuthorDate: Fri Aug 21 11:21:02 2026 +0100

    Add test case for CVE-2026-77762
    
    Generated-by: Claude Code / Sonnet 5
---
 .../org/apache/coyote/http2/TestHttp2Security.java | 246 +++++++++++++++++++++
 1 file changed, 246 insertions(+)

diff --git a/test/org/apache/coyote/http2/TestHttp2Security.java b/test/org/apache/coyote/http2/TestHttp2Security.java
new file mode 100644
index 0000000000..dc87c54b12
--- /dev/null
+++ b/test/org/apache/coyote/http2/TestHttp2Security.java
@@ -0,0 +1,246 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF 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
+ *
+ *      http://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 org.apache.coyote.http2;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+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.MimeHeaders;
+
+/*
+ * Tests that can't easily be added to the {@code org.apache.tomcat.security} package because they need access to
+ * package private classes.
+ */
+public class TestHttp2Security extends Http2TestBase {
+
+    private static final String INJECTED_TRAILER_VALUE = "stolen-from-reset-stream-3";
+
+    /*
+     * Reproduces a stale HPACK HeaderEmitter reference surviving a server-side stream reset.
+     * <p>
+     * {@link HpackDecoder} holds a reference to the {@link Stream} (its {@link HpackDecoder.HeaderEmitter}) for the
+     * entire time a header block is open, i.e. from the HEADERS frame that starts it until the frame (HEADERS or
+     * CONTINUATION) that carries the END_HEADERS flag. That reference is only cleared early in two cases:
+     * {@link Http2Parser#afterHeadersCompleteCleanUp(boolean)} is called from {@link Http2Parser#readRstFrame} (RST
+     * frame *received from the client*) and from {@link Http2Parser#onHeadersComplete} (END_HEADERS seen). Nothing
+     * clears it when Tomcat resets the stream itself.
+     * <p>
+     * Separately, when a servlet completes its response without reading the full request body,
+     * {@code StreamProcessor.process()} takes the {@code !stream.isInputFinished()} branch, resets the stream
+     * (RST_STREAM, NO_ERROR, observed by the client) and, once the stream is replaced, {@link Stream#recycle()} pushes
+     * the (now recycled) {@code Request}/{@code Response} pair back into
+     * {@link Http2Protocol#recycledRequestsAndResponses} -- a stack shared by every stream the connector processes.
+     * <p>
+     * Combining the two: if the client leaves a trailer HEADERS frame for the just-reset stream open (no END_HEADERS)
+     * and only later completes it with a CONTINUATION frame, that CONTINUATION is decoded against the stale,
+     * already-recycled {@link Stream} object. Its {@link Stream#emitHeader(String, String)} writes straight into
+     * {@code coyoteRequest.getMimeTrailerFields()} -- but {@code coyoteRequest} is a {@code final} field, so it is the
+     * exact same {@code Request} instance later handed out (by {@link Http2Protocol#popRequestAndResponse()}) to
+     * whichever stream next pops it from the shared pool. The result is attacker-controlled trailer injection into an
+     * unrelated request.
+     * <p>
+     * Generated by Claude Code with Sonnet 5
+     */
+    @Test
+    public void testCVE_2026_77762() throws Exception {
+        http2Connect();
+
+        ((AbstractHttp11Protocol<?>) http2Protocol.getHttp11Protocol()).setAllowedTrailerHeaders(TRAILER_HEADER_NAME);
+
+        // 1) Stream 3: announce a POST but never send (or read) a body. The
+        // "/noread" servlet completes its response immediately, so Tomcat's
+        // StreamProcessor will notice the request body was never fully read
+        // and reset the stream (NO_ERROR) once processing finishes.
+        sendHeadersNoBody(3, "/noread");
+
+        // 2) Without waiting for that reset, the (attacker-controlled) client
+        // immediately starts a *trailer* header block for the same stream,
+        // but withholds END_HEADERS. NoReadServlet sleeps briefly on its own
+        // (separate, worker) thread before completing, which gives the
+        // connection's parser thread -- blocked on nothing but a handful of
+        // already-buffered bytes -- more than enough time to process this
+        // frame first. Http2Parser.readHeadersFrame() points hpackDecoder's
+        // HeaderEmitter at the (still, for now, live) Stream 3 object, and
+        // Http2Parser.headersCurrentStream is left pointing at stream 3
+        // because the header block was not closed.
+        //
+        // This ordering matters: Stream.close() -> Stream.replace() swaps the
+        // Stream 3 entry in Http2UpgradeHandler's stream map for a lightweight
+        // stub the instant the reset happens, so a HEADERS frame for stream 3
+        // arriving *after* that point would be rejected outright (the parser
+        // would treat streamId 3 as an unknown/new stream and close the
+        // connection with a PROTOCOL_ERROR). Sending this before the reset
+        // sidesteps that: Http2Parser.readContinuationFrame() (step 3 below)
+        // never re-checks the stream map at all, it just trusts whatever
+        // object hpackDecoder.getHeaderEmitter() already holds -- which is
+        // why the swap does not save it once the header block is open.
+        sendOrphanedTrailerHeadersStart(3);
+
+        Assert.assertTrue("Server did not reset stream 3 for the unread request body", readUntilStreamReset(3));
+
+        // 3) ... arbitrarily later, from the parser's perspective (a
+        // client-controlled pause) ... the CONTINUATION that finally closes
+        // that header block arrives. Nothing severed the emitter in the
+        // meantime (that only happens on END_HEADERS or on an RST *received*
+        // from the client - neither happened here), so the header it carries
+        // is delivered straight into the stale Stream 3 object, which writes
+        // it into the shared, recycled Request via getMimeTrailerFields().
+        sendOrphanedTrailerContinuation(3, TRAILER_HEADER_NAME, INJECTED_TRAILER_VALUE);
+
+        // 4) A brand new stream is opened. Its Stream pops a Request/Response
+        // pair from the shared pool. Since nothing else has touched the pool,
+        // it pops back the exact same (now poisoned) Request that stream 3's
+        // orphaned trailer just wrote into.
+        output.setTraceBody(true);
+        sendSimplePostRequest(5, null);
+
+        // Stream 5 never sent any trailers of its own, so the only way its
+        // response can contain the injected value is via the reused,
+        // shared Request object.
+        boolean foundInjectedTrailer = false;
+        for (int i = 0; i < 20 && parser.readFrame(); i++) {
+            String trace = output.getTrace();
+            if (trace.contains(INJECTED_TRAILER_VALUE)) {
+                foundInjectedTrailer = true;
+            }
+            if (trace.contains("5-EndOfStream")) {
+                break;
+            }
+        }
+
+        // This is expected to fail until the HeaderEmitter held by
+        // HpackDecoder is severed when Tomcat itself resets a stream (not
+        // just on END_HEADERS or a client-sent RST for that stream), and/or
+        // Stream.emitHeader() is prevented from writing into a Request that
+        // has already been recycled.
+        Assert.assertFalse("Trailer value injected via stream 3's orphaned trailer CONTINUATION (sent after " +
+                "stream 3 was reset for an unread request body) leaked into stream 5's unrelated request/response " +
+                "via the shared, recycled Request object. Trace:\n" + output.getTrace(), foundInjectedTrailer);
+    }
+
+
+    private void sendHeadersNoBody(int streamId, String path) throws IOException {
+        byte[] headersFrameHeader = new byte[9];
+        ByteBuffer headersPayload = ByteBuffer.allocate(128);
+        byte[] dataFrameHeader = new byte[9];
+        ByteBuffer dataPayload = ByteBuffer.allocate(128);
+
+        buildPostRequest(headersFrameHeader, headersPayload, false, null, -1, path, dataFrameHeader, dataPayload, null,
+                false, streamId);
+        // Only write the HEADERS frame. Deliberately never write the DATA
+        // frame (and therefore never send END_STREAM) so the request body
+        // is left permanently incomplete from Tomcat's point of view.
+        writeFrame(headersFrameHeader, headersPayload);
+    }
+
+
+    private boolean readUntilStreamReset(int streamId) throws Exception {
+        String target = streamId + "-RST-[" + Http2Error.NO_ERROR.getCode() + "]\n";
+        for (int i = 0; i < 20 && parser.readFrame(); i++) {
+            if (output.getTrace().contains(target)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+
+    private void sendOrphanedTrailerHeadersStart(int streamId) throws IOException {
+        byte[] frameHeader = new byte[9];
+        ByteBuffer payload = ByteBuffer.allocate(0);
+
+        ByteUtil.setThreeBytes(frameHeader, 0, 0);
+        frameHeader[3] = FrameType.HEADERS.getIdByte();
+        // Flags: END_STREAM (0x01) only. Trailer HEADERS frames must set
+        // END_STREAM (Stream.receivedStartOfHeaders() sets a headerException
+        // otherwise, which would make emitHeader() a no-op below).
+        // Deliberately NOT setting END_HEADERS (0x04): this leaves the header
+        // block open on the wire, exactly as a client pausing before sending
+        // the CONTINUATION would.
+        frameHeader[4] = 0x01;
+        ByteUtil.set31Bits(frameHeader, 5, streamId);
+
+        writeFrame(frameHeader, payload);
+    }
+
+
+    private void sendOrphanedTrailerContinuation(int streamId, String name, String value) throws IOException {
+        byte[] frameHeader = new byte[9];
+        ByteBuffer payload = ByteBuffer.allocate(128);
+
+        MimeHeaders injected = new MimeHeaders();
+        injected.addValue(name).setString(value);
+        hpackEncoder.encode(injected, payload);
+        payload.flip();
+
+        ByteUtil.setThreeBytes(frameHeader, 0, payload.limit());
+        frameHeader[3] = FrameType.CONTINUATION.getIdByte();
+        // Flags: END_HEADERS (0x04). This finally closes the header block
+        // that was left open by sendOrphanedTrailerHeadersStart().
+        frameHeader[4] = 0x04;
+        ByteUtil.set31Bits(frameHeader, 5, streamId);
+
+        writeFrame(frameHeader, payload);
+    }
+
+
+    @Override
+    protected void configureAndStartWebApplication() throws LifecycleException {
+        Tomcat tomcat = getTomcatInstance();
+
+        Context ctxt = getProgrammaticRootContext();
+        Tomcat.addServlet(ctxt, "simple", new SimpleServlet());
+        ctxt.addServletMapping("/simple", "simple");
+        Tomcat.addServlet(ctxt, "noread", new NoReadServlet());
+        ctxt.addServletMapping("/noread", "noread");
+
+        tomcat.start();
+    }
+
+
+    private static class NoReadServlet extends HttpServlet {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+            // Give the test time to open (but not close) a trailer header
+            // block for this stream on the connection's parser thread before
+            // this (separate, worker) thread completes the response without
+            // having read any part of the (announced but never sent) request
+            // body -- which is what makes Tomcat reset this stream.
+            try {
+                Thread.sleep(200);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            resp.setStatus(HttpServletResponse.SC_OK);
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org


(tomcat) 02/03: Add test case for CVE-2026-77791

Posted by ma...@apache.org.
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 595d2608250d1aba308c090e9c91ddca2073faab
Author: Mark Thomas <ma...@apache.org>
AuthorDate: Fri Aug 21 13:40:26 2026 +0100

    Add test case for CVE-2026-77791
    
    Generated-by: Claude Code / Sonnet 5
---
 .../tomcat/security/TestSecurity2026WebSocket.java | 233 +++++++++++++++++++++
 1 file changed, 233 insertions(+)

diff --git a/test/org/apache/tomcat/security/TestSecurity2026WebSocket.java b/test/org/apache/tomcat/security/TestSecurity2026WebSocket.java
new file mode 100644
index 0000000000..43a34f5ccd
--- /dev/null
+++ b/test/org/apache/tomcat/security/TestSecurity2026WebSocket.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF 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
+ *
+ *      http://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 org.apache.tomcat.security;
+
+import java.lang.management.ManagementFactory;
+import java.net.URI;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import jakarta.websocket.ClientEndpoint;
+import jakarta.websocket.ContainerProvider;
+import jakarta.websocket.OnError;
+import jakarta.websocket.OnMessage;
+import jakarta.websocket.OnOpen;
+import jakarta.websocket.Session;
+import jakarta.websocket.WebSocketContainer;
+import jakarta.websocket.server.ServerEndpointConfig;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.servlets.DefaultServlet;
+import org.apache.catalina.startup.Tomcat;
+import org.apache.tomcat.websocket.Constants;
+import org.apache.tomcat.websocket.WebSocketBaseTest;
+import org.apache.tomcat.websocket.server.TesterEndpointConfig;
+
+import com.sun.management.OperatingSystemMXBean;
+
+public class TestSecurity2026WebSocket extends WebSocketBaseTest {
+
+    /*
+     * Statics for CVE-2026-77791 test
+     */
+    // Short so the test runs quickly, long enough to get a reliable CPU time sample.
+    private static final long BLOCKING_SEND_TIMEOUT_MILLIS = 2000;
+
+    // The server never sends a close frame back (that is the point of this test), so the client's own close
+    // handshake can never complete normally. Bound how long the client waits for one - otherwise it defaults to
+    // DEFAULT_SESSION_CLOSE_TIMEOUT (30s), which just adds dead time to the end of the test/teardown. This MUST stay
+    // comfortably longer than BLOCKING_SEND_TIMEOUT_MILLIS: if it fired first, the client would close its own socket
+    // while the measurement below is still in progress, which would prematurely unstick the server's pending write
+    // and invalidate the measurement.
+    private static final long CLIENT_SESSION_CLOSE_TIMEOUT_MILLIS = BLOCKING_SEND_TIMEOUT_MILLIS + 3000;
+
+    // A busy-wait of the kind under test spins continuously for the whole wait so its CPU time approaches 100% of the
+    // wall-clock time waited. A parked wait (Semaphore#tryAcquire(timeout)) consumes close to 0%. 50% comfortably
+    // separates the two while tolerating scheduling noise/GC on a shared test machine.
+    private static final double MAX_CPU_FRACTION = 0.5;
+
+    private static volatile CountDownLatch serverSendLatch;
+    private static volatile CountDownLatch clientReceiveLatch;
+    private static volatile Session serverSession;
+
+    /*
+     * WsRemoteEndpointImplServer#acquireMessagePartInProgressSemaphore() takes a special, spinning path (rather than
+     * parking on the semaphore, as the parent implementation does) when a CLOSE frame is being sent by a thread that
+     * already holds the socketWrapper lock. The loop released/re-acquires that lock and calls Thread.yield() but never
+     * parks, so it consumes CPU for as long as the pending data message write it is waiting on remains stuck - up to the
+     * full blocking send timeout.
+     *
+     * This test stalls a data message write by never reading it on the client (simulating a client with a zero TCP
+     * receive window), triggers a client-initiated close and measures how much CPU time the JVM consumes while the
+     * server is forced to wait for the stuck write to give up messagePartInProgress. A correctly parking wait consumes
+     * close to no CPU for that duration; the busy-wait described above consumes CPU proportional to wall-clock time.
+     * <p>
+     * This test is expected to fail until acquireMessagePartInProgressSemaphore() in WsRemoteEndpointImplServer parks
+     * (e.g. via Semaphore#tryAcquire(timeout, unit), as the parent implementation in WsRemoteEndpointImplBase already
+     * does) rather than spinning on tryAcquire() + Thread.yield() while repeatedly releasing/re-acquiring the socket
+     * lock.
+     * <p>
+     * Generated by Claude Code with Sonnet 5.
+     */
+    @Test
+    public void testCVE_2026_77791() throws Exception {
+        serverSendLatch = new CountDownLatch(1);
+        // Never released. The server's pending data message write must never complete, forcing
+        // acquireMessagePartInProgressSemaphore() to wait for the full blocking send timeout.
+        clientReceiveLatch = new CountDownLatch(1);
+        serverSession = null;
+
+        Tomcat tomcat = getTomcatInstance();
+
+        // No file system docBase required
+        Context ctx = getProgrammaticRootContext();
+        ctx.addApplicationListener(BusyWaitConfig.class.getName());
+        Tomcat.addServlet(ctx, "default", new DefaultServlet());
+        ctx.addServletMapping("/", "default");
+
+        WebSocketContainer wsContainer = ContainerProvider.getWebSocketContainer();
+        // The client container's own background thread (which notices the client's session close timeout expiring
+        // and finally tears the client-side session/connection down) only runs once every
+        // Constants.DEFAULT_PROCESS_PERIOD (10) seconds by default. Without this, teardown below can be stuck
+        // waiting on that for up to 10s regardless of how small CLIENT_SESSION_CLOSE_TIMEOUT_MILLIS is.
+        ((org.apache.tomcat.websocket.WsWebSocketContainer) wsContainer).setProcessPeriod(1);
+
+        tomcat.start();
+
+        BusyWaitClient client = new BusyWaitClient();
+        URI uri = new URI("ws://localhost:" + getPort() + BusyWaitConfig.PATH);
+
+        Session clientSession = wsContainer.connectToServer(client, uri);
+
+        // Wait for the server's send loop to block. At that point the socket buffers are full, the client is not
+        // reading and messagePartInProgress is held by the stuck write.
+        Assert.assertTrue("Server never blocked trying to send", serverSendLatch.await(10, TimeUnit.SECONDS));
+
+        // Bound how long the server will wait to send its own close response so the test completes quickly. This is
+        // read from the SERVER session because it is the server's WsRemoteEndpointImplServer that will be stuck in
+        // acquireMessagePartInProgressSemaphore() when it processes the client's close frame.
+        serverSession.getUserProperties().put(Constants.BLOCKING_SEND_TIMEOUT_PROPERTY,
+                Long.valueOf(BLOCKING_SEND_TIMEOUT_MILLIS));
+
+        clientSession.getUserProperties().put(Constants.SESSION_CLOSE_TIMEOUT_PROPERTY,
+                Long.valueOf(CLIENT_SESSION_CLOSE_TIMEOUT_MILLIS));
+
+        OperatingSystemMXBean osBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
+
+        long wallBefore = System.nanoTime();
+        long cpuBefore = osBean.getProcessCpuTime();
+
+        // Client-initiated close of an OPEN session. On the server this is processed on a container thread via
+        // WsFrameBase.processDataControl -> WsSession.onClose -> sendCloseMessage(), which blocks in
+        // acquireMessagePartInProgressSemaphore(OPCODE_CLOSE, ...) until the stuck write completes/fails or the
+        // blocking send timeout (set above) expires.
+        clientSession.close();
+
+        // The stuck write is never released, so the server session can only leave CLOSING once the blocking send
+        // timeout above has expired. Poll with a large safety margin over that timeout.
+        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
+        while (serverSession.isOpen() && System.nanoTime() < deadline) {
+            Thread.sleep(20);
+        }
+
+        long cpuAfter = osBean.getProcessCpuTime();
+        long wallAfter = System.nanoTime();
+
+        // The measurement is over. Release the client so it stops blocking (permanently, until the JVM exits
+        // otherwise) inside onMessage() and the client-side session/connection can shut down cleanly and quickly.
+        clientReceiveLatch.countDown();
+
+        Assert.assertFalse("Server session should have closed once the blocking send timeout expired",
+                serverSession.isOpen());
+
+        long wallNanos = wallAfter - wallBefore;
+        long cpuNanos = cpuAfter - cpuBefore;
+
+        // Sanity check that the wait was actually exercised (and this didn't, for example, close instantly because
+        // the stuck write unexpectedly completed).
+        Assert.assertTrue("Close completed in " + TimeUnit.NANOSECONDS.toMillis(wallNanos) +
+                "ms, too quickly for the blocking send wait to have been exercised",
+                wallNanos >= TimeUnit.MILLISECONDS.toNanos(BLOCKING_SEND_TIMEOUT_MILLIS / 2));
+
+        double cpuFraction = (double) cpuNanos / wallNanos;
+        Assert.assertTrue(
+                "Waiting to send the close response consumed " + TimeUnit.NANOSECONDS.toMillis(cpuNanos) +
+                        "ms of CPU time over a " + TimeUnit.NANOSECONDS.toMillis(wallNanos) +
+                        "ms wall-clock wait (" + Math.round(cpuFraction * 100) +
+                        "%). A busy-wait approaches 100%; a park should be close to 0%.",
+                cpuFraction < MAX_CPU_FRACTION);
+    }
+
+    public static class BusyWaitConfig extends TesterEndpointConfig {
+
+        public static final String PATH = "/testCloseBusyWait";
+
+        @Override
+        protected ServerEndpointConfig getServerEndpointConfig() {
+            return ServerEndpointConfig.Builder.create(BusyWaitEndpoint.class, PATH).build();
+        }
+    }
+
+    public static class BusyWaitEndpoint {
+
+        // 8k message
+        private static final String MSG = "a".repeat(1024 * 8);
+
+        @OnOpen
+        public void onOpen(Session session) {
+            serverSession = session;
+            // Send messages to the client until the write blocks. Must not be done on the container thread as it
+            // needs to keep running while the client stalls the read.
+            new Thread(() -> {
+                while (true) {
+                    Future<Void> sendMessageFuture = session.getAsyncRemote().sendText(MSG);
+                    try {
+                        sendMessageFuture.get(2, TimeUnit.SECONDS);
+                    } catch (InterruptedException | ExecutionException | TimeoutException e) {
+                        break;
+                    }
+                }
+                serverSendLatch.countDown();
+            }).start();
+        }
+
+        @OnError
+        public void onError(@SuppressWarnings("unused") Throwable t) {
+            // Expected once the connection is torn down. Swallow the error.
+        }
+    }
+
+    @ClientEndpoint
+    public static class BusyWaitClient {
+
+        @OnMessage
+        public void onMessage(@SuppressWarnings("unused") String msg) {
+            try {
+                // Never read again, simulating a client with a zero TCP receive window.
+                clientReceiveLatch.await();
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org