You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/01 06:24:36 UTC

(grails-core) 01/01: Harden unsafe controller render defaults

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

jamesfredley pushed a commit to branch fix/render-safe-content-types
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 7f1d7a9411d70479bacd74f590b0564238f552ea
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Wed Jul 1 02:22:19 2026 -0400

    Harden unsafe controller render defaults
    
    Default inspect-style render output to text/plain so unstructured values are not served as HTML. Render file responses as attachments by default, escape unsafe filename characters in Content-Disposition, and document the inline opt-out.
    
    Assisted-by: Hephaestus:gpt-5.5
---
 .../controller/support/ResponseRenderer.groovy     |  31 +++-
 .../controller/support/ResponseRendererSpec.groovy | 156 +++++++++++++++++++++
 grails-doc/src/en/ref/Controllers/render.adoc      |  11 +-
 .../grails/web/servlet/RenderMethodTests.groovy    |  28 +++-
 4 files changed, 217 insertions(+), 9 deletions(-)

diff --git a/grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRenderer.groovy b/grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRenderer.groovy
index 8ef0a3576e..f1d1fef9fb 100644
--- a/grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRenderer.groovy
+++ b/grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRenderer.groovy
@@ -134,7 +134,7 @@ trait ResponseRenderer extends WebAttributes {
         GrailsWebRequest webRequest = (GrailsWebRequest) RequestContextHolder.currentRequestAttributes()
         HttpServletResponse response = webRequest.currentResponse
         webRequest.renderView = false
-        applyContentType(response, null, object)
+        applyContentType(response, null, object, true, 'text/plain')
 
         try {
             response.writer.write(object.inspect())
@@ -391,14 +391,12 @@ trait ResponseRenderer extends WebAttributes {
                     if (!hasContentType) {
                         hasContentType = detectContentTypeFromFileName(webRequest, response, argMap, fileName)
                     }
-                    if (fnO) {
-                        response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "$DISPOSITION_HEADER_PREFIX\"$fileName\"")
-                    }
                 }
                 if (!hasContentType) {
                     throw new ControllerExecutionException(
                             'Argument [file] of render method specified without valid [contentType] argument')
                 }
+                applyFileDisposition(response, argMap, fileName)
 
                 InputStream input
                 try {
@@ -432,7 +430,7 @@ trait ResponseRenderer extends WebAttributes {
                 response.contentType = GrailsWebUtil.getContentType(MimeType.JSON.name, DEFAULT_ENCODING)
                 renderWritable((JSONElement) argMap, response)
             } else {
-                applyContentType(response, argMap, argMap)
+                applyContentType(response, argMap, argMap, true, 'text/plain')
                 try {
                     response.writer.write(argMap.inspect())
                 }
@@ -520,8 +518,12 @@ trait ResponseRenderer extends WebAttributes {
     }
 
     private boolean applyContentType(HttpServletResponse response, Map argMap, Object renderArgument, boolean useDefault) {
+        applyContentType(response, argMap, renderArgument, useDefault, TEXT_HTML)
+    }
+
+    private boolean applyContentType(HttpServletResponse response, Map argMap, Object renderArgument, boolean useDefault, String defaultContentType) {
         boolean contentTypeIsDefault = true
-        String contentType = resolveContentTypeBySourceType(renderArgument, useDefault ? TEXT_HTML : null)
+        String contentType = resolveContentTypeBySourceType(renderArgument, useDefault ? defaultContentType : null)
         String encoding = DEFAULT_ENCODING
         if (argMap != null) {
             if (argMap.containsKey(ARGUMENT_CONTENT_TYPE)) {
@@ -540,6 +542,23 @@ trait ResponseRenderer extends WebAttributes {
         false
     }
 
+    private void applyFileDisposition(HttpServletResponse response, Map argMap, String fileName) {
+        if (response.getHeader(HttpHeaders.CONTENT_DISPOSITION) != null) {
+            return
+        }
+        if (Boolean.TRUE.equals(argMap.get('inline'))) {
+            return
+        }
+        String disposition = fileName ? "$DISPOSITION_HEADER_PREFIX\"${escapeContentDispositionFilename(fileName)}\"" : 'attachment'
+        response.setHeader(HttpHeaders.CONTENT_DISPOSITION, disposition)
+    }
+
+    private String escapeContentDispositionFilename(String fileName) {
+        fileName.replace('\\', '\\\\')
+                .replace('"', '\\"')
+                .replaceAll('[\\x00-\\x1F\\x7F]', '_')
+    }
+
     private void setContentType(HttpServletResponse response, String contentType, String encoding) {
         setContentType(response, contentType, encoding, false)
     }
diff --git a/grails-controllers/src/test/groovy/grails/artefact/controller/support/ResponseRendererSpec.groovy b/grails-controllers/src/test/groovy/grails/artefact/controller/support/ResponseRendererSpec.groovy
new file mode 100644
index 0000000000..798c4187e0
--- /dev/null
+++ b/grails-controllers/src/test/groovy/grails/artefact/controller/support/ResponseRendererSpec.groovy
@@ -0,0 +1,156 @@
+/*
+ *  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.artefact.controller.support
+
+import grails.util.GrailsWebMockUtil
+import org.grails.web.servlet.mvc.GrailsWebRequest
+import org.grails.web.servlet.mvc.ParameterCreationListener
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import org.springframework.web.context.WebApplicationContext
+import org.springframework.web.context.request.RequestContextHolder
+import spock.lang.Specification
+
+class ResponseRendererSpec extends Specification {
+
+    void cleanup() {
+        RequestContextHolder.setRequestAttributes(null)
+    }
+
+    void 'rendering an object uses text plain for inspect output'() {
+        given:
+        GrailsWebRequest webRequest = bindWebRequest()
+        def renderer = new TestResponseRenderer()
+
+        when:
+        renderer.render(new InspectableResponseValue('<script>alert(1)</script>'))
+
+        then:
+        webRequest.response.contentType == 'text/plain;charset=utf-8'
+        webRequest.response.contentAsString == '<script>alert(1)</script>'
+    }
+
+    void 'rendering an unrecognized map uses text plain for inspect output'() {
+        given:
+        GrailsWebRequest webRequest = bindWebRequest()
+        def renderer = new TestResponseRenderer()
+
+        when:
+        renderer.render([unsafe: '<script>alert(1)</script>'])
+
+        then:
+        webRequest.response.contentType == 'text/plain;charset=utf-8'
+        webRequest.response.contentAsString == "['unsafe':'<script>alert(1)</script>']"
+    }
+
+    void 'file renders are attachments by default without a file name'() {
+        given:
+        GrailsWebRequest webRequest = bindWebRequest()
+        def renderer = new TestResponseRenderer()
+
+        when:
+        renderer.render(file: '<svg/>'.bytes, contentType: 'image/svg+xml')
+
+        then:
+        webRequest.response.getHeader('Content-Disposition') == 'attachment'
+        webRequest.response.contentAsByteArray == '<svg/>'.bytes
+    }
+
+    void 'file renders use the resolved file name for attachment disposition'() {
+        given:
+        GrailsWebRequest webRequest = bindWebRequest()
+        File file = File.createTempFile('grails-render-', '.txt')
+        file.text = 'download body'
+        def renderer = new TestResponseRenderer()
+
+        when:
+        renderer.render(file: file, contentType: 'text/plain')
+
+        then:
+        webRequest.response.getHeader('Content-Disposition') == "attachment;filename=\"${file.name}\""
+        webRequest.response.contentAsString == 'download body'
+
+        cleanup:
+        file.delete()
+    }
+
+    void 'file renders escape unsafe attachment file names'() {
+        given:
+        GrailsWebRequest webRequest = bindWebRequest()
+        def renderer = new TestResponseRenderer()
+
+        when:
+        renderer.render(file: 'download body'.bytes, contentType: 'text/plain', fileName: 'a"b\\c\r\n.txt')
+
+        then:
+        webRequest.response.getHeader('Content-Disposition') == 'attachment;filename="a\\"b\\\\c__.txt"'
+        webRequest.response.contentAsString == 'download body'
+    }
+
+    void 'file renders may explicitly opt into inline disposition'() {
+        given:
+        GrailsWebRequest webRequest = bindWebRequest()
+        def renderer = new TestResponseRenderer()
+
+        when:
+        renderer.render(file: '<svg/>'.bytes, contentType: 'image/svg+xml', inline: true)
+
+        then:
+        webRequest.response.getHeader('Content-Disposition') == null
+    }
+
+    void 'file renders preserve an existing content disposition header'() {
+        given:
+        GrailsWebRequest webRequest = bindWebRequest()
+        webRequest.response.setHeader('Content-Disposition', 'inline')
+        def renderer = new TestResponseRenderer()
+
+        when:
+        renderer.render(file: '<svg/>'.bytes, contentType: 'image/svg+xml')
+
+        then:
+        webRequest.response.getHeader('Content-Disposition') == 'inline'
+    }
+
+    private GrailsWebRequest bindWebRequest() {
+        WebApplicationContext applicationContext = Mock(WebApplicationContext)
+        applicationContext.getBeansOfType(ParameterCreationListener) >> [:]
+        GrailsWebMockUtil.bindMockWebRequest(
+                applicationContext,
+                new MockHttpServletRequest(),
+                new MockHttpServletResponse())
+    }
+}
+
+class TestResponseRenderer implements ResponseRenderer {
+}
+
+class InspectableResponseValue {
+
+    private final String inspectedValue
+
+    InspectableResponseValue(String inspectedValue) {
+        this.inspectedValue = inspectedValue
+    }
+
+    @Override
+    String toString() {
+        inspectedValue
+    }
+}
diff --git a/grails-doc/src/en/ref/Controllers/render.adoc b/grails-doc/src/en/ref/Controllers/render.adoc
index cc2a234718..d3a101dd17 100644
--- a/grails-doc/src/en/ref/Controllers/render.adoc
+++ b/grails-doc/src/en/ref/Controllers/render.adoc
@@ -83,8 +83,11 @@ render(contentType: "application/json") {
 // render with status code
 render(status: 503, text: "Failed to update book ${b.id}")
 
-// render a file
+// render a file as an attachment
 render(file: new File(absolutePath), fileName: "book.pdf")
+
+// render a file inline
+render(file: new File(absolutePath), inline: true)
 ----
 
 
@@ -92,6 +95,9 @@ render(file: new File(absolutePath), fileName: "book.pdf")
 
 
 A multi-purpose method for rendering responses to the client which is best illustrated with a few examples! Warning - this method does not always support multiple parameters. For example, if you specify both collection and model, the model parameter will be ignored.
+
+Rendering an arbitrary object, or a map without a recognized render argument, writes the Groovy `inspect()` value as `text/plain` by default.
+
 Parameters
 
 Parameters:
@@ -109,5 +115,6 @@ Parameters:
 * `encoding` (optional) - The encoding of the response
 * `plugin` (optional) - The plugin to look for the template in
 * `status` (optional) - The HTTP status code to use
-* `file` (optional) - The byte[], java.io.File, or inputStream you wish to send with the response
+* `file` (optional) - The byte[], java.io.File, or inputStream you wish to send with the response. File responses are rendered with `Content-Disposition: attachment` by default.
 * `fileName` (optional) - For specifying an attachment file name while rendering a file.
+* `inline` (optional) - Set to `true` while rendering a file to omit the default attachment `Content-Disposition` header.
diff --git a/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/RenderMethodTests.groovy b/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/RenderMethodTests.groovy
index bd1bc8478e..d7e27d4a9b 100644
--- a/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/RenderMethodTests.groovy
+++ b/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/RenderMethodTests.groovy
@@ -51,13 +51,26 @@ class RenderMethodTests extends Specification implements ControllerUnitTest<Rend
         e.message == 'Argument [file] of render method specified without valid [contentType] argument'
     }
 
-    void 'renders an input stream without setting content disposition by default'() {
+    void 'renders an input stream as an attachment by default'() {
         when: 'the controller renders a file input stream'
         controller.render(
                 file: new ByteArrayInputStream('hello'.bytes),
                 contentType: 'text/plain'
         )
 
+        then: 'the response contains the stream contents and an attachment header'
+        response.contentAsString == 'hello'
+        response.getHeader(HttpHeaders.CONTENT_DISPOSITION) == 'attachment'
+    }
+
+    void 'renders an input stream inline when requested'() {
+        when: 'the controller renders a file input stream inline'
+        controller.render(
+                file: new ByteArrayInputStream('hello'.bytes),
+                contentType: 'text/plain',
+                inline: true
+        )
+
         then: 'the response contains the stream contents and no attachment header'
         response.contentAsString == 'hello'
         response.getHeader(HttpHeaders.CONTENT_DISPOSITION) == null
@@ -76,6 +89,19 @@ class RenderMethodTests extends Specification implements ControllerUnitTest<Rend
         response.getHeader(HttpHeaders.CONTENT_DISPOSITION) == 'attachment;filename="hello.txt"'
     }
 
+    void 'escapes content disposition filenames while rendering file attachments'() {
+        when: 'the controller renders a file input stream with an unsafe filename'
+        controller.render(
+                file: new ByteArrayInputStream('hello'.bytes),
+                contentType: 'text/plain',
+                fileName: 'a"b\\c\r\n.txt'
+        )
+
+        then: 'the response includes an escaped attachment filename'
+        response.contentAsString == 'hello'
+        response.getHeader(HttpHeaders.CONTENT_DISPOSITION) == 'attachment;filename="a\\"b\\\\c__.txt"'
+    }
+
     void 'renders text with the configured status code'() {
         when: 'the controller renders a message with a status'
         controller.renderMessageWithStatus()