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 <[email protected]> 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: [email protected] For additional commands, e-mail: [email protected]
