Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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')

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.

If the content type is unknown, why are we rendering something else?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I looked at narrowing the PR to only the filename/disposition fixes and leaving inspect-style render output on the existing default, but review-gate flagged that as reintroducing the exact browser-interpreted inspect-output risk this branch is trying to avoid. The current implementation still renders the same inspect string; it only changes the default response media type for the inspect fallback from browser-interpreted HTML to text/plain unless the caller explicitly sets a content type. I am leaving this thread unresolved for maintainer discussion rather than treating it as resolved.

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.

Let's discuss in the weekly?


try {
response.writer.write(object.inspect())
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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)

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.

I'm not sure we should use a default content type. Especially plain text when encoders could cause non-text values to render.

String encoding = DEFAULT_ENCODING
if (argMap != null) {
if (argMap.containsKey(ARGUMENT_CONTENT_TYPE)) {
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
40 changes: 30 additions & 10 deletions grails-doc/src/en/guide/upgrading/upgrading80x.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1350,7 +1350,27 @@ grails:
- Trident
----

==== 31. @EnableWebMvc No Longer Added to Grails Applications
==== 31. Render Defaults Harden Unsafe Content

Grails 8 hardens controller `render(...)` defaults for responses that could otherwise be interpreted as browser-rendered HTML.
Rendering an arbitrary object with `render(object)`, or rendering a map that does not contain a recognized render argument, now writes the Groovy `inspect()` value as `text/plain` by default instead of `text/html`.

File renders now set `Content-Disposition: attachment` by default.
If a `fileName` is supplied, unsafe filename characters are escaped before the value is written to the `Content-Disposition` header.

Set an explicit `contentType` when a response should use another media type, and set `inline: true` when a file response should not receive the default attachment header.
The hardened defaults only apply when no content type has been set, so assigning `response.contentType` before calling `render(object)` also overrides them.
If you need a custom disposition, set the `Content-Disposition` response header explicitly before rendering the file; Grails preserves an existing header.

For example:

[source,groovy]
----
render(text: myObject.inspect(), contentType: 'text/html')
render(file: new File(absolutePath), inline: true)
----

==== 32. @EnableWebMvc No Longer Added to Grails Applications

In earlier versions, Grails added Spring's `@EnableWebMvc` annotation to the generated `Application` class at compile time.
`@EnableWebMvc` is a Spring Framework configuration annotation that is not intended to be combined with Spring Boot: it imports `DelegatingWebMvcConfiguration`, which eagerly registers a fixed set of MVC beans and switches off Spring Boot's `WebMvcAutoConfiguration`.
Expand All @@ -1363,7 +1383,7 @@ Spring Boot's `WebMvcAutoConfiguration` is now active for Grails servlet web app
Grails adapts internally so the change is transparent.
The notes below explain the behavioral differences this introduces and the configuration toggles available if you need to control them.

===== 31.1 Boot's default view resolver is removed automatically
===== 32.1 Boot's default view resolver is removed automatically

With `WebMvcAutoConfiguration` active, Boot contributes a catch-all `defaultViewResolver` (an `InternalResourceViewResolver`).
For a Grails application that does not use JSP/`InternalResource` views (for example a REST/JSON-views application) this resolver maps any unmatched view name to a servlet forward, which can surface as:
Expand All @@ -1387,7 +1407,7 @@ grails:
NOTE: In GSP applications the GSP view resolver continues to be installed as the primary `viewResolver`, controlled as before by `spring.gsp.replaceViewResolverBean`.
The earlier `spring.gsp.removeDefaultViewResolverBean` property (from when this removal lived in the GSP module) is still honoured for backward compatibility but is **deprecated** — `defaultViewResolver` removal is now handled centrally for all Grails servlet web applications by the `grails.web.removeDefaultViewResolverBean` property above, which takes precedence. Setting the old name logs a deprecation warning at startup.

===== 31.2 GrailsWebRequest binding
===== 32.2 GrailsWebRequest binding

With `WebMvcAutoConfiguration` active, Boot would otherwise register its own `RequestContextFilter` that rebinds a plain `ServletRequestAttributes`, replacing the `GrailsWebRequest` bound earlier in the filter chain.
Downstream code that expects a `GrailsWebRequest` would then fail with a `ClassCastException`.
Expand All @@ -1396,22 +1416,22 @@ Grails 8 registers its request-binding filter as a `RequestContextFilter` bean s
This is handled internally and requires no configuration changes.
Applications that defined their own `GrailsWebRequestFilter` bean, or their own `grailsWebRequestFilter` filter-registration bean, continue to override the Grails-provided ones.

===== 31.3 Boot's static resources and welcome page are removed
===== 32.3 Boot's static resources and welcome page are removed

With `WebMvcAutoConfiguration` active, Boot would register a catch-all static-resource handler (`classpath:/META-INF/resources/`, `/resources/`, `/static/`, `/public/`) and a `WelcomePageHandlerMapping` that serves a static `index.html` for the root path.
In a Grails application these can shadow URL mappings a request that should fall through to Grails' URL-mapping error handling could instead be served as a static resource, and `/` could return a static `index.html` rather than being handled by your `UrlMappings`.
In a Grails application these can shadow URL mappings - a request that should fall through to Grails' URL-mapping error handling could instead be served as a static resource, and `/` could return a static `index.html` rather than being handled by your `UrlMappings`.

So that Grails' own URL mappings and resource handling stay in control, Grails 8 disables both by default:

* `spring.web.resources.add-mappings` defaults to `false`, which disables Boot's catch-all static-resource handler. Re-enable it with `spring.web.resources.add-mappings: true`.
* Boot's welcome-page mapping is removed so `UrlMappings` owns `/`. Opt out with `grails.web.removeWelcomePageMapping: false`.

===== 31.4 Other Boot MVC features now active
===== 32.4 Other Boot MVC features now active

Because Boot's `WebMvcAutoConfiguration` is now active, a handful of its features that `@EnableWebMvc` previously suppressed take effect for Grails servlet web applications.
None require action for a typical application, but they are behavioral differences worth knowing about when you upgrade:

* **Form-content filter for `PUT` / `PATCH` / `DELETE`.** A `FormContentFilter` parses `application/x-www-form-urlencoded` bodies of `PUT`, `PATCH` and `DELETE` requests so their fields are visible through the standard `request.getParameter(...)` API (and therefore in `params`). Boot's `OrderedFormContentFilter` provides it for a default application; for an application that declares `@EnableWebMvc` where Boot's MVC auto-configuration backs off Grails contributes an equivalent filter itself, so form parameters behave the same either way. In Grails 7 only `PUT` and `PATCH` bodies were parsed (by `GrailsParameterMap`) and `DELETE` was not; all three are now handled uniformly by the filter. It is enabled by default via `spring.mvc.formcontent.filter.enabled`; setting that to `false` disables `PUT` / `PATCH` / `DELETE` form-parameter parsing entirely. An application that reads those bodies itself can disable it:
* **Form-content filter for `PUT` / `PATCH` / `DELETE`.** A `FormContentFilter` parses `application/x-www-form-urlencoded` bodies of `PUT`, `PATCH` and `DELETE` requests so their fields are visible through the standard `request.getParameter(...)` API (and therefore in `params`). Boot's `OrderedFormContentFilter` provides it for a default application; for an application that declares `@EnableWebMvc` - where Boot's MVC auto-configuration backs off - Grails contributes an equivalent filter itself, so form parameters behave the same either way. In Grails 7 only `PUT` and `PATCH` bodies were parsed (by `GrailsParameterMap`) and `DELETE` was not; all three are now handled uniformly by the filter. It is enabled by default via `spring.mvc.formcontent.filter.enabled`; setting that to `false` disables `PUT` / `PATCH` / `DELETE` form-parameter parsing entirely. An application that reads those bodies itself can disable it:
+
[source,yaml]
.application.yml
Expand All @@ -1429,12 +1449,12 @@ spring:

* **Conversion and message converters.** `mvcConversionService` is now Boot's `ApplicationConversionService` (which honours the `spring.mvc.format.*` properties above), and the MVC HTTP message converters pick up Boot's customizations, including the application's Jackson `ObjectMapper`. This mainly affects MVC-layer data binding and `@RestController`-style endpoints.

===== 31.5 Locale resolution
===== 32.5 Locale resolution

Grails' locale resolution including `?lang=` switching via `SessionLocaleResolver` continues to work as before, and the resolver strategy is now configurable through `grails.i18n.localeResolver` (`session` (default), `cookie`, `acceptHeader` or `fixed`).
Grails' locale resolution - including `?lang=` switching via `SessionLocaleResolver` - continues to work as before, and the resolver strategy is now configurable through `grails.i18n.localeResolver` (`session` (default), `cookie`, `acceptHeader` or `fixed`).
Applications that opt into `@EnableWebMvc` are unaffected too: Grails removes the `AcceptHeaderLocaleResolver` that Spring's `WebMvcConfigurationSupport` would otherwise contribute, so the configured Grails resolver still wins.

==== 32. More Framework Beans Are Cleanly Overridable
==== 33. More Framework Beans Are Cleanly Overridable

Several Grails framework beans that were previously registered unconditionally are now guarded with `@ConditionalOnMissingBean`, so a bean of the same name (or type) defined in an application configuration class (a `@Bean` method) takes precedence cleanly instead of relying on bean-definition overriding:

Expand Down
11 changes: 9 additions & 2 deletions grails-doc/src/en/ref/Controllers/render.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,21 @@ 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)
----


=== Description


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:
Expand All @@ -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.
Loading
Loading