Skip to content

Fix SAX parser feature URIs and reject DOCTYPE declarations - #16331

Open
jamesfredley wants to merge 6 commits into
8.0.xfrom
fix/xml-parser-hardening
Open

Fix SAX parser feature URIs and reject DOCTYPE declarations#16331
jamesfredley wants to merge 6 commits into
8.0.xfrom
fix/xml-parser-hardening

Conversation

@jamesfredley

Copy link
Copy Markdown
Contributor

Same-repo continuation of #16310. Head is apache/grails-core:fix/xml-parser-hardening (includes jdaugherty's follow-up). The previous PR used the archive fork as head, which is not allowed.

Summary

ASF security review finding f002: SAX XXE hardening in SpringIOUtils.createParserFactory set parser features with https://xml.org/... and https://apache.org/.... The registered JAXP / Xerces identifiers are the http:// forms, so the factory rejected the features and the empty catch hid the failure. XmlDataBindingSourceCreator still uses SpringIOUtils.createXmlSlurper() for application/xml request bodies. The HTTP test-client XmlUtils had the same identifiers and left disallow-doctype-decl false.

This is hardening, not a HIGH CVE against the current threat model (THREAT_MODEL.md §9 disclaims parser configuration / XXE). No threat-model change in this PR.

Changes

  • Use the registered http://xml.org/... and http://apache.org/... SAX feature URIs.
  • Set disallow-doctype-decl to true and disable XInclude.
  • Apply the same defaults in the HTTP test-client XML slurper.
  • Cover DOCTYPE rejection in SpringIOUtilsSpec, XmlUtilsSpec, and TestHttpResponseSpec.

Testing

  • :grails-gradle:grails-gradle-model:test
  • :grails-testing-support-http-client:test
  • :grails-gradle:grails-gradle-model:codeStyle
  • :grails-testing-support-http-client:codeStyle

jamesfredley and others added 3 commits September 3, 2026 17:34
The SAX XXE hardening used https://xml.org and https://apache.org feature
identifiers. Xerces only recognizes the http:// forms, so the empty catch
swallowed the misconfiguration. XmlDataBindingSourceCreator and the HTTP
test client both now set the registered http:// identifiers, disable
XInclude, and reject DOCTYPE declarations.
The SAX feature identifiers were declared twice, once in SpringIOUtils and
once in the HTTP test client, as bare string literals. That duplication is
how the https:// spelling was introduced and went unnoticed: setFeature
answers an unrecognised name with SAXNotRecognizedException, and both call
sites swallow it, so the hardening silently switched off.

Collect the five identifiers in XmlParserFeature in grails-gradle-common,
which sits in the grails-gradle build alongside SpringIOUtils and is already
exposed to the root build by grails-common. The enum documents that the
values are registered identifiers rather than addresses, and carries the
reason the http scheme cannot be rewritten.

Keep rejecting DOCTYPE declarations by default, and add
grails.xml.allowDocTypeDeclaration for applications that must accept them.
SpringIOUtils reads it through Metadata, so it is set in application.yml or
as a system property. Opting in relaxes only whether a declaration is
permitted; external general entities, external parameter entities and
external DTDs stay refused either way, so it does not reopen the XXE vector.

The setting is needed because this parser factory is shared with readers of
trusted classpath descriptors -- TldReader, WebXmlTagLibraryReader and
PluginUtils -- and TLDs routinely carry a DOCTYPE.
jakarta.servlet.jsp.jstl ships eight, including c-1_0-rt.tld, which the
default grails.gsp.tldScanPattern scans, so an application resolving JSP tag
libraries from a GSP needs it enabled. Document that on the JSP tag library
page and in the upgrade notes.

Cover both modules with tests that assert observable behaviour rather than
reading feature flags back, so no test holds a second copy of the
identifiers that a rewrite could update in step with the production code.
XmlParserFeatureSpec additionally asserts every identifier is one a parser
actually registers, turning an unrecognised name into a named failure
instead of a silent no-op.
@jamesfredley

Copy link
Copy Markdown
Contributor Author

from @matrei

AI Review Findings

Head 6fdc50880a on 8.0.x base 55d5076aa5. I reproduced the premise independently against the JDK 21 parser (com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl): every http:// identifier is accepted and every https:// spelling is answered with SAXNotRecognizedException. The sweep that introduced the https forms is eed8df3594 (#13478, 79 files), so since April 2024 createParserFactory() has configured nothing beyond FEATURE_SECURE_PROCESSING, and the catch (Exception) around each call kept that invisible. The correction is real and the direction is right.

There is one blocking problem with the default this PR ships, and it is not covered by any test in the repository. The follow-up in jamesfredley#4 addresses it with a global opt-in; I would take that PR with one change in shape (see the review on jamesfredley#4).

[P1] Rejecting DOCTYPE in the shared factory breaks JSP tag library resolution for every application that uses JSTL

Files:

  • grails-gradle/model/src/main/groovy/org/grails/io/support/SpringIOUtils.java:433
  • grails-gsp/grails-web-jsp/src/main/groovy/org/grails/gsp/jsp/TldReader.groovy:49
  • grails-gsp/grails-web-jsp/src/main/groovy/org/grails/gsp/jsp/TagLibraryResolverImpl.groovy:71-90
  • grails-gsp/spring-boot/src/main/java/grails/gsp/boot/GspAutoConfiguration.java:323

createParserFactory() returns one cached factory for every caller of createXmlSlurper() and newSAXParser(). Those callers fall into two trust levels:

Caller Input
XmlDataBindingSourceCreator, HalXmlDataBindingSourceCreator, grails.converters.XML.parse HTTP request bodies (untrusted)
TldReader, WebXmlTagLibraryReader, PluginUtils Descriptors on the application classpath (trusted)

The descriptors are where the strict default bites. GspAutoConfiguration defaults grails.gsp.tldScanPattern to a list that ends with classpath*:/META-INF/c-1_0-rt.tld. In org.glassfish.web:jakarta.servlet.jsp.jstl:3.0.1 that file opens with a JSP 1.2 DOCTYPE, and so do 8 of the 22 descriptors in the jar (c-1_0*.tld, fmt-1_0*.tld, sql-1_0*.tld, x-1_0*.tld). TagLibraryResolverImpl.initialize() scans every pattern in one loop and catches nothing, so the first resolveTagLibrary(uri) call throws SAXParseException: DOCTYPE is disallowed ... and no JSP tag library resolves, including jakarta.tags.core from the DOCTYPE-free c.tld that was scanned earlier in the same loop.

I confirmed this with a spec in grails-gsp/plugin (JSTL is already on that module's test runtime classpath) that scans c-1_0-rt.tld plus c.tld and resolves jakarta.tags.core. On this head it fails with DOCTYPE is disallowed. The existing GSP tests stay green only because GroovyPageWithJSPTagsTests, AbstractGrailsTagTests and TagLibraryResolverTests scan c.tld, fmt.tld, core.tld and spring*.tld, none of which carries a DOCTYPE; TldReaderTests uses a fixture without one. The two test examples that put JSTL on the runtime classpath (gsp-layout, gsp-sitemesh3) contain no <%@ taglib %> directive, so the resolver is never initialised there either.

The documented custom pattern classpath*:/META-INF/*.tld in usingJSPTagLibraries.adoc hits all eight.

The user-visible symptom is the worst kind: a 500 on the first page that uses a JSP tag, with a parser error that names a feature URI nobody set in the application.

What I would change. The two trust levels want two parsers, and the readers of trusted descriptors know they are reading trusted descriptors. Keep the strict default for request bodies and let the descriptor readers ask for DOCTYPE tolerance explicitly:

// SpringIOUtils
public static XmlSlurper createXmlSlurper() throws ... {            // strict, for request bodies
    return createXmlSlurper(false);
}

public static XmlSlurper createXmlSlurper(boolean allowDocTypeDeclaration) throws ... {
    return new XmlSlurper(createParserFactory(allowDocTypeDeclaration).newSAXParser());
}

with TldReader, WebXmlTagLibraryReader and PluginUtils passing true. External general and parameter entities, DTD grammar loading and external DTD retrieval stay off on both factories, so the tolerant parser still resolves a file:// entity to nothing and skips the web-jsptaglibrary_1_2.dtd reference instead of fetching it. That needs no configuration key, no upgrade note for JSP users, and does not relax request-body parsing as a side effect of using JSTL.

jamesfredley#4 instead adds grails.xml.allowDocTypeDeclaration, read through Metadata, that switches the single shared factory. It works, and I ran its tests, but it makes "I use JSTL" and "accept DOCTYPE in request bodies" the same switch. Details in that review.

[P2] A rejected hardening feature is still swallowed silently

Files:

  • grails-gradle/model/src/main/groovy/org/grails/io/support/SpringIOUtils.java:431-461
  • grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/utils/XmlUtils.groovy:235-243

The PR exists because catch (Exception) { /* ignore */ } hid a SAXNotRecognizedException for two years, and the catch blocks are unchanged. The tolerance for parsers lacking a feature is reasonable, but it should leave a trace. grails-gradle-model already has slf4j-api as an api dependency; a LOG.warn("Parser {} does not support feature {}", factory.getClass(), name) inside each catch costs nothing and would have surfaced this in every application log since 2024. Copilot's suggestion to make disallow-doctype-decl mandatory and propagate is too strong for a shared factory, but a warning is not.

jamesfredley#4 adds XmlParserFeatureSpec, which asserts each identifier is recognised by the JDK parser. That pins the identifiers at test time; the warning covers the runtime case where a different SAX provider is first on the classpath.

[P2] The four external-entity features lose their only behavioural coverage

Files:

  • grails-gradle/model/src/test/groovy/org/grails/io/support/SpringIOUtilsSpec.groovy
  • grails-testing-support-http-client/src/test/groovy/org/apache/grails/testing/http/client/utils/XmlUtilsSpec.groovy:279-300
  • grails-testing-support-http-client/src/test/groovy/org/apache/grails/testing/http/client/TestHttpResponseSpec.groovy:201-227

Every new test asserts DOCTYPE rejection, and once a DOCTYPE is refused no document reaches external-general-entities, external-parameter-entities, load-dtd-grammar or load-external-dtd. The deleted newXmlSlurper blocks external entities and xml uses a secure default slurper that does not resolve external entities were the only tests that drove an entity through the parser and asserted the file contents did not appear. After this PR the same wrong identifier in any of those four lines passes CI again. jamesfredley#4 restores this coverage for SpringIOUtils by testing with DOCTYPE permitted; the split-factory shape above makes that natural, because the tolerant factory is the one that can be tested for entity blocking.

[P2] THREAT_MODEL.md §9 now states something the code no longer does

Files:

  • THREAT_MODEL.md:346
  • THREAT_MODEL.md:470

The description says "No threat-model change in this PR", but §9 currently reads "XXE in XML data binding. XML parsing is delegated to the underlying parser; the framework does not impose a parser configuration. (inferred)", and §14 question 13 proposes confirming exactly that. After this PR the framework does impose a configuration on every XML request body it binds, and the strict DOCTYPE default is a behaviour change users will hit. The sentence in §9 should move to §8 or be rewritten to describe what is now guaranteed (external entities and external DTDs refused, DOCTYPE refused unless opted in), and Q13 should be answered rather than left open. Leaving the disclaimer in place means the next security review will re-report f002 against a document that says the fix does not exist.

Related: the upgrade notes need to say that application/xml, text/xml and HAL XML request bodies carrying a DOCTYPE are now refused. integrationTesting.adoc only covers the test client. jamesfredley#4 adds an "XML Parsing Defaults" section to upgrading.adoc that does this.

[P3] Small things in the test client, still present on jamesfredley#4's head

  • XmlUtils.groovy:116 javadoc drops "disables external entity expansion plus external DTD loading" although those features are still set; the README and integrationTesting.adoc kept the sentence.
  • XmlUtilsSpec.groovy:281,292: def parsed = is assigned and never read.
  • TestHttpResponseSpec.groovy:201-227 and XmlUtilsSpec.groovy:279-300: both tests now assert a bare thrown(SAXParseException), which a malformed document also satisfies. e.message.contains('DOCTYPE is disallowed') pins them to the behaviour their names claim.
  • The external-entity fixtures indent <!ENTITY and ]> by one space; the internal-entity fixtures next to them do not.

Verified

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Hardens XML parsing across Grails utilities by fixing SAX feature URIs, rejecting DOCTYPE declarations by default, and aligning HTTP test-client parsing behavior with the hardened defaults.

Changes:

  • Replace incorrect https://... SAX/Xerces feature identifiers with registered http://... URIs via a shared XmlParserFeature enum.
  • Default to rejecting DOCTYPE declarations and disable XInclude in parser factories / XML slurpers.
  • Add/adjust specs and docs to validate and explain DOCTYPE rejection and the opt-in override.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/utils/XmlUtils.groovy Uses shared feature constants, flips DOCTYPE handling default to “reject”, disables XInclude.
grails-testing-support-http-client/src/test/groovy/org/apache/grails/testing/http/client/utils/XmlUtilsSpec.groovy Updates expectations to assert DOCTYPE rejection.
grails-testing-support-http-client/src/test/groovy/org/apache/grails/testing/http/client/TestHttpResponseSpec.groovy Updates response XML parsing tests to assert DOCTYPE rejection.
grails-testing-support-http-client/build.gradle Adds dependency used to share XML feature identifiers.
grails-testing-support-http-client/README.md Updates documentation to reflect DOCTYPE rejection default.
grails-gradle/model/src/main/groovy/org/grails/io/support/SpringIOUtils.java Fixes feature URIs, adds opt-in key for DOCTYPE, disables XInclude, refactors factory creation.
grails-gradle/model/src/test/groovy/org/grails/io/support/SpringIOUtilsSpec.groovy Adds behavioral tests for DOCTYPE rejection and opt-in, plus XXE safeguards.
grails-gradle/model/build.gradle Adds shared module dependency for feature enum.
grails-gradle/common/src/main/groovy/org/apache/grails/gradle/common/XmlParserFeature.java Introduces centralized, documented feature identifiers.
grails-gradle/common/src/test/groovy/org/apache/grails/gradle/common/XmlParserFeatureSpec.groovy Ensures feature identifiers are recognized by the parser and distinct.
grails-doc/src/en/guide/upgrading.adoc Documents new DOCTYPE-rejection default and the opt-in setting.
grails-doc/src/en/guide/theWebLayer/gsp/taglibs/usingJSPTagLibraries.adoc Documents enabling DOCTYPE for JSP taglib scanning.
grails-doc/src/en/guide/testing/integrationTesting.adoc Updates test-client XML parsing docs to reflect DOCTYPE rejection.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread grails-gradle/model/src/main/groovy/org/grails/io/support/SpringIOUtils.java Outdated
Comment thread grails-gradle/model/src/main/groovy/org/grails/io/support/SpringIOUtils.java Outdated
Comment thread grails-testing-support-http-client/build.gradle
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.03704% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.1123%. Comparing base (e9cd005) to head (e10b69c).

Files with missing lines Patch % Lines
...in/groovy/org/grails/io/support/SpringIOUtils.java 87.0968% 4 Missing ⚠️
...e/grails/testing/http/client/utils/XmlUtils.groovy 75.0000% 2 Missing ⚠️
...sting/AbstractGrailsMockHttpServletResponse.groovy 0.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16331        +/-   ##
==================================================
+ Coverage     55.0810%   55.1123%   +0.0314%     
- Complexity      20804      20826        +22     
==================================================
  Files            2111       2112         +1     
  Lines          101378     101402        +24     
  Branches        18005      18010         +5     
==================================================
+ Hits            55840      55885        +45     
+ Misses          37490      37471        -19     
+ Partials         8048       8046         -2     
Files with missing lines Coverage Δ
...vy/org/apache/grails/core/plugins/PluginUtils.java 73.1579% <100.0000%> (ø)
.../apache/grails/gradle/common/XmlParserFeature.java 100.0000% <100.0000%> (ø)
...rc/main/groovy/org/grails/gsp/jsp/TldReader.groovy 86.6667% <100.0000%> (ø)
...y/org/grails/gsp/jsp/WebXmlTagLibraryReader.groovy 100.0000% <100.0000%> (ø)
...sting/AbstractGrailsMockHttpServletResponse.groovy 0.0000% <0.0000%> (ø)
...e/grails/testing/http/client/utils/XmlUtils.groovy 89.6552% <75.0000%> (-2.5017%) ⬇️
...in/groovy/org/grails/io/support/SpringIOUtils.java 21.4286% <87.0968%> (+18.0502%) ⬆️

... and 9 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.

@matrei

matrei commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

AI Review Findings (round 3)

Head d88d15ea15 on 8.0.x base 0980623481. The 13 files this PR touches are byte-identical to the round-2 head 840aea9b91 (jamesfredley#4); the only new commit is the merge of 8.0.x. So this round is a status check against the two earlier reviews rather than a fresh read. I re-ran everything on this head: :grails-gradle-common:test (12), :grails-gradle-model:test (56), :grails-testing-support-http-client:test (105), all green, plus codeStyle on the three modules. I also re-ran the JSTL spec from round 2 under grails-gsp/plugin: on this head resolveTagLibrary('jakarta.tags.core') still throws SAXParseException: DOCTYPE is disallowed when c-1_0-rt.tld is in the scan pattern, and passes with grails.xml.allowDocTypeDeclaration=true.

The three replies on the Copilot threads are fine as answers to Copilot. None of the findings from the two earlier rounds has a reply or a code change, so they are listed again below with their current status.

[P1] Still open: the opt-in is global, so using JSTL also relaxes request-body parsing

Files:

  • grails-gradle/model/src/main/groovy/org/grails/io/support/SpringIOUtils.java:462-475
  • grails-gsp/grails-web-jsp/src/main/groovy/org/grails/gsp/jsp/TldReader.groovy:49
  • grails-gsp/grails-web-jsp/src/main/groovy/org/grails/gsp/jsp/WebXmlTagLibraryReader.groovy:47
  • grails-core/src/main/groovy/org/apache/grails/core/plugins/PluginUtils.java:141
  • grails-doc/src/en/guide/theWebLayer/gsp/taglibs/usingJSPTagLibraries.adoc:54-64

Unchanged from round 2. The default grails.gsp.tldScanPattern includes c-1_0-rt.tld, that file carries a JSP 1.2 DOCTYPE, so every application that adds JSTL breaks on its first JSP tag unless it sets the flag, and the flag then also admits DOCTYPE in application/xml request bodies. The enum's own javadoc on DISALLOW_DOCTYPE_DECL ("a parser shared with those callers must leave this disabled") describes the problem the code now has.

The ask is the same: give the three trusted-descriptor readers a createXmlSlurper(true) / newSAXParser(true) overload backed by the docTypeParserFactory this PR already builds, keep the strict factory for request bodies unconditionally, and drop the JSP doc paragraph. Both cached factories and buildParserFactory(boolean) carry over as they are. Whether grails.xml.allowDocTypeDeclaration survives as a request-body-only switch is a separate decision; if it does, it should only select which factory the no-arg methods return.

If the maintainers decide the global switch is the intended shape, that is a legitimate call, but it should be said on the PR so the next reviewer does not re-raise it, and the JSP doc paragraph then needs to say plainly that the setting also affects request bodies.

[P2] Still open: the setting is resolved through Metadata, and the docs do not say what that excludes

Files:

  • grails-gradle/model/src/main/groovy/org/grails/io/support/SpringIOUtils.java:477-480
  • grails-gradle/model/src/main/groovy/grails/util/Metadata.groovy:126-165,168-173
  • grails-doc/src/en/guide/upgrading.adoc:34-51

I re-read Metadata on this head to make sure the round-2 claim holds. loadFromDefault() reads the classpath application.yml, grails.build.info and system properties, and loadYml() keeps only propertySources[0], so environments: blocks map to environments.production.grails.xml... and are never consulted, application.groovy is never evaluated, and there is no relaxed-binding path for GRAILS_XML_ALLOWDOCTYPEDECLARATION. The upgrade note says "in application.yml, or as a system property", which a reader will take to mean the normal Grails configuration rules. One sentence stating that only a top-level application.yml key or the system property is honoured, and that environments: and application.groovy are not, closes this. Under the P1 shape the flag might go away entirely, which also closes it.

One new observation while reading this: createParserFactory() calls Metadata.getCurrent() on every parse, and Metadata is held in a SoftReference. Under memory pressure the reference is cleared and the next XML request body re-reads application.yml and grails.build.info from the classpath before parsing. Cheap, but it is a new classpath read on the request path, and one more reason to resolve the choice once at the call site rather than per parse.

[P2] Still open: the regression path has no test in the repository

Files:

  • grails-gsp/plugin/src/test/groovy/org/grails/gsp/jsp/TagLibraryResolverTests.groovy:30-37
  • grails-gsp/plugin/build.gradle:189

SpringIOUtilsSpec parses a string shaped like a JSP 1.2 descriptor; nothing scans the real c-1_0-rt.tld through TagLibraryResolverImpl. grails-gsp/plugin already has org.glassfish.web:jakarta.servlet.jsp.jstl on testRuntimeOnly, and TagLibraryResolverTests already builds the resolver by hand, so the two-case spec from round 2 drops in unchanged. It is the test that would have caught #16310 and the one that keeps the default tldScanPattern honest. I ran it again on this head; both cases behave as described above.

[P2] Still open: a rejected hardening feature leaves no trace

Files:

  • grails-gradle/model/src/main/groovy/org/grails/io/support/SpringIOUtils.java:481-503
  • grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/utils/XmlUtils.groovy:223-237

The reply to Copilot on this thread says failing fast would change supported behaviour, which is true, and neither earlier review asked for that. The ask is a LOG.warn naming the factory class and the feature inside each catch. XmlParserFeatureSpec pins the identifiers against the JDK parser at test time; the warning covers the runtime case where another SAX provider is first on the classpath and silently fails open, which is exactly how this went unnoticed for two years. grails-gradle-model already has slf4j-api as api.

[P2] Still open: THREAT_MODEL.md §9 and §14 Q13 contradict the code

Files:

  • THREAT_MODEL.md:346
  • THREAT_MODEL.md:470

Both lines are unchanged on this head. §9 still says the framework does not impose a parser configuration and Q13 still proposes confirming that. After this PR the framework imposes one on every XML request body it binds. Leaving the text as is means the next security review re-reports f002 against a document that says the fix does not exist. This can be a one-paragraph change in this PR or a follow-up, but it should be tracked.

[P3] Still open: the small items from #16310

None of these moved since round 1:

  • XmlUtils.groovy:116 javadoc drops "disables external entity expansion plus external DTD loading" although both features are still set; the README and integrationTesting.adoc kept the sentence.
  • XmlUtilsSpec.groovy:281,292: def parsed = is assigned and never read.
  • TestHttpResponseSpec.groovy:201-215 and XmlUtilsSpec.groovy:279-300: both DOCTYPE tests assert a bare thrown(SAXParseException), which a malformed document also satisfies. e.message.contains('DOCTYPE is disallowed') pins them to what their names claim.
  • The external-entity fixtures indent <!ENTITY and ]> by one space; the internal-entity fixtures next to them do not.
  • grails-doc/src/en/guide/upgrading/upgrading80x.adoc has no pointer to the new "XML Parsing Defaults" section on the top-level page.

[P3] New: one more caller of the strict factory

Files:

  • grails-test-core/src/main/groovy/org/grails/plugins/testing/AbstractGrailsMockHttpServletResponse.groovy:84-86

response.xml in controller unit tests goes through SpringIOUtils.createXmlSlurper() too. A controller that renders XML with a DOCTYPE, which is the application's own output, now makes its unit test throw DOCTYPE is disallowed instead of returning a GPathResult. Not a security concern and rare, but it is a behaviour change the upgrade note does not mention, and it is another caller whose trust level does not match the request-body default.

Verified

  • grails-gradle: :grails-gradle-common:test 12 cases, :grails-gradle-model:test 56 cases, both codeStyle tasks, all green on d88d15ea15.
  • Root: :grails-testing-support-http-client:test 105 cases and codeStyle, green.
  • :grails-gsp:test --tests org.grails.gsp.jsp.JstlDocTypeTldSpec (my two-case spec, not committed): strict default throws SAXParseException containing DOCTYPE is disallowed from resolveTagLibrary('jakarta.tags.core'); with the opt-in both jakarta.tags.core and http://java.sun.com/jstl/core_rt resolve.
  • git diff 840aea9b91 HEAD over the 13 PR files is empty; the merge commit resolved nothing in them.
  • THREAT_MODEL.md:346 and :470 are unchanged on this head.
  • The grails-gradle-common coordinate is managed by every grails-bom variant (grails-bom/*/build.gradle:48), so the versionless dependency in the test client resolves for published consumers; the round-2 remark about the module's placement stands as a preference only.

@matrei

matrei commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

I think this is the best solution for the DOCTYPE reading:

What I would change. The two trust levels want two parsers, and the readers of trusted descriptors know they are reading trusted descriptors. Keep the strict default for request bodies and let the descriptor readers ask for DOCTYPE tolerance explicitly:

// SpringIOUtils
public static XmlSlurper createXmlSlurper() throws ... {            // strict, for request bodies
    return createXmlSlurper(false);
}

public static XmlSlurper createXmlSlurper(boolean allowDocTypeDeclaration) throws ... {
    return new XmlSlurper(createParserFactory(allowDocTypeDeclaration).newSAXParser());
}

with TldReader, WebXmlTagLibraryReader and PluginUtils passing true. External general and parameter entities, DTD grammar loading and external DTD retrieval stay off on both factories, so the tolerant parser still resolves a file:// entity to nothing and skips the web-jsptaglibrary_1_2.dtd reference instead of fetching it. That needs no configuration key, no upgrade note for JSP users, and does not relax request-body parsing as a side effect of using JSTL.

We could optionally check the config value and pass that in the no-param method.

@jdaugherty

Copy link
Copy Markdown
Contributor

@matrei I believe I implemented your solution, and all feedback on this PR. Please re-review.

@jdaugherty
jdaugherty requested a review from matrei September 11, 2026 01:37
@testlens-app

testlens-app Bot commented Sep 11, 2026

Copy link
Copy Markdown

🚨 TestLens detected 1 failed test 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

CI / Functional Tests (Java 21, indy=true) > :grails-test-examples-scaffolding:integrationTest

Test Runs Flakiness
UserControllerSpec > User list 5% 🟠

🏷️ Commit: e10b69c
▶️ Tests: 84000 executed
⚪️ Checks: 91/91 completed

Test Failures

UserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in CI / Functional Tests (Java 21, indy=true))
geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
	at geb.waiting.Wait.waitFor(Wait.groovy:128)
	at geb.waiting.DefaultWaitingSupport.doWaitFor(DefaultWaitingSupport.groovy:55)
	at geb.waiting.DefaultWaitingSupport.waitFor(DefaultWaitingSupport.groovy:41)
	at geb.Page.waitFor(Page.groovy:120)
	at com.example.pages.LoginPage.login(LoginPage.groovy:39)
	at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:48)
Caused by: Assertion failed: 

title != pageTitle && $('input', name: 'username').empty
|     |  |         |
|     |  |         false
|     |  'Please sign in'
|     false
'Please sign in'

	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy:39)
	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy)
	at geb.waiting.Wait.waitFor(Wait.groovy:117)
	... 5 more

Rerun Controls

Select tests to mute in this pull request:

  • UserControllerSpec > User list

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app/docs.

@matrei matrei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Findings (round 4)

Head e10b69c4be on 8.0.x base e9cd00567b. One new commit on top of the round-3 head d88d15ea15 plus a merge of 8.0.x. It is the two-parser shape from round 2, applied in full. I re-ran everything on this head: :grails-gradle-common:test (12), :grails-gradle-model:test (61), :grails-web-databinding:test for the new binding spec (4), :grails-core:test for PluginDiscoverySpec (19), :grails-test-suite-uber:test for GrailsMockHttpServletResponseTests (3), :grails-gsp:test for the org.grails.gsp.jsp package (33, including the JSTL spec), :grails-testing-support-http-client:test (105), all green, and codeStyle on the seven touched modules, clean.

Resolved since round 3

Every open item from rounds 1 to 3 has a code or doc change on this head.

  • [P1] Global opt-in. Gone. SpringIOUtils now hands out two cached factories, createXmlSlurper(boolean) and newSAXParser(boolean) select between them, the no-arg forms stay strict, and TldReader, WebXmlTagLibraryReader and PluginUtils pass true. grails.xml.allowDocTypeDeclaration no longer exists anywhere in the tree, Metadata is untouched, and the JSP page carries one sentence instead of a configuration paragraph. XmlDataBindingSourceCreator, HalXmlDataBindingSourceCreator and XML.parse still call the no-arg form, so request bodies are strict with no switch.
  • [P2] Metadata resolution. Moot; the flag is gone and nothing is resolved per parse any more.
  • [P2] Regression path untested. JstlDocTypeTldSpec scans c.tld and c-1_0-rt.tld through TagLibraryResolverImpl and resolves all eight JSP 1.2 JSTL URIs; TldReaderTests and WebXmlTagLibraryReaderTests cover DOCTYPE plus the external-entity case at the reader level; PluginDiscoverySpec reads a grails-plugin.xml whose DOCTYPE names a missing DTD.
  • [P2] Rejected feature leaves no trace. setFeature in SpringIOUtils and the loop in XmlUtils now catch ParserConfigurationException | SAXException only and log a warning naming the factory class and the feature. The logger is looked up per call in SpringIOUtils with a comment explaining why, which is the right call for a class used before logging is configured.
  • [P2] THREAT_MODEL.md contradicts the code. §9 line removed, P10 added to §8 with the documented provenance, Q13 marked resolved, a false-friend entry scopes the claim to framework-parsed documents, threat-model.yaml mirrors all of it.
  • [P3] Small items. XmlUtils javadoc, the unused parsed locals, the bare thrown(SAXParseException) assertions, the fixture indentation and the upgrading80x.adoc pointer are all fixed. The two DOCTYPE tests in the test client and XmlUtilsSpec now assert DOCTYPE is disallowed.
  • [P3] response.xml in controller unit tests. Now tolerant, with a DOCTYPE case and an external-entity case in GrailsMockHttpServletResponseTests.
  • Copilot: unsafe publication. Both factory fields are volatile now.

[P3] New: the invalidRequestBody claim covers single-object binding only

Files:

  • grails-doc/src/en/guide/upgrading.adoc:40
  • grails-doc/src/en/guide/upgrading/upgrading80x.adoc:3588-3589
  • grails-web-databinding/src/main/groovy/org/grails/web/databinding/bindingsource/AbstractRequestBodyDataBindingSourceCreator.groovy:77-100
  • grails-web-databinding/src/main/groovy/grails/web/databinding/DataBindingUtils.java:507-511

Both pages say a body that declares a DOCTYPE "fails with the invalidRequestBody error code and the target is left unpopulated". That is true for bindData and command-object binding, where createBindingSourceCreationException wraps the SAXParseException in InvalidRequestBodyException and DataBindingUtils.bindObjectToInstance turns it into the error code. It is not true for the collection path: createCollectionDataBindingSource in the abstract creator wraps every exception in a plain DataBindingSourceCreationException, and bindToCollection does not catch it, so a collection body carrying a DOCTYPE propagates as an exception. The new XmlDataBindingSourceCreatorSpec encodes exactly that difference in its third feature.

The asymmetry predates this PR; a malformed collection body behaved the same way. But the PR introduces a new input that hits it, and the upgrade note is where a reader will look. One clause saying that collection binding surfaces the failure as a DataBindingSourceCreationException rather than a binding error closes this. Routing the collection path through createBindingSourceCreationException would be the better fix but changes behaviour for JSON too, so it belongs in a follow-up.

[P3] New: XML.parse is named in the docs and P10 but has no direct test

Files:

  • grails-converters/src/main/groovy/grails/converters/XML.java:62,311,329

XML.parse(String) and XML.parse(HttpServletRequest) statically import the no-arg createXmlSlurper, so they are strict and SpringIOUtilsSpec covers the behaviour transitively. Both doc pages and the threat model name XML.parse explicitly though, and request.XML goes through it. A one-case spec in grails-converters that calls XML.parse on a DOCTYPE string and asserts DOCTYPE is disallowed pins the documented claim to the class that makes it. Optional.

Note: the compile-time plugin descriptor reader stays strict

GlobalGrailsClassInjectorTransformation.groovy:654 reads the plugin's own plugin.xml at compile time through the no-arg IOUtils.createXmlSlurper(), while PluginUtils now reads the same descriptor type at runtime with the tolerant parser. Grails generates plugin.xml itself and never writes a DOCTYPE, so nothing breaks and I am not asking for a change. Mentioning it only so the next reader does not take the PluginUtils comment as a statement that the descriptor is read tolerantly everywhere.

Verified

  • grails-gradle: :grails-gradle-common:test 12 cases, :grails-gradle-model:test 61 cases (56 before this commit), both codeStyle tasks, all green on e10b69c4be.
  • Root: XmlDataBindingSourceCreatorSpec 4, PluginDiscoverySpec 19, GrailsMockHttpServletResponseTests 3, org.grails.gsp.jsp.* 33, :grails-testing-support-http-client:test 105, all green. codeStyle clean on grails-web-databinding, grails-core, grails-gsp, grails-testing-support-http-client and grails-test-core.
  • grep -rn allowDocTypeDeclaration over source, docs and yaml finds only the new method parameter in SpringIOUtils; git diff e9cd00567b HEAD -- grails-gradle/model/src/main/groovy/grails/util/Metadata.groovy is empty.
  • Every production caller of createXmlSlurper / newSAXParser was checked: the three descriptor readers and response.xml pass true; the two binding-source creators, XML.parse and the compile-time plugin.xml reader use the strict default.
  • grails-gradle-common has only compileOnly dependencies, so the new implementation edge from grails-gradle-model and the test client brings no Gradle API onto the runtime classpath, as the reply to Copilot says.
  • The testlens failure on the PR is UserControllerSpec > User list, a Geb login timeout unrelated to any file this PR touches.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants