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

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

commit 8d8ea7ae30f50b2a3cd6d23e45cbec93e94dee08
Author: opencode <[email protected]>
AuthorDate: Mon Oct 12 11:39:52 2026 +0200

    Fix silent loss of error reports in ProxyErrorReportValve proxy mode
    
    In proxy mode, the target URL is commonly a relative error page
    location, for example the location of an <error-page> element. Such a
    URL cannot be converted to a URL for the proxy connection, the
    resulting exception was logged only at debug level and the client was
    left with an empty response carrying only the error status: the error
    report was silently lost. The fallback to the default ErrorReportValve
    was unreachable in this case because the one-shot reported flag had
    already been consumed before the proxy attempt.
    
    Rework the handling of the reported flag so it is consumed only when
    the report is actually produced. The proxy path now falls back to the
    default error report whenever proxying fails, which also covers
    unreachable targets, targets that respond with an error status and
    non-HTTP target URLs. Only absolute HTTP and HTTPS URLs are proxied,
    removing a ClassCastException for other protocols, and connect and read
    timeouts are applied to the proxy connection so an unresponsive target
    can no longer block the request thread indefinitely.
    
    Also obtain the target response before applying target metadata to the
    current response and clear a stale Content-Length before falling back,
    so a failure after the target connection was opened can no longer
    truncate or suppress the fallback report, and log proxy failures at
    warn level rather than debug level.
    
    Add tests for the relative location, unreachable target, unsupported
    protocol and target error status cases, and document the proxy mode
    URL requirements, the fallback behaviour and the timeouts.
---
 .../catalina/valves/ProxyErrorReportValve.java     |  61 +++++--
 .../catalina/valves/TestProxyErrorReportValve.java | 178 +++++++++++++++++++++
 webapps/docs/changelog.xml                         |  11 ++
 webapps/docs/config/valve.xml                      |   7 +
 4 files changed, 244 insertions(+), 13 deletions(-)

diff --git a/java/org/apache/catalina/valves/ProxyErrorReportValve.java 
b/java/org/apache/catalina/valves/ProxyErrorReportValve.java
index 75ed77f3a5..97b63e8221 100644
--- a/java/org/apache/catalina/valves/ProxyErrorReportValve.java
+++ b/java/org/apache/catalina/valves/ProxyErrorReportValve.java
@@ -50,6 +50,13 @@ import org.apache.tomcat.util.res.StringManager;
 public class ProxyErrorReportValve extends ErrorReportValve {
     private static final Log log = 
LogFactory.getLog(ProxyErrorReportValve.class);
 
+    /*
+     * Timeouts for the connection used when proxying an error report. These 
bound the time the request thread can be
+     * blocked by an unresponsive error report target.
+     */
+    private static final int PROXY_CONNECT_TIMEOUT_MS = 5000;
+    private static final int PROXY_READ_TIMEOUT_MS = 10000;
+
     /**
      * Default constructor.
      */
@@ -163,8 +170,11 @@ public class ProxyErrorReportValve extends 
ErrorReportValve {
             return;
         }
 
-        // No need to delegate anymore
-        if (!response.setErrorReported()) {
+        // If the error has already been reported, there is nothing more to do.
+        // Note: the reported flag is deliberately not consumed here. A failed
+        // proxy attempt must still be able to fall back to super.report(),
+        // which consumes the flag itself.
+        if (!response.isErrorReportRequired()) {
             return;
         }
 
@@ -216,6 +226,10 @@ public class ProxyErrorReportValve extends 
ErrorReportValve {
             if (log.isTraceEnabled()) {
                 log.trace("Redirecting error reporting to " + urlString);
             }
+            // No need to delegate anymore
+            if (!response.setErrorReported()) {
+                return;
+            }
             try {
                 response.sendRedirect(urlString);
             } catch (IOException ioe) {
@@ -226,25 +240,46 @@ public class ProxyErrorReportValve extends 
ErrorReportValve {
                 log.trace("Proxying error reporting to " + urlString);
             }
             HttpURLConnection httpURLConnection = null;
+            boolean proxied = false;
+            Exception failure = null;
             try {
                 URL url = (new URI(urlString)).toURL();
-                httpURLConnection = (HttpURLConnection) url.openConnection();
-                httpURLConnection.connect();
-                response.setContentType(httpURLConnection.getContentType());
-                
response.setContentLength(httpURLConnection.getContentLength());
-                OutputStream outputStream = response.getOutputStream();
-                InputStream inputStream = httpURLConnection.getInputStream();
-                IOTools.flow(inputStream, outputStream);
-            } catch (URISyntaxException | IOException | 
IllegalArgumentException e) {
-                if (log.isDebugEnabled()) {
-                    log.debug(sm.getString("proxyErrorReportValve.error", 
urlString), e);
+                String protocol = url.getProtocol();
+                if ("http".equalsIgnoreCase(protocol) || 
"https".equalsIgnoreCase(protocol)) {
+                    httpURLConnection = (HttpURLConnection) 
url.openConnection();
+                    
httpURLConnection.setConnectTimeout(PROXY_CONNECT_TIMEOUT_MS);
+                    httpURLConnection.setReadTimeout(PROXY_READ_TIMEOUT_MS);
+                    httpURLConnection.connect();
+                    // Obtain the target response before modifying this 
response. Otherwise, a failure after the
+                    // connection was opened would leave the target's metadata 
on this response, corrupting the
+                    // fallback error report produced below.
+                    InputStream inputStream = 
httpURLConnection.getInputStream();
+                    
response.setContentType(httpURLConnection.getContentType());
+                    
response.setContentLength(httpURLConnection.getContentLength());
+                    OutputStream outputStream = response.getOutputStream();
+                    IOTools.flow(inputStream, outputStream);
+                    proxied = true;
                 }
-                // Ignore
+            } catch (URISyntaxException | IOException | 
IllegalArgumentException | IllegalStateException e) {
+                failure = e;
             } finally {
                 if (httpURLConnection != null) {
                     httpURLConnection.disconnect();
                 }
             }
+            if (proxied) {
+                // No need to delegate anymore
+                response.setErrorReported();
+            } else {
+                if (log.isWarnEnabled()) {
+                    log.warn(sm.getString("proxyErrorReportValve.error", 
urlString), failure);
+                }
+                // Discard any target metadata that was applied to this 
response before the failure, so that a stale
+                // Content-Length cannot truncate or suppress the fallback 
report. Fall back to the default error
+                // report rather than returning an empty response. The 
reported flag is consumed by super.report().
+                response.setContentLength(-1);
+                super.report(request, response, throwable);
+            }
         }
     }
 }
diff --git a/test/org/apache/catalina/valves/TestProxyErrorReportValve.java 
b/test/org/apache/catalina/valves/TestProxyErrorReportValve.java
index 6e3619246c..3969a7bc6e 100644
--- a/test/org/apache/catalina/valves/TestProxyErrorReportValve.java
+++ b/test/org/apache/catalina/valves/TestProxyErrorReportValve.java
@@ -17,6 +17,7 @@
 package org.apache.catalina.valves;
 
 import java.io.IOException;
+import java.net.ServerSocket;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -335,6 +336,183 @@ public class TestProxyErrorReportValve extends 
TomcatBaseTest {
     }
 
 
+    @Test
+    public void testProxyModeRelativeLocationFallsBackToSuper() throws 
Exception {
+        Tomcat tomcat = getTomcatInstance();
+        StandardHost host = (StandardHost) tomcat.getHost();
+        host.setErrorReportValveClass(PROXY_VALVE);
+
+        Context ctx = getProgrammaticRootContext();
+
+        Tomcat.addServlet(ctx, "error", new SendErrorServlet(
+                HttpServletResponse.SC_NOT_FOUND, "Not found"));
+        ctx.addServletMapping("/", "error");
+
+        tomcat.start();
+
+        ProxyErrorReportValve valve = null;
+        Valve[] valves = host.getPipeline().getValves();
+        for (Valve valveCandidate : valves) {
+            if (PROXY_VALVE.equals(valveCandidate.getClass().getName())) {
+                valve = (ProxyErrorReportValve)valveCandidate;
+                break;
+            }
+        }
+        Assert.assertNotNull(valve);
+        valve.setUseRedirect(false);
+        // Relative locations cannot be proxied. The default error report must
+        // be produced rather than an empty response.
+        valve.setProperty("errorCode." + HttpServletResponse.SC_NOT_FOUND, 
"/error-page");
+
+        ByteChunk res = new ByteChunk();
+        int rc = getUrl("http://localhost:"; + getPort(), res, null);
+
+        Assert.assertEquals(HttpServletResponse.SC_NOT_FOUND, rc);
+
+        String body = res.toString();
+        Assert.assertNotNull("Response body should not be empty", body);
+        Assert.assertTrue("Should contain HTML error report",
+                body.contains("html") &&
+                    
body.contains(String.valueOf(HttpServletResponse.SC_NOT_FOUND)));
+    }
+
+
+    @Test
+    public void testProxyModeUnreachableTargetFallsBackToSuper() throws 
Exception {
+        Tomcat tomcat = getTomcatInstance();
+        StandardHost host = (StandardHost) tomcat.getHost();
+        host.setErrorReportValveClass(PROXY_VALVE);
+
+        Context ctx = getProgrammaticRootContext();
+
+        Tomcat.addServlet(ctx, "error", new SendErrorServlet(
+                HttpServletResponse.SC_NOT_FOUND, "Not found"));
+        ctx.addServletMapping("/", "error");
+
+        // Find a port that is not in use
+        int deadPort;
+        try (ServerSocket listener = new ServerSocket(0)) {
+            deadPort = listener.getLocalPort();
+        }
+
+        tomcat.start();
+
+        ProxyErrorReportValve valve = null;
+        Valve[] valves = host.getPipeline().getValves();
+        for (Valve valveCandidate : valves) {
+            if (PROXY_VALVE.equals(valveCandidate.getClass().getName())) {
+                valve = (ProxyErrorReportValve)valveCandidate;
+                break;
+            }
+        }
+        Assert.assertNotNull(valve);
+        valve.setUseRedirect(false);
+        // An unreachable proxy target must not result in an empty response.
+        valve.setProperty("errorCode." + HttpServletResponse.SC_NOT_FOUND,
+                "http://localhost:"; + deadPort + "/error-page");
+
+        ByteChunk res = new ByteChunk();
+        int rc = getUrl("http://localhost:"; + getPort(), res, null);
+
+        Assert.assertEquals(HttpServletResponse.SC_NOT_FOUND, rc);
+
+        String body = res.toString();
+        Assert.assertNotNull("Response body should not be empty", body);
+        Assert.assertTrue("Should contain HTML error report",
+                body.contains("html") &&
+                    
body.contains(String.valueOf(HttpServletResponse.SC_NOT_FOUND)));
+    }
+
+
+    @Test
+    public void testProxyModeUnsupportedProtocolFallsBackToSuper() throws 
Exception {
+        Tomcat tomcat = getTomcatInstance();
+        StandardHost host = (StandardHost) tomcat.getHost();
+        host.setErrorReportValveClass(PROXY_VALVE);
+
+        Context ctx = getProgrammaticRootContext();
+
+        Tomcat.addServlet(ctx, "error", new SendErrorServlet(
+                HttpServletResponse.SC_NOT_FOUND, "Not found"));
+        ctx.addServletMapping("/", "error");
+
+        tomcat.start();
+
+        ProxyErrorReportValve valve = null;
+        Valve[] valves = host.getPipeline().getValves();
+        for (Valve valveCandidate : valves) {
+            if (PROXY_VALVE.equals(valveCandidate.getClass().getName())) {
+                valve = (ProxyErrorReportValve)valveCandidate;
+                break;
+            }
+        }
+        Assert.assertNotNull(valve);
+        valve.setUseRedirect(false);
+        // Only http and https URLs can be proxied. The default error report
+        // must be produced rather than an empty response.
+        valve.setProperty("errorCode." + HttpServletResponse.SC_NOT_FOUND,
+                "file:///tmp/does-not-exist");
+
+        ByteChunk res = new ByteChunk();
+        int rc = getUrl("http://localhost:"; + getPort(), res, null);
+
+        Assert.assertEquals(HttpServletResponse.SC_NOT_FOUND, rc);
+
+        String body = res.toString();
+        Assert.assertNotNull("Response body should not be empty", body);
+        Assert.assertTrue("Should contain HTML error report",
+                body.contains("html") &&
+                    
body.contains(String.valueOf(HttpServletResponse.SC_NOT_FOUND)));
+    }
+
+
+    @Test
+    public void testProxyModeTargetErrorStatusFallsBackToSuper() throws 
Exception {
+        Tomcat tomcat = getTomcatInstance();
+        StandardHost host = (StandardHost) tomcat.getHost();
+        host.setErrorReportValveClass(PROXY_VALVE);
+
+        Context ctx = getProgrammaticRootContext();
+
+        Tomcat.addServlet(ctx, "error", new SendErrorServlet(
+                HttpServletResponse.SC_NOT_FOUND, "Not found"));
+        ctx.addServletMapping("/", "error");
+
+        // The proxy target itself responds with an error status
+        Tomcat.addServlet(ctx, "failingErrorPage", new SendErrorServlet(
+                HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Target broke"));
+        ctx.addServletMapping("/error-page", "failingErrorPage");
+
+        tomcat.start();
+
+        ProxyErrorReportValve valve = null;
+        Valve[] valves = host.getPipeline().getValves();
+        for (Valve valveCandidate : valves) {
+            if (PROXY_VALVE.equals(valveCandidate.getClass().getName())) {
+                valve = (ProxyErrorReportValve)valveCandidate;
+                break;
+            }
+        }
+        Assert.assertNotNull(valve);
+        valve.setUseRedirect(false);
+        valve.setProperty("errorCode." + HttpServletResponse.SC_NOT_FOUND,
+                "http://localhost:"; + getPort() + "/error-page");
+
+        ByteChunk res = new ByteChunk();
+        int rc = getUrl("http://localhost:"; + getPort(), res, null);
+
+        Assert.assertEquals(HttpServletResponse.SC_NOT_FOUND, rc);
+
+        // A target that responds with an error status cannot be proxied. The
+        // default error report must not be corrupted by target metadata.
+        String body = res.toString();
+        Assert.assertNotNull("Response body should not be empty", body);
+        Assert.assertTrue("Should contain HTML error report",
+                body.contains("html") &&
+                    
body.contains(String.valueOf(HttpServletResponse.SC_NOT_FOUND)));
+    }
+
+
     private static final class SendErrorServlet extends HttpServlet {
 
         private static final long serialVersionUID = 1L;
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 81b0749064..0cc7f602bc 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -1999,6 +1999,17 @@
         <code>throwOnFailure</code>, so the now reachable skip path cannot
         silently drop a customised configuration. (remm)
       </fix>
+      <fix>
+        Correctly handle error reports that cannot be proxied by
+        <code>ProxyErrorReportValve</code>. Previously, a relative location
+        or an unreachable, non-HTTP or failing proxy target resulted in an
+        empty response with only the error status set. The valve now falls
+        back to the error report of the default <code>ErrorReportValve</code>
+        in these cases, no longer applies proxy target metadata to the
+        response before the target response has been received, restricts
+        proxying to absolute HTTP and HTTPS URLs and applies connect and
+        read timeouts to the connection to the proxy target. (remm)
+      </fix>
     </changelog>
   </subsection>
   <subsection name="Coyote">
diff --git a/webapps/docs/config/valve.xml b/webapps/docs/config/valve.xml
index 15596630d8..c6dfb06e93 100644
--- a/webapps/docs/config/valve.xml
+++ b/webapps/docs/config/valve.xml
@@ -2575,6 +2575,13 @@ RequestHeader unset SSL_CLIENT_ESCAPED_CERT</source>
     key named <code>0</code> should be used to match any other unmapped
     code to a redirect or proxy URL.</p>
 
+    <p>When proxying (as opposed to redirecting), the base URL must be an
+    absolute HTTP or HTTPS URL. If the error report cannot be proxied, for
+    example because the URL is relative or the target is unreachable, the
+    valve falls back to the error report generated by the default
+    <code>ErrorReportValve</code>. Connections used for proxying are subject
+    to fixed connect (5 second) and read (10 second) timeouts.</p>
+
   </subsection>
 
 </section>


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to