You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/09/07 20:46:04 UTC

(groovy) branch master updated: GROOVY-12373: GroovyServlet: omit error detail from client responses by default

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

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 7da6e7fd41 GROOVY-12373: GroovyServlet: omit error detail from client responses by default
7da6e7fd41 is described below

commit 7da6e7fd4170c2351b0e583f55a429ddb6523b49
Author: Paul King <pa...@asert.com.au>
AuthorDate: Mon Sep 7 12:34:45 2026 +1000

    GROOVY-12373: GroovyServlet: omit error detail from client responses by default
    
    A failing groovlet sent the script path, exception message and top stack frame
    to the client in the 500 response body -- the same detail already written to
    the servlet and application logs. That discloses internals to callers, and as
    the response is text/html with a request-derived script path, it is also a
    reflected-content risk where the container does not escape the sendError
    message.
    
    The client now receives a generic 500 by default; the full detail still goes to
    the logs. A protected isVerboseErrors() hook, defaulting to the
    groovy.servlet.verbose.errors system property, restores the detailed responses
    for debugging (override it, or set -Dgroovy.servlet.verbose.errors=true).
    Documented in THREAT_MODEL.md section 5a, with a regression test.
---
 THREAT_MODEL.md                                    |   1 +
 .../main/java/groovy/servlet/GroovyServlet.java    |  24 ++++-
 .../src/spec/doc/servlet-userguide.adoc            |   6 ++
 .../groovy/servlet/GroovyServletErrorTest.groovy   | 109 +++++++++++++++++++++
 4 files changed, 138 insertions(+), 2 deletions(-)

diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index 78dcabbaa9..fb0299acbf 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -303,6 +303,7 @@ hunt for the former and discount the latter.
 | Grape / `@Grab` resolution | enabled in the runtime; **off** in AI/automation tooling via `-Dgroovy.grape.enable=false` | Controls whether a script may fetch and load remote dependencies. Keep off for untrusted scripts. *(documented — see [`AGENTS.md`](AGENTS.md))* |
 | `groovy.asttest.enable` | `true` | When `false`, `@ASTTest` becomes a no-op instead of evaluating its closure during compilation. Since the closure runs at compile time, this matters wherever attacker-influenced source is *compiled*, whether or not the result is then run. Keep off for untrusted source. Available from 5.1.0 and 6.0.0. *(documented — verified in `ASTTestTransformation`)* |
 | `CliBuilder.expandArgumentFiles` | `true` | When `true`, an argv token `@file` expands to the contents of `file` (the javac/picocli `@argfile` convention). The argument vector is developer-supplied and trusted ([§6](#6-assumptions-about-inputs)), so this reads only files the invoker named; set `false` (or call `setExpandArgumentFiles(false)`) to disable. *(documented — verified in `CliBuilderInternal`)* |
+| `groovy.servlet.verbose.errors` | `false` | When `false`, `GroovyServlet` sends a generic `500` to the client and writes the exception detail, script path and stack frame only to the servlet/application logs. Set `true` (or override `GroovyServlet.isVerboseErrors()`) to restore the detailed client responses while debugging, at the cost of disclosing internal detail to callers. *(documented — verified in `GroovyServlet`)* |
 | `SecureASTCustomizer` allow/deny lists | none unless configured | A *partial* grammar restriction, not a sandbox. *(documented)* |
 | `groovy.antlr4.cache.threshold` and similar | tuning | Resource tuning, not a security boundary. |
 
diff --git a/subprojects/groovy-servlet/src/main/java/groovy/servlet/GroovyServlet.java b/subprojects/groovy-servlet/src/main/java/groovy/servlet/GroovyServlet.java
index 282b41b6be..2f95730648 100644
--- a/subprojects/groovy-servlet/src/main/java/groovy/servlet/GroovyServlet.java
+++ b/subprojects/groovy-servlet/src/main/java/groovy/servlet/GroovyServlet.java
@@ -145,7 +145,11 @@ public class GroovyServlet extends AbstractHttpServlet {
                 servletContext.log(error.toString());
                 LOGGER.log(ERROR, error.toString());
                 LOGGER.log(ERROR, "Script processing failed", runtimeException);
-                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, error.toString());
+                if (isVerboseErrors()) {
+                    response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, error.toString());
+                } else {
+                    response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+                }
                 return;
             }
             /*
@@ -168,7 +172,11 @@ public class GroovyServlet extends AbstractHttpServlet {
             servletContext.log(e.toString());
             LOGGER.log(ERROR, e.toString());
             LOGGER.log(ERROR, "Internal error", runtimeException);
-            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.toString());
+            if (isVerboseErrors()) {
+                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.toString());
+            } else {
+                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+            }
         }
     }
 
@@ -179,4 +187,16 @@ public class GroovyServlet extends AbstractHttpServlet {
     protected GroovyScriptEngine createGroovyScriptEngine(){
         return new GroovyScriptEngine(this);
     }
+
+    /**
+     * Whether error responses sent to the client include the script path, the
+     * exception message and the top stack frame. {@code false} by default, so
+     * internal detail is not disclosed to callers; the full error is written to
+     * the servlet and application logs regardless. Override this method, or set
+     * the {@code groovy.servlet.verbose.errors} system property to {@code true},
+     * to re-enable the detailed client responses while debugging.
+     */
+    protected boolean isVerboseErrors() {
+        return Boolean.getBoolean("groovy.servlet.verbose.errors");
+    }
 }
diff --git a/subprojects/groovy-servlet/src/spec/doc/servlet-userguide.adoc b/subprojects/groovy-servlet/src/spec/doc/servlet-userguide.adoc
index f0dcd14718..c3aa979ed3 100644
--- a/subprojects/groovy-servlet/src/spec/doc/servlet-userguide.adoc
+++ b/subprojects/groovy-servlet/src/spec/doc/servlet-userguide.adoc
@@ -118,3 +118,9 @@ handler.addServlet(GroovyServlet, '*.groovy')
 server.start()
 server.join()
 ----
+
+== Error responses
+
+When a Groovlet fails, `GroovyServlet` returns a generic `500` response to the client by default and writes the full detail — the script path, the exception message and a stack frame — to the servlet and application logs. This keeps internal detail out of responses sent to callers.
+
+While developing, you can restore the detailed client responses by setting the `groovy.servlet.verbose.errors` system property to `true` (for example, `-Dgroovy.servlet.verbose.errors=true`), or by overriding the `isVerboseErrors()` method in a `GroovyServlet` subclass.
diff --git a/subprojects/groovy-servlet/src/test/groovy/groovy/servlet/GroovyServletErrorTest.groovy b/subprojects/groovy-servlet/src/test/groovy/groovy/servlet/GroovyServletErrorTest.groovy
new file mode 100644
index 0000000000..edcef23732
--- /dev/null
+++ b/subprojects/groovy-servlet/src/test/groovy/groovy/servlet/GroovyServletErrorTest.groovy
@@ -0,0 +1,109 @@
+/*
+ *  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 groovy.servlet
+
+import jakarta.servlet.ServletConfig
+import jakarta.servlet.ServletContext
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+class GroovyServletErrorTest {
+
+    private static final String VERBOSE = 'groovy.servlet.verbose.errors'
+
+    @TempDir
+    File tempDir
+
+    @AfterEach
+    void clearVerbose() {
+        System.clearProperty(VERBOSE)
+    }
+
+    @Test
+    void failingScriptSendsGenericErrorWithoutDetailByDefault() {
+        def data = serviceFailingScript()
+
+        assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, data.error)
+        assertNull(data.errorMessage, 'exception detail must not be sent to the client by default')
+    }
+
+    @Test
+    void verboseErrorsRestoreDetailInResponseWhenEnabled() {
+        System.setProperty(VERBOSE, 'true')
+
+        def data = serviceFailingScript()
+
+        assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, data.error)
+        assertTrue(data.errorMessage?.contains('boom'),
+                "expected the exception detail in the response, but got: ${data.errorMessage}")
+    }
+
+    private Map serviceFailingScript() {
+        def script = new File(tempDir, 'boom.groovy')
+        script.text = "throw new RuntimeException('boom')"
+
+        def servlet = new GroovyServlet()
+        servlet.init(mockConfig(script.toURI().toURL()))
+
+        def data = [error: null, errorMessage: null, status: null, writer: new StringWriter()]
+        servlet.service(mockRequest(), mockResponse(data))
+        data
+    }
+
+    private HttpServletRequest mockRequest() {
+        [
+                getAttribute     : { null },
+                getPathInfo      : { '/boom.groovy' },
+                getServletPath   : { '/boom.groovy' },
+                getSession       : { null },
+                getParameterNames: { new Vector().elements() },
+                getHeaderNames   : { new Vector().elements() },
+        ] as HttpServletRequest
+    }
+
+    private HttpServletResponse mockResponse(Map data) {
+        [
+                getWriter     : { new PrintWriter(data.writer) },
+                sendError     : { Object... args -> data.error = args[0]; if (args.length > 1) data.errorMessage = args[1] },
+                setContentType: { contentType -> },
+                setStatus     : { status -> data.status = status },
+                flushBuffer   : { -> },
+        ] as HttpServletResponse
+    }
+
+    private ServletConfig mockConfig(URL scriptUrl) {
+        def servletContext = [
+                getRealPath: { arg -> null },
+                getResource: { arg -> scriptUrl },
+                log        : { Object... args -> },
+        ] as ServletContext
+        [
+                getServletName   : { 'Groovy' },
+                getServletContext: { servletContext },
+                getInitParameter : { null },
+        ] as ServletConfig
+    }
+}