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/02 20:51:59 UTC

(groovy) branch master updated (6e7b76ff2a -> dd1fa5a88f)

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

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


    from 6e7b76ff2a GROOVY-12327: JsonSlurper: bound the cost of parsing a wide JSON object
     new 4e9e16b2e2 GROOVY-12331: XmlParser/XmlSlurper: bound element nesting depth
     new dd1fa5a88f GROOVY-12331: correct the threat model's claim that JAXP bounds element depth

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 THREAT_MODEL.md                                    |  18 +--
 .../src/main/java/groovy/xml/FactorySupport.java   |  83 ++++++++++++++
 .../src/main/java/groovy/xml/XmlParser.java        |   2 +-
 .../src/main/java/groovy/xml/XmlSlurper.java       |   2 +-
 .../src/main/java/groovy/xml/XmlUtil.java          |   4 +-
 .../groovy/groovy/xml/XmlElementDepthTest.groovy   | 123 +++++++++++++++++++++
 6 files changed, 220 insertions(+), 12 deletions(-)
 create mode 100644 subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlElementDepthTest.groovy


(groovy) 02/02: GROOVY-12331: correct the threat model's claim that JAXP bounds element depth

Posted by pa...@apache.org.
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

commit dd1fa5a88f79f71071e3b5342f8dbb4fec3f73d8
Author: Paul King <pa...@asert.com.au>
AuthorDate: Wed Sep 2 17:44:42 2026 +1000

    GROOVY-12331: correct the threat model's claim that JAXP bounds element depth
    
    The §6 parser table said secure processing and DOCTYPE rejection "cap
    entity expansion and depth via the JAXP limits". Entity expansion, yes;
    depth, no. The JAXP limit governing depth, jdk.xml.maxElementDepth,
    defaults to 0 meaning unlimited, and nothing set it, so the row asserted
    a bound that did not exist - the kind of claim a reader acts on by not
    bounding the input themselves.
    
    Correcting the row alone would leave the rest of the document inheriting
    the mistake. The half-sentence had propagated: §6's preamble named
    JsonSlurper as the only parser with a depth cap, §11a cited only
    GROOVY-12064 for "bounded by default in all of them", and the closing
    paragraph credited the JsonSlurper cap with closing the last gap when the
    gap it describes was the XML one, still open at the time. §5a gains the
    knob beside groovy.json.maxNestingDepth. All five now say the same thing.
    
    The branch caveat matters as much as the correction: 3.0.x, 4.0.x and
    5.0.x have neither bound, so depth-bounding untrusted XML there stays a
    downstream responsibility, exactly as the document already said of JSON.
    
    P2 is deliberately untouched. It records XXE and entity expansion, and
    adding element depth to it would make an unbounded parser a violation of
    a provided property rather than a hardening obligation, changing how such
    a report is dispositioned under §13. That is the PMC's call, not a
    correction.
---
 THREAT_MODEL.md | 18 ++++++++++--------
 1 file changed, 10 insertions(+), 8 deletions(-)

diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index c659bbeee3..be9d9a3f86 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -299,6 +299,7 @@ hunt for the former and discount the latter.
 |---|---|---|
 | `XmlParser` / `XmlSlurper` / `XmlUtil` `allowDocTypeDeclaration` | `false` | When `false`, DOCTYPE is rejected and secure processing is on → XXE / entity-expansion mitigated. Setting `true` re-introduces XXE risk for untrusted XML. *(documented)* |
 | `groovy.json.maxNestingDepth` (per-instance: `JsonSlurper.setMaxNestingDepth`) | `1000` | Caps the array/object nesting depth `JsonSlurper`/`JsonSlurperClassic` accept; a small but deeply-nested document throws a `JsonException` instead of driving a `StackOverflowError`. A value `<= 0` disables the check (restoring the previous unbounded behaviour). Available from 6.0.0. *(documented — verified in `groovy-json` `BaseJsonParser`)* |
+| `jdk.xml.maxElementDepth` | `1000` (Groovy's default; the JAXP default is `0`, unlimited) | Caps the element nesting depth `XmlParser`/`XmlSlurper`/`DOMBuilder` accept, so a deeply nested document fails the parse rather than driving a `StackOverflowError` in the first consumer that walks the result. Set the property yourself to choose another bound, including `0` for unlimited; a caller-supplied parser is left alone. Available from 6.0.0. *(documented — verified in `groovy-xml` `Factor [...]
 | 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`)* |
 | `SecureASTCustomizer` allow/deny lists | none unless configured | A *partial* grammar restriction, not a sandbox. *(documented)* |
@@ -321,9 +322,9 @@ Size/shape: parsers and GDK operations process inputs of developer-chosen
 size. Groovy does **not** impose universal limits on collection size, regex
 backtracking, hash-key cardinality, or numeric magnitude; bounding untrusted
 input is a downstream responsibility ([§10](#10-downstream-responsibilities)).
-The one exception is **document nesting depth in `JsonSlurper`**, now capped
-by default (see the parser table below) — the rest still need bounding by the
-caller.
+The exceptions are **document nesting depth in `JsonSlurper` and in
+`XmlParser`/`XmlSlurper`**, both now capped by default (see the parser table
+below) — the rest still need bounding by the caller.
 
 **Groovy's own data parsers are a distinct concern.** `JsonSlurper`,
 `XmlSlurper`/`XmlParser`, and the `groovy-yaml`/`groovy-toml`/`groovy-csv`
@@ -340,16 +341,17 @@ All of Groovy's own data parsers now bound nesting depth by default:
 
 | Parser | Resource-bound state |
 |---|---|
-| `XmlSlurper` / `XmlParser` | Mitigated by default — `FEATURE_SECURE_PROCESSING` + DOCTYPE disabled (**P2**) cap entity expansion and depth via the JAXP limits. |
+| `XmlSlurper` / `XmlParser` | **Now bounded explicitly (6.0.0+).** `FEATURE_SECURE_PROCESSING` + DOCTYPE disabled (**P2**) cap entity expansion but **not** element depth — the JAXP limit that governs depth, `jdk.xml.maxElementDepth`, defaults to `0` (unlimited) — so `FactorySupport` now sets it to `1000` on the parsers it creates. Unbounded, the SAX parse itself survives an arbitrarily deep document (nesting is tracked on the heap) and the first consumer to walk the result recursively — [...]
 | `YamlSlurper` / `TomlSlurper` / `CsvSlurper` | Bounded *implicitly* by the Jackson runtime's `StreamReadConstraints` (nesting-depth / length caps) and the YAML layer's alias limits — i.e. by the dependency's defaults, not an explicit Groovy decision. |
 | `JsonSlurper` | **Now bounded explicitly (6.0.0+).** Its recursive-descent parsers (`decodeValue` → `decodeJsonObject`/`decodeJsonArray` → `decodeValue`) enforce a Groovy-level nesting-depth cap (`BaseJsonParser`, default `1000`, configurable via `setMaxNestingDepth` or `-Dgroovy.json.maxNestingDepth`), throwing a `JsonException` rather than overflowing the stack. The default matches Jackson's `StreamReadConstraints`, so JSON is now bounded consistently with the Jackson-backed slurpers [...]
 
 This is separate from the Groovy *language* parser (`groovyc`/Antlr), which
 only ever parses trusted source — compiling untrusted Groovy is already out
 of model ([§3](#3-out-of-scope-explicit-non-goals)) — so its robustness
-carries no equivalent obligation. The `JsonSlurper` nesting cap (GROOVY-12064)
-closed the last gap here for the 6.0.0 line; on the 3.0.x/4.0.x/5.0.x branches,
-where it is not (yet) available, depth-bounding untrusted JSON remains a
+carries no equivalent obligation. The XML element-depth bound (GROOVY-12331)
+closed the last gap here for the 6.0.0 line, after the `JsonSlurper` nesting cap
+(GROOVY-12064) closed the JSON one; on the 3.0.x/4.0.x/5.0.x branches, where
+neither is available, depth-bounding untrusted JSON **and XML** remains a
 downstream responsibility ([§10](#10-downstream-responsibilities)).
 
 **Proportionate size is not amplification.** The `VALID-HARDENING` obligation above is for
@@ -622,7 +624,7 @@ vulnerabilities** unless a concrete, in-model data-boundary crossing
 | `@Grab`/Grape fetching and loading artifacts | By design dependency resolution — `OUT-OF-MODEL` |
 | Temp-file/dir creation | Now NIO owner-only (P4) — `KNOWN-NON-FINDING` |
 | Regex, `BigInteger`/`BigDecimal` parsing, hash-collision flooding (JDK treeifies heavily-collided `String`-keyed buckets since Java 8) | DoS bounded by developer-chosen input — `OUT-OF-MODEL: downstream-responsibility` |
-| Deep recursion / unbounded input in Groovy's *own* data parsers (`JsonSlurper`, `XmlSlurper`/`XmlParser`, `groovy-yaml`/`-toml`/`-csv`) | Robustness of code meant to consume untrusted input — **`VALID-HARDENING`** *(maintainer)*; nesting depth is now bounded by default in all of them (JSON via the 6.0.0 `maxNestingDepth` cap, GROOVY-12064), per-parser exposure in [§6](#6-assumptions-about-inputs) |
+| Deep recursion / unbounded input in Groovy's *own* data parsers (`JsonSlurper`, `XmlSlurper`/`XmlParser`, `groovy-yaml`/`-toml`/`-csv`) | Robustness of code meant to consume untrusted input — **`VALID-HARDENING`** *(maintainer)*; nesting depth is now bounded by default in all of them (JSON via the 6.0.0 `maxNestingDepth` cap, GROOVY-12064; XML via the 6.0.0 `jdk.xml.maxElementDepth` bound, GROOVY-12331), per-parser exposure in [§6](#6-assumptions-about-inputs) |
 | Proportionate memory use from a *large* (not amplified) document handed to `JsonSlurper`/`XmlSlurper`/`XmlParser` | Cost proportional to input size; bound the input or use a streaming/Jackson parser ([§6](#6-assumptions-about-inputs)) — `OUT-OF-MODEL: downstream-responsibility` |
 | `groovy-servlet` building a filesystem path from the request URI (`GroovyServlet`/`TemplateServlet`/`AbstractHttpServlet`) | Groovlets/templates are operator-deployed code ([§3](#3-out-of-scope-explicit-non-goals)); request-path normalization is the servlet container's responsibility — `OUT-OF-MODEL: downstream-responsibility` |
 | Missing response security headers (HSTS/CSP/…) from `groovy-servlet` | Transport/edge concern set at the TLS-terminating proxy or container ([§10](#10-downstream-responsibilities)) — `OUT-OF-MODEL: downstream-responsibility` |


(groovy) 01/02: GROOVY-12331: XmlParser/XmlSlurper: bound element nesting depth

Posted by pa...@apache.org.
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

commit 4e9e16b2e2a952aecab7217b7da0b1bfa3b41dab
Author: Paul King <pa...@asert.com.au>
AuthorDate: Wed Sep 2 17:31:23 2026 +1000

    GROOVY-12331: XmlParser/XmlSlurper: bound element nesting depth
    
    Secure processing does not bound element depth. The JAXP limit that does,
    jdk.xml.maxElementDepth, defaults to 0 meaning unlimited, and nothing in
    groovy-xml set it, so the depth a document could reach was unbounded by
    default.
    
    The SAX parse itself survives that, because nesting is tracked on the
    heap rather than the stack. The damage lands on the first consumer to
    walk the result recursively - Node.text(), XmlNodePrinter,
    GPathResult.toString(), XmlUtil.serialize - each of which runs a stack
    frame per level. A 350KB document 50,000 elements deep parsed cleanly and
    then killed every one of them with a StackOverflowError. That is an
    Error, so it escapes the catch(Exception) an application would
    reasonably use to handle a malformed document: the failure arrives
    somewhere the caller is not defending, well after the parse it would
    have attributed it to.
    
    The limit is now set to 1000 by default, matching groovy.json's nesting
    bound, so a document too deep to walk is refused by the parse that reads
    it. The JDK enforces the bound itself, which puts the check ahead of
    every consumer at once and reports the offending element with its depth
    and position rather than unwinding an anonymous stack.
    
    The limit cannot be set on a SAXParserFactory - it is a parser property -
    so FactorySupport.createSaxParser applies it to the parser it creates,
    and the four places that built a SAX parser now go through it. The
    DocumentBuilderFactory route takes it as a factory attribute, which
    covers DOMBuilder and the DOM paths too.
    
    Nothing is applied when jdk.xml.maxElementDepth is already set: that is
    the standard knob for this limit, and an explicitly set parser property
    would otherwise override the value a user chose - including a deliberate
    0 to restore unlimited depth. A parser supplied by the caller is left
    alone, as with the other hardening here.
---
 .../src/main/java/groovy/xml/FactorySupport.java   |  83 ++++++++++++++
 .../src/main/java/groovy/xml/XmlParser.java        |   2 +-
 .../src/main/java/groovy/xml/XmlSlurper.java       |   2 +-
 .../src/main/java/groovy/xml/XmlUtil.java          |   4 +-
 .../groovy/groovy/xml/XmlElementDepthTest.groovy   | 123 +++++++++++++++++++++
 5 files changed, 210 insertions(+), 4 deletions(-)

diff --git a/subprojects/groovy-xml/src/main/java/groovy/xml/FactorySupport.java b/subprojects/groovy-xml/src/main/java/groovy/xml/FactorySupport.java
index 63b0d0f777..e9e6f0c939 100644
--- a/subprojects/groovy-xml/src/main/java/groovy/xml/FactorySupport.java
+++ b/subprojects/groovy-xml/src/main/java/groovy/xml/FactorySupport.java
@@ -18,12 +18,14 @@
  */
 package groovy.xml;
 
+import org.xml.sax.SAXException;
 import org.xml.sax.SAXNotRecognizedException;
 import org.xml.sax.SAXNotSupportedException;
 
 import javax.xml.XMLConstants;
 import javax.xml.parsers.DocumentBuilderFactory;
 import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
 import javax.xml.parsers.SAXParserFactory;
 import javax.xml.stream.XMLInputFactory;
 import javax.xml.transform.TransformerConfigurationException;
@@ -48,6 +50,28 @@ public class FactorySupport {
 
     private static final String DISALLOW_DOCTYPE_DECL_FEATURE = "http://apache.org/xml/features/disallow-doctype-decl";
 
+    private static final String MAX_ELEMENT_DEPTH_LIMIT = "jdk.xml.maxElementDepth";
+
+    /**
+     * Default bound on how deeply elements may nest in a parsed document.
+     * <p>
+     * {@link XMLConstants#FEATURE_SECURE_PROCESSING} does not bound element depth: the JAXP
+     * {@code jdk.xml.maxElementDepth} limit defaults to {@code 0}, meaning unlimited. The parse
+     * itself survives an arbitrarily deep document, because SAX tracks nesting on the heap, but the
+     * first consumer to walk the result recursively &mdash; {@code Node.text()},
+     * {@code XmlNodePrinter}, {@code GPathResult.toString()}, {@code XmlUtil.serialize} &mdash;
+     * runs one stack frame per level and dies with a {@link StackOverflowError}. That is an
+     * {@link Error}, so it escapes the {@code catch (Exception)} an application would reasonably
+     * use to handle a malformed document. Bounding the depth at parse time turns it into an
+     * ordinary parse failure, at one check point, before any of those consumers is reached.
+     * <p>
+     * The value matches {@code groovy.json}'s nesting bound, and sits far above any realistic
+     * document.
+     *
+     * @since 6.0.0
+     */
+    public static final int DEFAULT_MAX_ELEMENT_DEPTH = 1000;
+
     /**
      * Logs that a security-hardening setting could not be applied to a JAXP factory. These settings
      * are applied on a best-effort basis (a non-standard JAXP provider may not recognise them), but a
@@ -122,6 +146,7 @@ public class FactorySupport {
         DocumentBuilderFactory factory = (DocumentBuilderFactory) createFactory(DocumentBuilderFactory::newInstance);
         setFeatureQuietly(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true);
         setFeatureQuietly(factory, DISALLOW_DOCTYPE_DECL_FEATURE, !allowDocTypeDeclaration);
+        setMaxElementDepthQuietly(factory);
         factory.setXIncludeAware(false);
         factory.setExpandEntityReferences(false);
         return factory;
@@ -269,6 +294,64 @@ public class FactorySupport {
         return factory;
     }
 
+    /**
+     * Creates a {@link SAXParser} from the supplied factory, bounded by
+     * {@link #DEFAULT_MAX_ELEMENT_DEPTH}.
+     * <p>
+     * The depth limit cannot be set on a {@link SAXParserFactory} &mdash; it is a parser property
+     * &mdash; so SAX callers should obtain their parser here rather than calling
+     * {@link SAXParserFactory#newSAXParser()} directly, or the bound will not be applied.
+     *
+     * @param factory the factory to create the parser from
+     * @return a newly created parser with the element-depth bound applied
+     * @throws ParserConfigurationException if the parser cannot be created
+     * @throws SAXException if the parser cannot be created
+     * @since 6.0.0
+     */
+    public static SAXParser createSaxParser(SAXParserFactory factory) throws ParserConfigurationException, SAXException {
+        SAXParser parser = factory.newSAXParser();
+        setMaxElementDepthQuietly(parser);
+        return parser;
+    }
+
+    /**
+     * The element-depth bound to apply, or {@code null} to leave the parser's own default in place.
+     * <p>
+     * A value supplied through the standard {@code jdk.xml.maxElementDepth} system property is the
+     * user's choice of bound, and JAXP would let an explicitly set parser property override it, so
+     * nothing is applied when that property is present &mdash; including a deliberate {@code 0},
+     * which restores unlimited depth.
+     */
+    private static String maxElementDepthLimit() {
+        return System.getProperty(MAX_ELEMENT_DEPTH_LIMIT) == null
+                ? Integer.toString(DEFAULT_MAX_ELEMENT_DEPTH)
+                : null;
+    }
+
+    private static void setMaxElementDepthQuietly(DocumentBuilderFactory factory) {
+        String limit = maxElementDepthLimit();
+        if (limit == null) {
+            return;
+        }
+        try {
+            factory.setAttribute(MAX_ELEMENT_DEPTH_LIMIT, limit);
+        } catch (IllegalArgumentException e) {
+            warnHardeningNotApplied(factory, MAX_ELEMENT_DEPTH_LIMIT, limit, e);
+        }
+    }
+
+    private static void setMaxElementDepthQuietly(SAXParser parser) {
+        String limit = maxElementDepthLimit();
+        if (limit == null) {
+            return;
+        }
+        try {
+            parser.setProperty(MAX_ELEMENT_DEPTH_LIMIT, limit);
+        } catch (SAXNotRecognizedException | SAXNotSupportedException e) {
+            warnHardeningNotApplied(parser, MAX_ELEMENT_DEPTH_LIMIT, limit, e);
+        }
+    }
+
     // package-private for testing
     static void setFeatureQuietly(DocumentBuilderFactory factory, String feature, boolean value) {
         try {
diff --git a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
index 70dc507821..2b53f14c4c 100644
--- a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
+++ b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
@@ -154,7 +154,7 @@ public class XmlParser implements ContentHandler {
         SAXParserFactory factory = FactorySupport.createSaxParserFactory(allowDocTypeDeclaration);
         factory.setNamespaceAware(namespaceAware);
         factory.setValidating(validating);
-        reader = factory.newSAXParser().getXMLReader();
+        reader = FactorySupport.createSaxParser(factory).getXMLReader();
     }
 
     private XMLReader ensureReader() {
diff --git a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlSlurper.java b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlSlurper.java
index b346d0b1e9..3a15931495 100644
--- a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlSlurper.java
+++ b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlSlurper.java
@@ -193,7 +193,7 @@ public class XmlSlurper extends DefaultHandler {
         SAXParserFactory factory = FactorySupport.createSaxParserFactory(allowDocTypeDeclaration);
         factory.setNamespaceAware(namespaceAware);
         factory.setValidating(validating);
-        reader = factory.newSAXParser().getXMLReader();
+        reader = FactorySupport.createSaxParser(factory).getXMLReader();
     }
 
     private XMLReader getReader() throws ParserConfigurationException, SAXException {
diff --git a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlUtil.java b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlUtil.java
index 5d867bfa41..c851e3595d 100644
--- a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlUtil.java
+++ b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlUtil.java
@@ -469,7 +469,7 @@ public class XmlUtil {
             SchemaFactory schemaFactory = FactorySupport.createSchemaFactory(schemaLanguage);
             factory.setSchema(schemaFactory.newSchema(schemas));
         }
-        SAXParser saxParser = factory.newSAXParser();
+        SAXParser saxParser = FactorySupport.createSaxParser(factory);
         if (schemas.length == 0) {
             saxParser.setProperty("http://java.sun.com/xml/jaxp/properties/schemaLanguage", schemaLanguage);
         }
@@ -624,7 +624,7 @@ public class XmlUtil {
     private static SAXParser newSAXParser(boolean namespaceAware, boolean validating, Schema schema1) throws ParserConfigurationException, SAXException {
         SAXParserFactory factory = newFactoryInstance(namespaceAware, validating, false);
         factory.setSchema(schema1);
-        return factory.newSAXParser();
+        return FactorySupport.createSaxParser(factory);
     }
 
     private static String asString(Node node) {
diff --git a/subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlElementDepthTest.groovy b/subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlElementDepthTest.groovy
new file mode 100644
index 0000000000..40118ec6f8
--- /dev/null
+++ b/subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlElementDepthTest.groovy
@@ -0,0 +1,123 @@
+/*
+ *  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.xml
+
+import org.junit.jupiter.api.Test
+import org.xml.sax.SAXException
+
+import static groovy.test.GroovyAssert.shouldFail
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Secure processing does not bound element depth, and the SAX parse itself survives an arbitrarily
+ * deep document because nesting is tracked on the heap. The damage lands on the first consumer to
+ * walk the result recursively, which dies with a StackOverflowError -- an Error, so it escapes the
+ * catch(Exception) an application would use for a malformed document. The parsers therefore bound
+ * the depth themselves, at one check point ahead of every consumer.
+ */
+class XmlElementDepthTest {
+
+    private static final int DEFAULT = FactorySupport.DEFAULT_MAX_ELEMENT_DEPTH
+    private static final String LIMIT = 'jdk.xml.maxElementDepth'
+
+    /** A document whose deepest element sits at {@code depth}, the root counting as depth 1. */
+    private static String nested(int depth) {
+        '<a>' * depth + '</a>' * depth
+    }
+
+    private static withLimit(Object value, Closure body) {
+        String previous = System.getProperty(LIMIT)
+        if (value == null) System.clearProperty(LIMIT) else System.setProperty(LIMIT, value.toString())
+        try {
+            body()
+        } finally {
+            if (previous == null) System.clearProperty(LIMIT) else System.setProperty(LIMIT, previous)
+        }
+    }
+
+    // GROOVY-12331
+    @Test
+    void testDocumentAtTheDefaultBoundStillParses() {
+        assertEquals('', new XmlSlurper().parseText(nested(DEFAULT)).text())
+        assertEquals('a', new XmlParser().parseText(nested(DEFAULT)).name())
+    }
+
+    // GROOVY-12331
+    @Test
+    void testXmlSlurperRejectsADocumentPastTheBound() {
+        def e = shouldFail(SAXException) { new XmlSlurper().parseText(nested(DEFAULT + 1)) }
+        assertTrue(e.message.contains('maxElementDepth'), e.message)
+    }
+
+    // GROOVY-12331
+    @Test
+    void testXmlParserRejectsADocumentPastTheBound() {
+        def e = shouldFail(SAXException) { new XmlParser().parseText(nested(DEFAULT + 1)) }
+        assertTrue(e.message.contains('maxElementDepth'), e.message)
+    }
+
+    // GROOVY-12331
+    @Test
+    void testDomBuilderRejectsADocumentPastTheBound() {
+        // the DocumentBuilderFactory route is bounded too, not just the SAX one
+        shouldFail(SAXException) { DOMBuilder.parse(new StringReader(nested(DEFAULT + 1))) }
+    }
+
+    // GROOVY-12331
+    @Test
+    void testDeepDocumentFailsTheParseRatherThanTheConsumer() {
+        // 350KB used to parse cleanly and then kill text()/toString()/XmlNodePrinter with a
+        // StackOverflowError; the failure now arrives as an ordinary parse exception instead
+        String deep = nested(50000)
+        assertTrue(deep.length() > 300000)
+        shouldFail(SAXException) { new XmlSlurper().parseText(deep).text() }
+        shouldFail(SAXException) { new XmlParser().parseText(deep) }
+    }
+
+    // GROOVY-12331
+    @Test
+    void testBoundIsConfigurableByTheStandardJaxpProperty() {
+        withLimit(10) {
+            assertEquals('', new XmlSlurper().parseText(nested(10)).text())
+            shouldFail(SAXException) { new XmlSlurper().parseText(nested(11)) }
+            shouldFail(SAXException) { new XmlParser().parseText(nested(11)) }
+        }
+    }
+
+    // GROOVY-12331
+    @Test
+    void testPropertyValueOfZeroRestoresUnlimitedDepth() {
+        withLimit(0) {
+            assertEquals('', new XmlSlurper().parseText(nested(DEFAULT + 500)).text())
+        }
+    }
+
+    // GROOVY-12331
+    @Test
+    void testOrdinaryDocumentsAreUnaffected() {
+        def slurped = new XmlSlurper().parseText('<root><child id="1">text</child></root>')
+        assertEquals('text', slurped.child.text())
+        assertEquals('1', slurped.child.@id.text())
+
+        def parsed = new XmlParser().parseText('<root><child id="1">text</child></root>')
+        assertEquals('text', parsed.child.text())
+        assertEquals('1', parsed.child[0].@id)
+    }
+}