Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ Each property is stated with its conditions, the symptom of a violation, a sever
| P7 | **Compile-time AST transforms (`@Resource`, `@Validateable`, etc.) only act on developer-authored source.** | [CWE-94](https://cwe.mitre.org/data/definitions/94.html) | Build runs on developer-controlled source. | A transform fires on or is influenced by attacker-supplied input. | **Correctness** (security-critical only if reachable from a non-build attacker) | *(inferred)* |
| P8 | **Configuration loading does not evaluate `application.groovy` from a path the framework itself chose at runtime - paths come from build-time classpath and operator-supplied environment/system properties.** | [CWE-94](https://cwe.mitre.org/data/definitions/94.html) | Operator has not pointed `grails.config.locations` at attacker-writable storage. | A user request causes evaluation of a Groovy file the operator did not authorize. | **Security-critical (CVE-eligible)** if violated. | *(inferred)* (§14 wave 1) |
| P9 | **`maxFileSize` / `maxRequestSize` / `autoGrowCollectionLimit` provide bounded data-binding memory.** | [CWE-770](https://cwe.mitre.org/data/definitions/770.html) | Operator does not raise the limits past application needs. | Memory growth proportional to attacker-controlled input regardless of limit. | **Resource bug** | *(inferred)* (§14 wave 2) |
| P10 | **XML the framework parses is XXE-hardened: external general and parameter entities, external DTDs and DTD grammars are refused, and a request body that declares a `DOCTYPE` is rejected.** | [CWE-611](https://cwe.mitre.org/data/definitions/611.html) | Document is parsed by the framework's XML data binding (`application/xml`, `text/xml`, `application/hal+xml`) or by `XML.parse`; a parser the application constructs itself gets the JDK defaults. | Content of an external entity appears in bound data, or a body carrying a `DOCTYPE` is bound. | **Security-critical (CVE-eligible)** | *(documented: [upgrading.adoc](./grails-doc/src/en/guide/upgrading.adoc) "XML Parsing Defaults")* |

### Resource consumption line

Expand Down Expand Up @@ -335,6 +336,7 @@ Features that **look like** a security property but are not one. Reports that co
- **`grails.serverURL` is for link generation, not an authoritative declaration of the deployment URL for security purposes.** Setting it does not bind the application to that origin; the embedded container still serves whatever the operator binds it to. *(inferred)*
- **`GRAILS_ENV=development` is not a security boundary.** Stack traces, verbose error pages, and dev-tool endpoints surfaced in `development` mode are a deployment-configuration symptom, not a framework vulnerability. A report that requires `GRAILS_ENV=development` to reproduce is `OUT-OF-MODEL: non-default-build` (§13), not `VALID`. *(inferred)* (§14 wave 1)
- **`grails.config.locations` is a Groovy code-execution path, not a configuration-file path.** Any file the application process can read AND an attacker can write to is equivalent to classpath compromise: the `.groovy` form is evaluated via `ConfigSlurper`. A path that looks like "just config" but lives in attacker-writable storage (e.g. an S3 bucket without write controls, a world-writable `/tmp` derivative, a CI artifact directory) is `BY-DESIGN: property-disclaimed` (§13). *(inferred)* (§14 wave 1)
- **XML hardening covers documents the framework parses, not parsers the application constructs.** Request bodies bound through the framework and descriptors it reads from the classpath are parsed with external entities and external DTDs refused, and request bodies additionally reject a `DOCTYPE` (§8 P10). An `XmlSlurper` or `XmlParser` the application creates itself gets the JDK defaults; XXE through such a parser is application code, not a framework finding. *(inferred)*

### Well-known attack classes against this category of project that the framework does not defend against

Expand All @@ -343,7 +345,6 @@ One sentence per class.
- **Mass assignment.** Binding the request map directly to a domain class without `bindable`/allow-lists. *(inferred)*
- **Open redirect.** Using a request parameter as a `redirect(url: params.next)` target. *(documented: [securingAgainstAttacks.adoc](./grails-doc/src/en/guide/security/securingAgainstAttacks.adoc) "XSS - cross-site scripting injection" mentions this in the `successURL` example)*
- **Server-Side Request Forgery (SSRF).** No built-in URL-fetch allow-list. *(inferred)*
- **XXE in XML data binding.** XML parsing is delegated to the underlying parser; the framework does not impose a parser configuration. *(inferred)*
- **ReDoS in developer-authored URL mappings and constraint regexes.** No complexity ceiling. *(inferred)*
- **Zip-bomb / archive expansion** in multipart and `grails-forge` ZIP generation. Bounded only by container size limits. *(inferred)*
- **Path traversal** through `MultipartFile.originalFilename` if used as a filesystem path. *(inferred)*
Expand Down Expand Up @@ -467,7 +468,7 @@ The model is **draft-first**. The questions below are grouped in waves of 3-7 pe
10. **`SimpleDataBinder` mass-assignment.** *Proposed*: binding `new Book(params)` without an allow-list is `VALID-HARDENING` (the framework should warn, not block, but documents should call this out more loudly). Or is it `BY-DESIGN: property-disclaimed`? Choose.
11. **Multipart limits.** *Proposed*: the framework does not impose multipart caps beyond Spring Boot's defaults; this is operator responsibility. Confirm.
12. **`bindable=false` semantics.** *Proposed*: `bindable=false` is enforced for all binding paths (`bindData`, command-object binding, domain-class constructor binding, `properties=`). Confirm coverage - is there any binding path that ignores it?
13. **XML data binding parser configuration.** *Proposed*: the framework does not impose XXE-hardening configuration on the XML parser; XXE in `XmlDataBindingSourceCreator` is the parser's threat model, not the framework's. Confirm.
13. **XML data binding parser configuration.** *Resolved*: the framework imposes XXE hardening on every XML document it parses and rejects a `DOCTYPE` in request bodies (§8 P10). XXE through a parser the application constructs itself remains the application's responsibility (§9 false friend). Confirm the wording of P10 and promote it to *(maintainer)*.

### Wave 3 - misuse, false friends, and §11a curation

Expand Down Expand Up @@ -512,5 +513,6 @@ This back-map proves §3.1a coverage. Every threat-model-shaped claim already in
| [`SECURITY.md`](./SECURITY.md) | Disclosure routes through the ASF Security Team. | §1 reporting cross-reference |
| [`AGENTS.md`](./AGENTS.md) | JDK 21, Groovy 4.0.x, Spring Boot 4.0.x, Jakarta EE 10, Spock 2.3. | §5 runtime assumptions |
| [`README.md`](./README.md) | The framework is embedded in a user web application; not a service. | §1 description, §2 deployment context |
| [`grails-doc/.../upgrading.adoc`](./grails-doc/src/en/guide/upgrading.adoc) "XML Parsing Defaults" | Framework-parsed XML refuses external entities and external DTDs; a request body declaring a `DOCTYPE` is rejected. | §8 P10, §9 false friend |

No claim in the existing documentation is dropped, weakened, or contradicted by this document. Where the existing documentation and this document would conflict, the documentation wins; raise a §14 question rather than silently editing.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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
*
* https://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 grails.converters

import jakarta.servlet.http.HttpServletRequest
import org.springframework.mock.web.MockHttpServletRequest
import org.xml.sax.SAXParseException
import spock.lang.Specification
import spock.lang.Unroll

import org.grails.web.converters.exceptions.ConverterException

/**
* Holds {@link XML#parse} to the parsing guarantee the user guide and the threat model state for it:
* a document that declares a {@code DOCTYPE} is refused rather than parsed, whether or not the
* declaration references anything external.
*
* <p>Refusing the declaration is what closes the entity vectors at this entry point, since an entity
* cannot be declared without one. The parser features themselves are covered by
* {@code SpringIOUtilsSpec}; what is pinned here is that the class the documentation names uses the
* strict parser, through every overload an application reaches, {@code request.XML} included.
*/
class XMLParseSpec extends Specification {

private static final String DOCUMENT = '<book><title>Grails</title></book>'

private static final String DOCUMENT_WITH_DOCTYPE = "<!DOCTYPE book>\n${DOCUMENT}"

@Unroll
void 'parsing #entryPoint refuses a declared doctype'() {
when: 'a document declaring a doctype is parsed'
invoke(DOCUMENT_WITH_DOCTYPE)

then: 'it is refused rather than parsed'
ConverterException e = thrown()
e.message == 'Error parsing XML'
e.cause instanceof SAXParseException
e.cause.message.contains('DOCTYPE is disallowed')

and: 'the same document is read once the declaration is removed'
invoke(DOCUMENT).title.text() == 'Grails'

where:
entryPoint | invoke
'a string' | { String xml -> XML.parse(xml) }
'a stream' | { String xml -> XML.parse(new ByteArrayInputStream(xml.getBytes('UTF-8')), 'UTF-8') }
'a request' | { String xml -> XML.parse(post(xml)) }
}

private static HttpServletRequest post(String xml) {
new MockHttpServletRequest('POST', '/books').tap {
characterEncoding = 'UTF-8'
content = xml.getBytes('UTF-8')
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ public static List<PluginDescriptor> scanPluginDescriptorResources(ClassLoader c

try {
Enumeration<URL> resources = classLoader.getResources(PLUGIN_XML_PATTERN);
// Grails generates this descriptor and never writes a DOCTYPE, so it is read with the
// strict parser, matching the compile-time transform that generates and rewrites it
SAXParser saxParser = SpringIOUtils.newSAXParser();

while (resources.hasMoreElements()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,38 @@ class PluginDiscoverySpec extends Specification {
tempDir.deleteDir()
}

def 'skips a plugin descriptor that declares a doctype'() {
given: 'a descriptor that declares a DOCTYPE, which Grails never generates'
def doctypeDir = File.createTempDir()
def doctypeMetaInf = new File(doctypeDir, 'META-INF').tap { mkdirs() }
new File(doctypeMetaInf, 'grails-plugin.xml').text = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plugin>
<plugin name='doctype'>
<type>com.example.DoctypeGrailsPlugin</type>
</plugin>
"""

and: 'a second descriptor on the same classpath that declares none'
def plainDir = File.createTempDir()
def plainMetaInf = new File(plainDir, 'META-INF').tap { mkdirs() }
new File(plainMetaInf, 'grails-plugin.xml').text = """<plugin name='plain'>
<type>com.example.PlainGrailsPlugin</type>
</plugin>
"""
def classLoader = new URLClassLoader(
[doctypeDir.toURI().toURL(), plainDir.toURI().toURL()] as URL[], (ClassLoader) null)

when: 'plugin descriptor resources are scanned'
def descriptors = PluginUtils.scanPluginDescriptorResources(classLoader)

then: 'the declaration is refused and only the descriptor without one is discovered'
descriptors*.providedPlugins == [['com.example.PlainGrailsPlugin']]

cleanup:
doctypeDir.deleteDir()
plainDir.deleteDir()
}

def 'ignores malformed plugin descriptor XML without failing discovery'() {
given: 'a classloader that returns a grails-plugin.xml with invalid content'
def badXml = '<plugin><type>valid.Class</type><broken'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,45 @@ class GlobalGrailsClassInjectorTransformationSpec extends Specification {
xml.resources.resource*.text() == ['KeptThing']
}

void "plugin xml update recreates a descriptor that declares a doctype"() {
given:
def logCapture = new LogCapture(GlobalGrailsClassInjectorTransformation, Level.WARN)

and: 'an existing descriptor declaring a doctype, which Grails never generates'
def pluginXml = new File(tempDir, 'doctype-plugin.xml')
pluginXml.text = '''
<!DOCTYPE plugin>
<plugin>
<resources>
<resource>ExistingThing</resource>
</resources>
</plugin>
'''

when: 'the transformation attempts to update the descriptor'
transformation.updatePluginXml(null, null, pluginXml, ['NewThing'])

then: 'the declaration is refused, so the descriptor is discarded and a warning is logged'
!pluginXml.exists()
logCapture.events.size() == 1
with(logCapture.events[0]) {
level == Level.WARN
formattedMessage == "Failed to update existing file ${pluginXml.absolutePath}. Recreating it instead..."
}

and: 'the deferred names are written out when the descriptor is next generated'
transformation.generatePluginXml(
compilePlugin('class DoctypeRecoveredGrailsPlugin {}'),
'1.0',
[] as Set,
pluginXml
)
new XmlSlurper().parse(pluginXml).resources.resource*.text() == ['NewThing']

cleanup:
logCapture.close()
}

void "plugin xml update recreates safely when the existing descriptor is malformed"() {
given:
def logCapture = new LogCapture(GlobalGrailsClassInjectorTransformation, Level.WARN)
Expand Down
2 changes: 1 addition & 1 deletion grails-doc/src/en/guide/testing/integrationTesting.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ Supported named options mirror the `JsonSlurper` settings exposed by `JsonUtils.
===== Custom XML Parsing

Response XML parsing uses a secure default `XmlSlurper` configuration. It is namespace-aware, non-validating,
allows inline `DOCTYPE` declarations, and disables external entity expansion plus external DTD loading.
rejects `DOCTYPE` declarations, and disables external entity expansion plus external DTD loading.

When a test needs different XML parsing behavior, override it fluently on the response wrapper:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ grails:
tldScanPattern: 'classpath*:/META-INF/*.tld,/WEB-INF/tld/*.tld'
----

Descriptors that declare a `DOCTYPE`, such as the JSP 1.2 descriptors in `jakarta.servlet.jsp.jstl`, are read without retrieving the DTD they reference.

JSTL standard library is no longer added as a dependency by default. In case you are using JSTL, you should also add these dependencies to `build.gradle`:
[source,groovy]
.build.gradle
Expand Down
12 changes: 12 additions & 0 deletions grails-doc/src/en/guide/upgrading.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,15 @@ In compatibility mode, a `bindData` call that supplies only `exclude` continues
Values of typed `Map` properties are converted to the declared value type. Conversion failures are added to the binding errors and data binding listeners receive the corresponding events.

The values `true`, `false`, `'true'`, and `'false'` are accepted for `grails.databinding.denyByDefault`, ignoring case and surrounding whitespace for strings. An unrecognised value logs a warning and enables secure deny-by-default binding.

=== XML Parsing Defaults

XML that Grails parses on an application's behalf is read with external entity expansion, external DTD retrieval and DTD grammar loading disabled. This closes the XXE vector: an entity that points at a file on disk contributes nothing to the parsed document, and a DTD that a document names is skipped rather than fetched.

How a `DOCTYPE` declaration is treated depends on where the document comes from.

Request bodies are refused if they declare one. An `application/xml`, `text/xml` or `application/hal+xml` request body, or a string passed to `XML.parse`, that carries a `DOCTYPE` is rejected rather than bound: binding fails with the `invalidRequestBody` error code and the target is left unpopulated. This is stricter than blocking external entities, because a body whose `DOCTYPE` is entirely internal and harmless is refused too, and there is no setting that relaxes it. A client that sends a declaration, which is unusual for an API payload, needs to omit it.

Descriptors that Grails reads from the classpath may declare one. JSP tag library descriptors and `web.xml` are trusted input and routinely carry a `DOCTYPE`; `jakarta.servlet.jsp.jstl` ships several such descriptors, among them `c-1_0-rt.tld`, which the default `grails.gsp.tldScanPattern` scans. These are parsed with the declaration permitted and the entity and DTD hardening above still in force, so the DTD a descriptor names is never retrieved. The same applies to `response.xml` in a controller unit test, whose document is the controller's own output.

The plugin descriptor `grails-plugin.xml` is the exception among the descriptors. Grails generates it during compilation and never writes a `DOCTYPE`, so both the compiler that rewrites it and the plugin discovery that reads it back use the strict parser. A descriptor that has been hand-edited to add a declaration is refused: at compile time it is discarded and regenerated, and at runtime the plugin it describes is skipped.
18 changes: 18 additions & 0 deletions grails-doc/src/en/guide/upgrading/upgrading80x.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -3603,3 +3603,21 @@ def index(Integer max) {
}
}
----

==== 60. XML Request Bodies That Declare a DOCTYPE Are Refused

XML that Grails parses on an application's behalf is now read with external entity expansion, external DTD retrieval and
DTD grammar loading disabled. Earlier releases set these parser features under identifiers the parser did not recognise,
so the parser's own defaults applied instead.

An `application/xml`, `text/xml` or `application/hal+xml` request body, or a string passed to `XML.parse`, that declares
a `DOCTYPE` is additionally refused rather than bound, whether or not the declaration references anything external.
Binding such a body fails with the `invalidRequestBody` error code. There is no setting that relaxes this; a client that
sends a declaration needs to omit it.

Descriptors that Grails reads from the classpath, such as JSP tag library descriptors and `web.xml`, and the
`response.xml` accessor in controller unit tests are not affected. They may declare a `DOCTYPE`, though the DTD it names
is never retrieved. The plugin descriptor `grails-plugin.xml` is not among them: Grails generates it and never writes a
declaration, so it is read strictly at compile time and at runtime alike.

See <<upgrading>> for the full description of the XML parsing defaults.
Loading
Loading