Skip to content

GROOVY-12331: XmlParser/XmlSlurper: bound element nesting depth - #2858

Merged
paulk-asert merged 2 commits into
apache:masterfrom
paulk-asert:groovy12331
Sep 2, 2026
Merged

GROOVY-12331: XmlParser/XmlSlurper: bound element nesting depth#2858
paulk-asert merged 2 commits into
apache:masterfrom
paulk-asert:groovy12331

Conversation

@paulk-asert

Copy link
Copy Markdown
Contributor

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.

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.
…nt 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.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.07407% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.7985%. Comparing base (c251a83) to head (21777c6).

Files with missing lines Patch % Lines
...y-xml/src/main/java/groovy/xml/FactorySupport.java 73.9130% 5 Missing and 1 partial ⚠️
...s/groovy-xml/src/main/java/groovy/xml/XmlUtil.java 50.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #2858        +/-   ##
==================================================
- Coverage     70.8002%   70.7985%   -0.0017%     
- Complexity      36979      36984         +5     
==================================================
  Files            1576       1576                
  Lines          134792     134815        +23     
  Branches        24954      24956         +2     
==================================================
+ Hits            95433      95447        +14     
- Misses          30756      30765         +9     
  Partials         8603       8603                
Files with missing lines Coverage Δ
...groovy-xml/src/main/java/groovy/xml/XmlParser.java 70.9091% <100.0000%> (ø)
...roovy-xml/src/main/java/groovy/xml/XmlSlurper.java 62.5954% <100.0000%> (ø)
...s/groovy-xml/src/main/java/groovy/xml/XmlUtil.java 51.7647% <50.0000%> (ø)
...y-xml/src/main/java/groovy/xml/FactorySupport.java 77.1186% <73.9130%> (-0.7761%) ⬇️

... and 12 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@testlens-app

testlens-app Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 21777c6
▶️ Tests: 115377 executed
⚪️ Checks: 31/31 completed


Learn more about TestLens at testlens.app/docs.

@paulk-asert
paulk-asert merged commit dd1fa5a into apache:master Sep 2, 2026
32 checks passed
@paulk-asert
paulk-asert deleted the groovy12331 branch September 2, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants