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
+ }
+}