feat: make the hidden HTTP method filter optional - #16182
Conversation
Grails registers HiddenHttpMethodFilter unconditionally, rewriting a POST into a PUT, PATCH or DELETE when the request carries a _method parameter or an X-HTTP-Method-Override header. There is no way to turn it off, and the parameter read happens before the dispatcher runs: because a MultipartConfig is attached to the dispatcher servlet registration, Tomcat resolves it at filter time, so getParameter() on a multipart/form-data POST parses the whole body — temporary files and all — before any routing or authorization decision, and outside GrailsDispatcherServlet's MultipartException handling. Add grails.web.hiddenmethod.filter.enabled, defaulting to true so existing applications are unchanged, plus a marker bean that logs one startup warning when the override is switched off. Browsers submit only GET and POST, so with the override off a form can no longer reach the PUT and DELETE routes a resources: mapping generates. In that mode, and only in that mode, two POST routes are generated: POST /$controller/$id -> update POST /$controller/$id/delete -> delete update reuses the member URL, so a form's action attribute is identical in both modes; delete takes a segment of its own, mirroring the existing /$controller/$id/edit route. No variant is generated for patch, whose RestfulController implementation delegates to update() and resolves to the same target. A singular resource: mapping has no id segment and POST /$controller is already the save route, so there both actions take a segment. includes:/excludes: and nested collection/member blocks propagate. An application that leaves the override enabled generates exactly the mappings it does today. g:form targets those routes and stops emitting the _method field when nothing will read it, so scaffolded views and existing GSP templates need no changes. This required resolving the form's HTTP method before generating the link, which previously happened in the opposite order. A form whose target cannot be resolved to a mapping — a literal url attribute — is left alone and warns. RestfulController and the scaffolding controller templates accept POST for delete so the variant route reaches its action; update already permitted it. Also fixes a startup failure new in 8.0: Boot's WebMvcAutoConfiguration registers its own filter under the same 'hiddenHttpMethodFilter' bean name and keys its @ConditionalOnMissingBean on Spring's filter type, which the Grails FilterRegistrationBean does not satisfy, so spring.mvc.hiddenmethod.filter.enabled=true failed application startup with a BeanDefinitionOverrideException. Grails' registration now backs off when Boot's property is explicitly enabled. In 7.x @EnableWebMvc kept Boot's bean from existing, so the collision arrived with its removal in 8.0. Claude-Session: https://claude.ai/code/session_01Pwd8dRc4WWHEPpbgrmxZmn
|
Alternative implementation opened as #16183: same |
|
Superseded by #16183, which the discussion converged on — closing this in its favour. The short version of why: this PR kept browser forms working by generating two POST routes ( Two findings from this PR carry over and are worth preserving:
The |
Description
Grails registers
org.grails.web.filters.HiddenHttpMethodFilterunconditionally, rewriting aPOSTinto aPUT,PATCHorDELETEwhen the request carries a_methodparameter or anX-HTTP-Method-Overrideheader. There is no way to turn it off.This PR adds
grails.web.hiddenmethod.filter.enabled(defaulttrue, so existing applications are unchanged), and makesresources:mappings andg:formcooperate so that scaffolded views keep working when it is switched off.No approved issue exists, so — background:
Why an off switch is worth having. The filter reads a request parameter before the dispatcher runs.
ControllersAutoConfigurationattaches aMultipartConfigElementto the dispatcher servlet registration, and Tomcat resolves the mapped servlet's multipart config at filter time, sorequest.getParameter("_method")on amultipart/form-dataPOST triggers full container-side multipart parsing — temporary files and all — before any routing or authorization decision has been made, and outsideGrailsDispatcherServlet'sMultipartExceptionhandling. For a JSON API POST the cost is only a query-string parse, but for multipart it is the whole body.There is also a policy dimension: the Grails filter is deliberately wider than Spring's. Spring's
HiddenHttpMethodFilterreads only the_methodparameter and honours onlyPUT,PATCHandDELETE; the Grails one also trusts theX-HTTP-Method-Overrideheader and applies any method name it is given. Applications that do not need browser method override should be able to decline both.Spring Boot has shipped this filter disabled by default since 2.2 and Micronaut has no equivalent at all, so an off switch also keeps the door open to changing the default in a future major.
What changes
grails.web.hiddenmethod.filter.enabled, gating the filter registration inControllersAutoConfiguration, plus a marker bean that logs one startup warning when the override is switched off.POST variant routes. With the override disabled a browser form can no longer reach the
PUTandDELETEroutes aresources:mapping generates, because browsers submit onlyGETandPOST. In that mode — and only that mode — two extra routes are generated:POST/books/$idupdatePOST/books/$id/deletedeleteupdatereuses the member URL, so a form'sactionattribute is byte-identical in both modes.deletetakes a segment of its own, mirroring the existing/books/$id/editroute. No variant is generated forpatch, becauseRestfulController.patch()delegates toupdate()and both resolve to the same target — a second URL would be a synonym. A singularresource:mapping has no id segment andPOST /bookis already thesaveroute, so there both actions take a segment.includes:/excludes:and nestedcollection/memberblocks propagate.An application that leaves the override enabled generates exactly the mappings it does today and pays nothing for the feature.
Prior art for
POST /books/$id→update. This shape looks unusual next to aPUT, but it is the one AngularJS$resourceshipped as its default. Its action set contains noPUTat all —saveisPOST— and the URL template fills the id from the instance:$save()on a fetched object issuesPOST /user/123; on a new object it issuesPOST /user/. The URL alone distinguishes create from update, which is exactly thePOST /booksvsPOST /books/$idsplit above.Two caveats in fairness:
{update: {method: 'PUT'}}was among the most commonly overridden$resourcedefaults, so this establishes the shape as workable and familiar rather than uncontroversial. And it does not extend todelete—$resourcesent a realDELETE, as any XHR client can. The/deletesegment exists only for the one client that cannot: a browser form.g:formtargets the variant routes automatically, and stops emitting the_methodfield when nothing will read it. Scaffolded views and existing GSP templates therefore need no changes —<g:form resource="${book}" method="DELETE">renders/books/1/deletewith the override off and/books/1plus_methodwith it on. This required resolving the form's HTTP method before generating the link, which previously happened in the opposite order. Forms whose target cannot be resolved to a mapping (a literalurl="/some/path") are left alone and log a warning.allowedMethodsgainsPOSTfordeleteinRestfulControllerand in the scaffolding controller templates, so the variant routes reach their actions.updatealready permittedPOST.Also fixed: a startup failure that is new in 8.0
Boot's
WebMvcAutoConfigurationregisters its own hidden-method filter under the samehiddenHttpMethodFilterbean name, and its@ConditionalOnMissingBeankeys onorg.springframework.web.filter.HiddenHttpMethodFilter, which the GrailsFilterRegistrationBeandoes not satisfy. With bean-definition overriding disabled by default, settingspring.mvc.hiddenmethod.filter.enabled=truetherefore failed application startup with aBeanDefinitionOverrideException. Grails' registration now backs off when Boot's property is explicitly enabled.This could not occur in 7.x, where
@EnableWebMvckept Boot's bean from existing at all, so it arrived with the@EnableWebMvcremoval in 8.0.Alternative considered
Resolving
_methodduring URL matching instead — deleting the filter's pre-dispatch parameter read without adding any routes or touchingg:form. It was prototyped and rejected: it changes only which method string reachesmatchAll, whileAllowedMethodsHelper.isAllowed(and interceptors, and controller code) still readrequest.method, which staysPOST. A form submit routed todeletewould then be rejected with a 405 byRestfulController's ownallowedMethods. Making the override visible to the rest of the stack requires wrapping the request, and the natural layer to wrap a request for whole-stack consistency is a servlet filter — which is what already exists.It is also strictly worse for authorization: with POST variants the URL still distinguishes
updatefromdelete, so a method-based security rule can be rewritten path-wise. Resolving_methodat the mapping leavesPOST /books/1meaning both.Behaviour change to be aware of
With the override disabled,
updateanddeletebecome reachable byPOSTas well as byPUTandDELETE. A Spring Security rule or servlet filter matching onlyDELETE /books/**will not coverPOST /books/$id/delete. This is the one consequence that fails silently, and it is called out in the upgrade guide. Applications that leave the override enabled are unaffected.Testing
PostOverrideVariantResourceMappingSpec— route generation, includingincludes:/excludes:, nested resources, singular resources, and that nothing extra is generated in the default modeFormTagLibHiddenMethodDisabledSpec— end-to-end through real URL mappings and real form rendering, coveringresource=/action=,method=, theurl=[resource:…]map shape used by the Spring Security scaffolded views, nested resources, and the literal-URL fallbackControllersAutoConfigurationSpec— registration, the property gate, the startup warning, user-bean back-off, and the Boot collisionRestfulControllerSubclassSpec—updateanddeleteaccepting a formPOSTHiddenHttpMethodFilterTests— extended to cover non-POST requests, empty and custom parameters, parameter-over-header precedence, case folding, and the unrestricted-method behaviour that distinguishes this filter from Spring'sDocumentation
grails-docupgrade guide section 45 and the REST guide's Linking to Resources page.Generative AI tooling (Claude Code) was used in preparing this contribution, in line with the ASF policy on generative tooling. All changes were reviewed and verified against the project's test and style gates by the submitter.
https://claude.ai/code/session_01Pwd8dRc4WWHEPpbgrmxZmn