Apache Tomcat: Leaking of unrelated request bodies in default error page
Description
Generation of Error Message Containing Sensitive Information vulnerability in Apache Tomcat.This issue affects Apache Tomcat: from 8.5.7 through 8.5.63, from 9.0.0-M11 through 9.0.43. Other, EOL versions may also be affected.
Users are recommended to upgrade to version 8.5.64 onwards or 9.0.44 onwards, which contain a fix for the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Apache Tomcat 8.5.7-8.5.63 and 9.0.0-M11-9.0.43 may leak sensitive information in error messages when a client drops the connection during async request body reading.
Vulnerability
CVE-2024-21733 is an information disclosure vulnerability in Apache Tomcat's async request processing. When a client closes the connection before fully writing the request body, and an async thread is reading that body, an I/O error occurs. The error handling can generate error messages that contain sensitive information, such as internal paths or data, which may be exposed to the client [1][2].
The root cause lies in the handling of ReadListener.onError() – the fix ensures this callback is properly invoked when the connection is dropped, and changes the visibility of TrackingListener from private to public to support correct error propagation [3][4].
Exploitation
The attacker must be in a position to send an HTTP request (e.g., POST) to a vulnerable Tomcat instance that uses asynchronous servlets. The exploit sequence requires: entering the servlet's service() method, starting async, reading a partial body, then closing the client connection. This triggers an I/O error, and the error message may leak sensitive information [3][4]. No authentication is required, but the application must use async processing.
Impact
Successful exploitation can lead to the disclosure of sensitive information, potentially including file paths, configuration details, or other internal data. The CVSS score (not provided) would likely be medium due to the specific prerequisites. The vulnerability does not allow code execution or privilege escalation directly.
Mitigation
Apache has fixed this issue in Tomcat 8.5.64 and 9.0.44. Users are strongly recommended to upgrade to these or later versions [1][2]. Note that Tomcat 8.5.x has reached end-of-life, so users should plan migration to 9.0.x or later for ongoing security support [2].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.apache.tomcat:tomcat-coyoteMaven | >= 9.0.0-M11, < 9.0.44 | 9.0.44 |
org.apache.tomcat.embed:tomcat-embed-coreMaven | >= 8.5.7, < 8.5.64 | 8.5.64 |
Affected products
8- osv-coords7 versionspkg:bitnami/tomcatpkg:maven/org.apache.tomcat.embed/tomcat-embed-corepkg:maven/org.apache.tomcat/tomcat-coyotepkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%2012%20SP5pkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%2012%20SP5-LTSSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%20for%20SAP%20Applications%2012%20SP5pkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%20LTSS%20Extended%20Security%2012%20SP5
>= 8.5.7, < 8.5.98+ 6 more
- (no CPE)range: >= 8.5.7, < 8.5.98
- (no CPE)range: >= 8.5.7, < 8.5.64
- (no CPE)range: >= 9.0.0-M11, < 9.0.44
- (no CPE)range: < 9.0.36-3.121.1
- (no CPE)range: < 9.0.115-3.160.1
- (no CPE)range: < 9.0.36-3.121.1
- (no CPE)range: < 9.0.115-3.160.1
- Apache Software Foundation/Apache Tomcatv5Range: 8.5.7
Patches
2ce4b154e7b48Ensure ReadListener.onError() is fired if client drops the connection
4 files changed · +388 −14
java/org/apache/coyote/http11/Http11InputBuffer.java+21 −13 modified@@ -761,11 +761,13 @@ void init(SocketWrapperBase<?> socketWrapper) { private boolean fill(boolean block) throws IOException { if (log.isDebugEnabled()) { - log.debug("Before fill(): [" + parsingHeader + + log.debug("Before fill(): parsingHeader: [" + parsingHeader + "], parsingRequestLine: [" + parsingRequestLine + "], parsingRequestLinePhase: [" + parsingRequestLinePhase + "], parsingRequestLineStart: [" + parsingRequestLineStart + - "], byteBuffer.position() [" + byteBuffer.position() + "]"); + "], byteBuffer.position(): [" + byteBuffer.position() + + "], byteBuffer.limit(): [" + byteBuffer.limit() + + "], end: [" + end + "]"); } if (parsingHeader) { @@ -780,19 +782,25 @@ private boolean fill(boolean block) throws IOException { byteBuffer.limit(end).position(end); } - byteBuffer.mark(); - if (byteBuffer.position() < byteBuffer.limit()) { - byteBuffer.position(byteBuffer.limit()); - } - byteBuffer.limit(byteBuffer.capacity()); - SocketWrapperBase<?> socketWrapper = this.wrapper; int nRead = -1; - if (socketWrapper != null) { - nRead = socketWrapper.read(block, byteBuffer); - } else { - throw new CloseNowException(sm.getString("iib.eof.error")); + byteBuffer.mark(); + try { + if (byteBuffer.position() < byteBuffer.limit()) { + byteBuffer.position(byteBuffer.limit()); + } + byteBuffer.limit(byteBuffer.capacity()); + SocketWrapperBase<?> socketWrapper = this.wrapper; + if (socketWrapper != null) { + nRead = socketWrapper.read(block, byteBuffer); + } else { + throw new CloseNowException(sm.getString("iib.eof.error")); + } + } finally { + // Ensure that the buffer limit and position are returned to a + // consistent "ready for read" state if an error occurs during in + // the above code block. + byteBuffer.limit(byteBuffer.position()).reset(); } - byteBuffer.limit(byteBuffer.position()).reset(); if (log.isDebugEnabled()) { log.debug("Received ["
test/org/apache/catalina/core/TestAsyncContextImpl.java+169 −1 modified@@ -17,6 +17,7 @@ package org.apache.catalina.core; import java.io.IOException; +import java.io.InputStream; import java.io.PrintWriter; import java.net.URI; import java.net.URISyntaxException; @@ -866,7 +867,7 @@ public void run() { } } - private static class TrackingListener implements AsyncListener { + public static class TrackingListener implements AsyncListener { private final boolean completeOnError; private final boolean completeOnTimeout; @@ -3016,4 +3017,171 @@ public void run() { } } + + /* + * Tests an error on an async thread when the client closes the connection + * before fully writing the request body. + * + * Required sequence is: + * - enter Servlet's service() method + * - startAsync() + * - start async thread + * - read partial body + * - close client connection + * - read on async thread -> I/O error + * - exit Servlet's service() method + * + * This test makes extensive use of instance fields in the Servlet that + * would normally be considered very poor practice. It is only safe in this + * test as the Servlet only processes a single request. + */ + @Test + public void testCanceledPost() throws Exception { + CountDownLatch partialReadLatch = new CountDownLatch(1); + CountDownLatch clientCloseLatch = new CountDownLatch(1); + CountDownLatch threadCompleteLatch = new CountDownLatch(1); + + AtomicBoolean testFailed = new AtomicBoolean(true); + + // Setup Tomcat instance + Tomcat tomcat = getTomcatInstance(); + + // No file system docBase required + Context ctx = tomcat.addContext("", null); + + PostServlet postServlet = new PostServlet(partialReadLatch, clientCloseLatch, threadCompleteLatch, testFailed); + Wrapper wrapper = Tomcat.addServlet(ctx, "postServlet", postServlet); + wrapper.setAsyncSupported(true); + ctx.addServletMappingDecoded("/*", "postServlet"); + + tomcat.start(); + + PostClient client = new PostClient(); + client.setPort(getPort()); + client.setRequest(new String[] { "POST / HTTP/1.1" + SimpleHttpClient.CRLF + + "Host: localhost:" + SimpleHttpClient.CRLF + + "Content-Length: 100" + SimpleHttpClient.CRLF + + SimpleHttpClient.CRLF + + "This is 16 bytes" + }); + client.connect(); + client.sendRequest(); + + // Wait server to read partial request body + partialReadLatch.await(); + + client.disconnect(); + + clientCloseLatch.countDown(); + + threadCompleteLatch.await(); + + Assert.assertFalse(testFailed.get()); + } + + + private static final class PostClient extends SimpleHttpClient { + + @Override + public boolean isResponseBodyOK() { + return true; + } + } + + + private static final class PostServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + + private final transient CountDownLatch partialReadLatch; + private final transient CountDownLatch clientCloseLatch; + private final transient CountDownLatch threadCompleteLatch; + private final AtomicBoolean testFailed; + + public PostServlet(CountDownLatch doPostLatch, CountDownLatch clientCloseLatch, + CountDownLatch threadCompleteLatch, AtomicBoolean testFailed) { + this.partialReadLatch = doPostLatch; + this.clientCloseLatch = clientCloseLatch; + this.threadCompleteLatch = threadCompleteLatch; + this.testFailed = testFailed; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + + AsyncContext ac = req.startAsync(); + Thread t = new PostServletThread(ac, partialReadLatch, clientCloseLatch, threadCompleteLatch, testFailed); + t.start(); + + try { + threadCompleteLatch.await(); + } catch (InterruptedException e) { + // Ignore + } + } + } + + + private static final class PostServletThread extends Thread { + + private final AsyncContext ac; + private final CountDownLatch partialReadLatch; + private final CountDownLatch clientCloseLatch; + private final CountDownLatch threadCompleteLatch; + private final AtomicBoolean testFailed; + + public PostServletThread(AsyncContext ac, CountDownLatch partialReadLatch, CountDownLatch clientCloseLatch, + CountDownLatch threadCompleteLatch, AtomicBoolean testFailed) { + this.ac = ac; + this.partialReadLatch = partialReadLatch; + this.clientCloseLatch = clientCloseLatch; + this.threadCompleteLatch = threadCompleteLatch; + this.testFailed = testFailed; + } + + @Override + public void run() { + try { + int bytesRead = 0; + byte[] buffer = new byte[32]; + InputStream is = null; + + try { + is = ac.getRequest().getInputStream(); + + // Read the partial request body + while (bytesRead < 16) { + int read = is.read(buffer); + if (read == -1) { + // Error condition + return; + } + bytesRead += read; + } + } catch (IOException ioe) { + // Error condition + return; + } finally { + partialReadLatch.countDown(); + } + + // Wait for client to close connection + clientCloseLatch.await(); + + // Read again + try { + is.read(); + } catch (IOException e) { + e.printStackTrace(); + // Required. Clear the error marker. + testFailed.set(false); + } + } catch (InterruptedException e) { + // Ignore + } finally { + threadCompleteLatch.countDown(); + } + } + } }
test/org/apache/catalina/nonblocking/TestNonBlockingAPI.java+192 −0 modified@@ -32,7 +32,10 @@ import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.LogManager; import javax.net.SocketFactory; import javax.servlet.AsyncContext; @@ -45,6 +48,7 @@ import javax.servlet.ServletOutputStream; import javax.servlet.WriteListener; import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -53,7 +57,9 @@ import org.junit.Test; import org.apache.catalina.Context; +import org.apache.catalina.Wrapper; import org.apache.catalina.startup.BytesStreamer; +import org.apache.catalina.startup.SimpleHttpClient; import org.apache.catalina.startup.TesterServlet; import org.apache.catalina.startup.Tomcat; import org.apache.catalina.startup.TomcatBaseTest; @@ -1113,4 +1119,190 @@ public void onError(Throwable t) { } } + + + /* + * Tests an error on an non-blocking read when the client closes the + * connection before fully writing the request body. + * + * Required sequence is: + * - enter Servlet's service() method + * - startAsync() + * - configure non-blocking read + * - read partial body + * - close client connection + * - error is triggered + * - exit Servlet's service() method + * + * This test makes extensive use of instance fields in the Servlet that + * would normally be considered very poor practice. It is only safe in this + * test as the Servlet only processes a single request. + */ + @Test + public void testCanceledPost() throws Exception { + + LogManager.getLogManager().getLogger("org.apache.coyote").setLevel(Level.ALL); + LogManager.getLogManager().getLogger("org.apache.tomcat.util.net").setLevel(Level.ALL); + + CountDownLatch partialReadLatch = new CountDownLatch(1); + CountDownLatch completeLatch = new CountDownLatch(1); + + AtomicBoolean testFailed = new AtomicBoolean(true); + + // Setup Tomcat instance + Tomcat tomcat = getTomcatInstance(); + + // No file system docBase required + Context ctx = tomcat.addContext("", null); + + PostServlet postServlet = new PostServlet(partialReadLatch, completeLatch, testFailed); + Wrapper wrapper = Tomcat.addServlet(ctx, "postServlet", postServlet); + wrapper.setAsyncSupported(true); + ctx.addServletMappingDecoded("/*", "postServlet"); + + tomcat.start(); + + PostClient client = new PostClient(); + client.setPort(getPort()); + client.setRequest(new String[] { "POST / HTTP/1.1" + SimpleHttpClient.CRLF + + "Host: localhost:" + SimpleHttpClient.CRLF + + "Content-Length: 100" + SimpleHttpClient.CRLF + + SimpleHttpClient.CRLF + + "This is 16 bytes" + }); + client.connect(); + client.sendRequest(); + + // Wait server to read partial request body + partialReadLatch.await(); + + client.disconnect(); + + completeLatch.await(); + + Assert.assertFalse(testFailed.get()); + } + + + private static final class PostClient extends SimpleHttpClient { + + @Override + public boolean isResponseBodyOK() { + return true; + } + } + + + private static final class PostServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + + private final transient CountDownLatch partialReadLatch; + private final transient CountDownLatch completeLatch; + private final AtomicBoolean testFailed; + + public PostServlet(CountDownLatch doPostLatch, CountDownLatch completeLatch, AtomicBoolean testFailed) { + this.partialReadLatch = doPostLatch; + this.completeLatch = completeLatch; + this.testFailed = testFailed; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + + AsyncContext ac = req.startAsync(); + ac.setTimeout(-1); + CanceledPostAsyncListener asyncListener = new CanceledPostAsyncListener(completeLatch); + ac.addListener(asyncListener); + + CanceledPostReadListener readListener = new CanceledPostReadListener(ac, partialReadLatch, testFailed); + req.getInputStream().setReadListener(readListener); + } + } + + + private static final class CanceledPostAsyncListener implements AsyncListener { + + private final transient CountDownLatch completeLatch; + + public CanceledPostAsyncListener(CountDownLatch completeLatch) { + this.completeLatch = completeLatch; + } + + @Override + public void onComplete(AsyncEvent event) throws IOException { + System.out.println("complete"); + completeLatch.countDown(); + } + + @Override + public void onTimeout(AsyncEvent event) throws IOException { + System.out.println("onTimeout"); + } + + @Override + public void onError(AsyncEvent event) throws IOException { + System.out.println("onError-async"); + } + + @Override + public void onStartAsync(AsyncEvent event) throws IOException { + System.out.println("onStartAsync"); + } + } + + private static final class CanceledPostReadListener implements ReadListener { + + private final AsyncContext ac; + private final CountDownLatch partialReadLatch; + private final AtomicBoolean testFailed; + private int totalRead = 0; + + public CanceledPostReadListener(AsyncContext ac, CountDownLatch partialReadLatch, AtomicBoolean testFailed) { + this.ac = ac; + this.partialReadLatch = partialReadLatch; + this.testFailed = testFailed; + } + + @Override + public void onDataAvailable() throws IOException { + ServletInputStream sis = ac.getRequest().getInputStream(); + boolean isReady; + + byte[] buffer = new byte[32]; + do { + if (partialReadLatch.getCount() == 0) { + System.out.println("debug"); + } + int bytesRead = sis.read(buffer); + + if (bytesRead == -1) { + return; + } + totalRead += bytesRead; + isReady = sis.isReady(); + System.out.println("Read [" + bytesRead + + "], buffer [" + new String(buffer, 0, bytesRead, StandardCharsets.UTF_8) + + "], total read [" + totalRead + + "], isReady [" + isReady + "]"); + } while (isReady); + if (totalRead == 16) { + partialReadLatch.countDown(); + } + } + + @Override + public void onAllDataRead() throws IOException { + ac.complete(); + } + + @Override + public void onError(Throwable throwable) { + throwable.printStackTrace(); + // This is the expected behaviour so clear the failed flag. + testFailed.set(false); + ac.complete(); + } + } }
webapps/docs/changelog.xml+6 −0 modified@@ -132,6 +132,12 @@ Avoid NullPointerException when a secure channel is closed before the SSL engine was initialized. (remm) </fix> + <fix> + Ensure that the <code>ReadListener</code>'s <code>onError()</code> event + is triggered if the client closes the connection before sending the + entire request body and the server is ready the request body using + non-blocking I/O. (markt) + </fix> </changelog> </subsection> <subsection name="Web applications">
86ccc4394086Ensure ReadListener.onError() is fired if client drops the connection
4 files changed · +388 −14
java/org/apache/coyote/http11/Http11InputBuffer.java+21 −13 modified@@ -761,11 +761,13 @@ void init(SocketWrapperBase<?> socketWrapper) { private boolean fill(boolean block) throws IOException { if (log.isDebugEnabled()) { - log.debug("Before fill(): [" + parsingHeader + + log.debug("Before fill(): parsingHeader: [" + parsingHeader + "], parsingRequestLine: [" + parsingRequestLine + "], parsingRequestLinePhase: [" + parsingRequestLinePhase + "], parsingRequestLineStart: [" + parsingRequestLineStart + - "], byteBuffer.position() [" + byteBuffer.position() + "]"); + "], byteBuffer.position(): [" + byteBuffer.position() + + "], byteBuffer.limit(): [" + byteBuffer.limit() + + "], end: [" + end + "]"); } if (parsingHeader) { @@ -780,19 +782,25 @@ private boolean fill(boolean block) throws IOException { byteBuffer.limit(end).position(end); } - byteBuffer.mark(); - if (byteBuffer.position() < byteBuffer.limit()) { - byteBuffer.position(byteBuffer.limit()); - } - byteBuffer.limit(byteBuffer.capacity()); - SocketWrapperBase<?> socketWrapper = this.wrapper; int nRead = -1; - if (socketWrapper != null) { - nRead = socketWrapper.read(block, byteBuffer); - } else { - throw new CloseNowException(sm.getString("iib.eof.error")); + byteBuffer.mark(); + try { + if (byteBuffer.position() < byteBuffer.limit()) { + byteBuffer.position(byteBuffer.limit()); + } + byteBuffer.limit(byteBuffer.capacity()); + SocketWrapperBase<?> socketWrapper = this.wrapper; + if (socketWrapper != null) { + nRead = socketWrapper.read(block, byteBuffer); + } else { + throw new CloseNowException(sm.getString("iib.eof.error")); + } + } finally { + // Ensure that the buffer limit and position are returned to a + // consistent "ready for read" state if an error occurs during in + // the above code block. + byteBuffer.limit(byteBuffer.position()).reset(); } - byteBuffer.limit(byteBuffer.position()).reset(); if (log.isDebugEnabled()) { log.debug("Received ["
test/org/apache/catalina/core/TestAsyncContextImpl.java+169 −1 modified@@ -17,6 +17,7 @@ package org.apache.catalina.core; import java.io.IOException; +import java.io.InputStream; import java.io.PrintWriter; import java.net.URI; import java.net.URISyntaxException; @@ -866,7 +867,7 @@ public void run() { } } - private static class TrackingListener implements AsyncListener { + public static class TrackingListener implements AsyncListener { private final boolean completeOnError; private final boolean completeOnTimeout; @@ -3016,4 +3017,171 @@ public void run() { } } + + /* + * Tests an error on an async thread when the client closes the connection + * before fully writing the request body. + * + * Required sequence is: + * - enter Servlet's service() method + * - startAsync() + * - start async thread + * - read partial body + * - close client connection + * - read on async thread -> I/O error + * - exit Servlet's service() method + * + * This test makes extensive use of instance fields in the Servlet that + * would normally be considered very poor practice. It is only safe in this + * test as the Servlet only processes a single request. + */ + @Test + public void testCanceledPost() throws Exception { + CountDownLatch partialReadLatch = new CountDownLatch(1); + CountDownLatch clientCloseLatch = new CountDownLatch(1); + CountDownLatch threadCompleteLatch = new CountDownLatch(1); + + AtomicBoolean testFailed = new AtomicBoolean(true); + + // Setup Tomcat instance + Tomcat tomcat = getTomcatInstance(); + + // No file system docBase required + Context ctx = tomcat.addContext("", null); + + PostServlet postServlet = new PostServlet(partialReadLatch, clientCloseLatch, threadCompleteLatch, testFailed); + Wrapper wrapper = Tomcat.addServlet(ctx, "postServlet", postServlet); + wrapper.setAsyncSupported(true); + ctx.addServletMappingDecoded("/*", "postServlet"); + + tomcat.start(); + + PostClient client = new PostClient(); + client.setPort(getPort()); + client.setRequest(new String[] { "POST / HTTP/1.1" + SimpleHttpClient.CRLF + + "Host: localhost:" + SimpleHttpClient.CRLF + + "Content-Length: 100" + SimpleHttpClient.CRLF + + SimpleHttpClient.CRLF + + "This is 16 bytes" + }); + client.connect(); + client.sendRequest(); + + // Wait server to read partial request body + partialReadLatch.await(); + + client.disconnect(); + + clientCloseLatch.countDown(); + + threadCompleteLatch.await(); + + Assert.assertFalse(testFailed.get()); + } + + + private static final class PostClient extends SimpleHttpClient { + + @Override + public boolean isResponseBodyOK() { + return true; + } + } + + + private static final class PostServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + + private final transient CountDownLatch partialReadLatch; + private final transient CountDownLatch clientCloseLatch; + private final transient CountDownLatch threadCompleteLatch; + private final AtomicBoolean testFailed; + + public PostServlet(CountDownLatch doPostLatch, CountDownLatch clientCloseLatch, + CountDownLatch threadCompleteLatch, AtomicBoolean testFailed) { + this.partialReadLatch = doPostLatch; + this.clientCloseLatch = clientCloseLatch; + this.threadCompleteLatch = threadCompleteLatch; + this.testFailed = testFailed; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + + AsyncContext ac = req.startAsync(); + Thread t = new PostServletThread(ac, partialReadLatch, clientCloseLatch, threadCompleteLatch, testFailed); + t.start(); + + try { + threadCompleteLatch.await(); + } catch (InterruptedException e) { + // Ignore + } + } + } + + + private static final class PostServletThread extends Thread { + + private final AsyncContext ac; + private final CountDownLatch partialReadLatch; + private final CountDownLatch clientCloseLatch; + private final CountDownLatch threadCompleteLatch; + private final AtomicBoolean testFailed; + + public PostServletThread(AsyncContext ac, CountDownLatch partialReadLatch, CountDownLatch clientCloseLatch, + CountDownLatch threadCompleteLatch, AtomicBoolean testFailed) { + this.ac = ac; + this.partialReadLatch = partialReadLatch; + this.clientCloseLatch = clientCloseLatch; + this.threadCompleteLatch = threadCompleteLatch; + this.testFailed = testFailed; + } + + @Override + public void run() { + try { + int bytesRead = 0; + byte[] buffer = new byte[32]; + InputStream is = null; + + try { + is = ac.getRequest().getInputStream(); + + // Read the partial request body + while (bytesRead < 16) { + int read = is.read(buffer); + if (read == -1) { + // Error condition + return; + } + bytesRead += read; + } + } catch (IOException ioe) { + // Error condition + return; + } finally { + partialReadLatch.countDown(); + } + + // Wait for client to close connection + clientCloseLatch.await(); + + // Read again + try { + is.read(); + } catch (IOException e) { + e.printStackTrace(); + // Required. Clear the error marker. + testFailed.set(false); + } + } catch (InterruptedException e) { + // Ignore + } finally { + threadCompleteLatch.countDown(); + } + } + } }
test/org/apache/catalina/nonblocking/TestNonBlockingAPI.java+192 −0 modified@@ -32,7 +32,10 @@ import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.LogManager; import javax.net.SocketFactory; @@ -46,6 +49,7 @@ import jakarta.servlet.ServletOutputStream; import jakarta.servlet.WriteListener; import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -54,7 +58,9 @@ import org.junit.Test; import org.apache.catalina.Context; +import org.apache.catalina.Wrapper; import org.apache.catalina.startup.BytesStreamer; +import org.apache.catalina.startup.SimpleHttpClient; import org.apache.catalina.startup.TesterServlet; import org.apache.catalina.startup.Tomcat; import org.apache.catalina.startup.TomcatBaseTest; @@ -1114,4 +1120,190 @@ public void onError(Throwable t) { } } + + + /* + * Tests an error on an non-blocking read when the client closes the + * connection before fully writing the request body. + * + * Required sequence is: + * - enter Servlet's service() method + * - startAsync() + * - configure non-blocking read + * - read partial body + * - close client connection + * - error is triggered + * - exit Servlet's service() method + * + * This test makes extensive use of instance fields in the Servlet that + * would normally be considered very poor practice. It is only safe in this + * test as the Servlet only processes a single request. + */ + @Test + public void testCanceledPost() throws Exception { + + LogManager.getLogManager().getLogger("org.apache.coyote").setLevel(Level.ALL); + LogManager.getLogManager().getLogger("org.apache.tomcat.util.net").setLevel(Level.ALL); + + CountDownLatch partialReadLatch = new CountDownLatch(1); + CountDownLatch completeLatch = new CountDownLatch(1); + + AtomicBoolean testFailed = new AtomicBoolean(true); + + // Setup Tomcat instance + Tomcat tomcat = getTomcatInstance(); + + // No file system docBase required + Context ctx = tomcat.addContext("", null); + + PostServlet postServlet = new PostServlet(partialReadLatch, completeLatch, testFailed); + Wrapper wrapper = Tomcat.addServlet(ctx, "postServlet", postServlet); + wrapper.setAsyncSupported(true); + ctx.addServletMappingDecoded("/*", "postServlet"); + + tomcat.start(); + + PostClient client = new PostClient(); + client.setPort(getPort()); + client.setRequest(new String[] { "POST / HTTP/1.1" + SimpleHttpClient.CRLF + + "Host: localhost:" + SimpleHttpClient.CRLF + + "Content-Length: 100" + SimpleHttpClient.CRLF + + SimpleHttpClient.CRLF + + "This is 16 bytes" + }); + client.connect(); + client.sendRequest(); + + // Wait server to read partial request body + partialReadLatch.await(); + + client.disconnect(); + + completeLatch.await(); + + Assert.assertFalse(testFailed.get()); + } + + + private static final class PostClient extends SimpleHttpClient { + + @Override + public boolean isResponseBodyOK() { + return true; + } + } + + + private static final class PostServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + + private final transient CountDownLatch partialReadLatch; + private final transient CountDownLatch completeLatch; + private final AtomicBoolean testFailed; + + public PostServlet(CountDownLatch doPostLatch, CountDownLatch completeLatch, AtomicBoolean testFailed) { + this.partialReadLatch = doPostLatch; + this.completeLatch = completeLatch; + this.testFailed = testFailed; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + + AsyncContext ac = req.startAsync(); + ac.setTimeout(-1); + CanceledPostAsyncListener asyncListener = new CanceledPostAsyncListener(completeLatch); + ac.addListener(asyncListener); + + CanceledPostReadListener readListener = new CanceledPostReadListener(ac, partialReadLatch, testFailed); + req.getInputStream().setReadListener(readListener); + } + } + + + private static final class CanceledPostAsyncListener implements AsyncListener { + + private final transient CountDownLatch completeLatch; + + public CanceledPostAsyncListener(CountDownLatch completeLatch) { + this.completeLatch = completeLatch; + } + + @Override + public void onComplete(AsyncEvent event) throws IOException { + System.out.println("complete"); + completeLatch.countDown(); + } + + @Override + public void onTimeout(AsyncEvent event) throws IOException { + System.out.println("onTimeout"); + } + + @Override + public void onError(AsyncEvent event) throws IOException { + System.out.println("onError-async"); + } + + @Override + public void onStartAsync(AsyncEvent event) throws IOException { + System.out.println("onStartAsync"); + } + } + + private static final class CanceledPostReadListener implements ReadListener { + + private final AsyncContext ac; + private final CountDownLatch partialReadLatch; + private final AtomicBoolean testFailed; + private int totalRead = 0; + + public CanceledPostReadListener(AsyncContext ac, CountDownLatch partialReadLatch, AtomicBoolean testFailed) { + this.ac = ac; + this.partialReadLatch = partialReadLatch; + this.testFailed = testFailed; + } + + @Override + public void onDataAvailable() throws IOException { + ServletInputStream sis = ac.getRequest().getInputStream(); + boolean isReady; + + byte[] buffer = new byte[32]; + do { + if (partialReadLatch.getCount() == 0) { + System.out.println("debug"); + } + int bytesRead = sis.read(buffer); + + if (bytesRead == -1) { + return; + } + totalRead += bytesRead; + isReady = sis.isReady(); + System.out.println("Read [" + bytesRead + + "], buffer [" + new String(buffer, 0, bytesRead, StandardCharsets.UTF_8) + + "], total read [" + totalRead + + "], isReady [" + isReady + "]"); + } while (isReady); + if (totalRead == 16) { + partialReadLatch.countDown(); + } + } + + @Override + public void onAllDataRead() throws IOException { + ac.complete(); + } + + @Override + public void onError(Throwable throwable) { + throwable.printStackTrace(); + // This is the expected behaviour so clear the failed flag. + testFailed.set(false); + ac.complete(); + } + } }
webapps/docs/changelog.xml+6 −0 modified@@ -144,6 +144,12 @@ Avoid NullPointerException when a secure channel is closed before the SSL engine was initialized. (remm) </fix> + <fix> + Ensure that the <code>ReadListener</code>'s <code>onError()</code> event + is triggered if the client closes the connection before sending the + entire request body and the server is ready the request body using + non-blocking I/O. (markt) + </fix> </changelog> </subsection> <subsection name="Web applications">
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
11- github.com/advisories/GHSA-f4qf-m5gf-8jm8ghsaADVISORY
- lists.apache.org/thread/h9bjqdd0odj6lhs2o96qgowcc6hb0cfzghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2024-21733ghsaADVISORY
- packetstormsecurity.com/files/176951/Apache-Tomcat-8.5.63-9.0.43-HTTP-Response-Smuggling.htmlghsaWEB
- www.openwall.com/lists/oss-security/2024/01/19/2ghsaWEB
- github.com/apache/tomcat/commit/86ccc43940861703c2be96a5f35384407522125aghsaWEB
- github.com/apache/tomcat/commit/ce4b154e7b48f66bd98858626347747cd2514311ghsaWEB
- lists.debian.org/debian-lts-announce/2025/01/msg00009.htmlghsaWEB
- security.netapp.com/advisory/ntap-20240216-0005ghsaWEB
- tomcat.apache.org/security-8.htmlghsaWEB
- tomcat.apache.org/security-9.htmlghsaWEB
News mentions
0No linked articles in our index yet.