Modernize Grails 8 content negotiation: framework MIME defaults and honor the Accept header#15758
Conversation
3318c9f to
2b7ad52
Compare
MIME types are now framework defaults and the Accept header is honored for all clients by default. MIME types: - MimeType.createDefaults() now provides the full set of types new apps historically declared in application.yml (all, atom, css, csv, form, html, js, json, multipartForm, pdf, rss, text, hal, xml), used when no grails.mime.types is configured. - A declared grails.mime.types still replaces the defaults, exactly as before, so existing applications are unaffected. Set the new grails.mime.mergeDefaults=true to instead merge declared types over the defaults (adding any extension not declared). - Grails Forge (GSP/JSON/Markup features) and the web and rest-api profiles no longer generate a grails.mime.types block. Accept header: - grails.mime.disable.accept.header.userAgents now defaults to unset, so the Accept header is honored for every client including browsers. The legacy browser blocklist (Gecko/WebKit/Presto/Trident) is now opt-in, fixing browser fetch()/axios requests for application/json that were receiving HTML. - Removed the dead Internet Explorer user-agent branch. Docs and tests updated; upgrade notes added (sections 27 and 28).
2b7ad52 to
0df0959
Compare
✅ All tests passed ✅🏷️ Commit: 0df0959 Learn more about TestLens at testlens.app. |
sbglasius
left a comment
There was a problem hiding this comment.
cannot see any flaws in this implementation. And simplifying it for the users is always good.
|
The documentation changes in this pull request update the content negotiation guide to reflect that Grails 8 now provides built-in default mime types. The guide clarifies that developers no longer need to declare these defaults in |
jdaugherty
left a comment
There was a problem hiding this comment.
I don't see the test applications being changed to remove this config by default. Also, we need to add or change one of the test applications so that the merge defaults behavior is tested / shown working in an end to end app.
Remove the now-redundant grails.mime.types block from 44 test applications that declared the standard web-profile default set (the exact set the framework now supplies out of the box). For app3/4/5 and geb-context-path only the types map is removed; their explicit disable.accept.header.userAgents opt-in is preserved. Apps that declare a JSON-first ordering (which makes JSON their default respond format), assert grails.mime.types.* config values, or declare a deliberately narrowed set are left unchanged, because removing the block there would alter behavior rather than fall back to the identical defaults. Demonstrate grails.mime.mergeDefaults end-to-end in app1: it now declares only a custom 'vnd' type with merging enabled, and ContentNegotiationSpec verifies over HTTP that the custom type negotiates while the undeclared framework defaults (json/xml/html) remain available because they are merged in.
|
Thanks @jdaugherty — both points addressed. 1. End-to-end
grails:
mime:
mergeDefaults: true
types:
vnd: application/vnd.app1.v1+jsonA new
Crucially, app1's entire existing 2. Removed the redundant default block from the test apps Removed the now-redundant A few apps were intentionally left as-is because removing the block there is not a no-op:
Verified locally by running the affected integration suites (app1 incl. the new merge tests, |
…ormat The mergeDefaults demo declared only the custom 'vnd' type, which made 'vnd' the first configured MIME type. For a request without an Accept header the response format resolves from the first configured type, so this shifted app1's default from html to vnd and broke BookControllerSpec (a respond-based controller unit test) and the SiteMesh 2 functional run. Declare 'all' first alongside 'vnd' so 'all' remains the default negotiated format, matching app1's previous behavior. The demo is unchanged in intent: json/xml/html and the other defaults are still undeclared and supplied via mergeDefaults, which the ContentNegotiationSpec verifies.
Summary
This modernizes Grails' content-negotiation defaults for Grails 8, in two related parts:
grails.mime.typesblock inapplication.yml; the framework supplies the full set out of the box. A declaredgrails.mime.typesstill replaces the defaults (unchanged from Grails 7), with an opt-in flag to merge instead.Acceptheader is honored for all clients by default. The legacy "ignore theAcceptheader for browser user agents" behavior is now opt-in. This fixes browserfetch()/axioscalls that requestapplication/jsonbut receive HTML.Both changes are backward compatible for typical apps and are covered by upgrade notes.
Part 1 — MIME types as framework defaults
Previously, every generated app carried a ~15-line
grails.mime.typesblock, while the framework's built-in fallback (MimeType.createDefaults()) only knew 6 types. Removing the block silently shrank content negotiation to those 6.This change:
MimeType.createDefaults()to the full set new apps historically declared:all, atom, css, csv, form, html, js, json, multipartForm, pdf, rss, text, hal, xml. This is what's used when nograils.mime.typesis configured.grails.mime.typesstill replaces the defaults (the map you declare is authoritative, and the first entry is the default format). Existing apps — including those that deliberately narrowed the set — are unaffected, and restricting the negotiable types still works exactly as before.grails.mime.mergeDefaults(defaultfalse): whentrue, a declaredgrails.mime.typesis merged over the defaults instead — an entry overrides the default for its extension, extensions you don't declare keep their defaults, and declared entries are ordered first. This gives the "add one custom type without re-declaring the full list" convenience for those who want it.webandrest-apiprofiles.Result: a new app's
application.ymlis cleaner, existing configs behave exactly as before, and merging is available on request.The following default is no longer required to be added to every Grails
application.yml:Part 2 — Honor the
Acceptheader by default (stop User-Agent sniffing)What changed
grails.mime.disable.accept.header.userAgentspreviously defaulted to['Gecko', 'WebKit', 'Presto', 'Trident'], which caused Grails to ignore theAcceptheader for any request whoseUser-Agentmatched a major browser engine. It now defaults to unset, so theAcceptheader is honored for every client. The browser blocklist remains available as an explicit opt-in.Legacy circa 2007 broken default configuration:
Why the old default is wrong today
It works around a problem that no longer exists. The blocklist dates to the Firefox 2/3 era, when browsers sent:
— ranking XML above HTML. Modern browsers send a well-formed header that ranks HTML highest:
text/htmlis q=1.0 andapplication/xmlis q=0.9, so ordinary q-value negotiation already resolves browser navigations to HTML. No sniffing required.It actively breaks modern AJAX. The old code exempted "XHR" requests — but only those carrying
X-Requested-With: XMLHttpRequest, a header jQuery adds automatically andfetch()/axiosdo not. So in a current SPA:matched the browser
User-Agent, theAcceptheader was discarded before it was even read, and the response came back as HTML — even though the client explicitly asked for JSON. That is the concrete bug this fixes.How Spring Boot / Spring MVC handles it
Spring takes the simpler, opposite approach: it trusts the client's explicit signals and never inspects
User-Agent.ContentNegotiationManagerresolves the representation from theAcceptheader (and optionally an explicitformatparameter). There is no User-Agent strategy.@RestControllermethod withAccept: application/jsonselects itsHttpMessageConverterpurely from theAcceptheader → JSON, regardless of which browser made the request.favorPathExtension = false) in Spring Framework 5.3+ specifically because deriving the response type from an implicit signal (the URL suffix) enabled Reflected File Download (RFD) attacks.Grails 8 now aligns with Spring: honor the explicit
Acceptheader; don't branch on an ambient, spoofable one.Security / robustness considerations of User-Agent-based negotiation
Beyond correctness, negotiating on
User-Agenthas real downsides:User-Agent, a correct implementation must emitVary: User-Agent. Grails' UA-based negotiation does not, so a shared / CDN cache keyed on the URL can store one representation (say HTML) and serve it to a client that needs another (a JSON API consumer), or the reverse. HonoringAcceptremoves this class of cache mismatch from the default configuration.User-Agentis entirely client-controlled. Gating the response type on it makes server behavior depend on a value the client can set arbitrarily — fragile to reason about and harder to cover with security tooling / WAF rules that expect a stable request → representation contract.application/jsoncan cause client code to mis-handle the body (parsing an HTML error page as JSON, or rendering untrusted HTML it never expected).None of these is a critical vulnerability on its own, but together they are concrete reasons the explicit-
Acceptapproach is the safer default.Dead-code cleanup
While here, removed a sibling dead path —
if (msie) header = '*/*'— whose guard (userAgent ==~ /msie(?i)/) never matches a real Internet ExplorerUser-Agentstring and so never executed.Compatibility
grails.mime.typesare fully unaffected — the declared map replaces the defaults exactly as before, so you get precisely the types you declared (the first entry remains the default format). Opt into merging withgrails.mime.mergeDefaults: trueif you'd rather add to the defaults.text/htmlhighest —withFormat { html { … } json { … } }keeps serving HTML to browsers and JSON to clients that ask for it.grails.mime.disable.accept.header.userAgents: [Gecko, WebKit, Presto, Trident].Testing
grails-mimetypes,grails-web-common, andgrails-test-suite-web; Grails Forge view specs pass.grails.mime.mergeDefaultsmerge/override behavior, theAcceptheader being honored for non-XHR browser requests, and the opt-back-in config restoring the legacy User-Agent behavior.