Skip to content

feat: make the hidden HTTP method filter optional - #16182

Closed
codeconsole wants to merge 1 commit into
apache:8.0.xfrom
codeconsole:feat/optional-hidden-method-filter
Closed

feat: make the hidden HTTP method filter optional#16182
codeconsole wants to merge 1 commit into
apache:8.0.xfrom
codeconsole:feat/optional-hidden-method-filter

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Description

Grails registers org.grails.web.filters.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.

This PR adds grails.web.hiddenmethod.filter.enabled (default true, so existing applications are unchanged), and makes resources: mappings and g:form cooperate 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. ControllersAutoConfiguration attaches a MultipartConfigElement to the dispatcher servlet registration, and Tomcat resolves the mapped servlet's multipart config at filter time, so request.getParameter("_method") on a multipart/form-data POST triggers full container-side multipart parsing — temporary files and all — before any routing or authorization decision has been made, and outside GrailsDispatcherServlet's MultipartException handling. 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 HiddenHttpMethodFilter reads only the _method parameter and honours only PUT, PATCH and DELETE; the Grails one also trusts the X-HTTP-Method-Override header 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 in ControllersAutoConfiguration, 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 PUT and DELETE routes a resources: mapping generates, because browsers submit only GET and POST. In that mode — and only that mode — two extra routes are generated:

Method URL Action
POST /books/$id update
POST /books/$id/delete delete

update reuses the member URL, so a form's action attribute is byte-identical in both modes. delete takes a segment of its own, mirroring the existing /books/$id/edit route. No variant is generated for patch, because RestfulController.patch() delegates to update() and both resolve to the same target — a second URL would be a synonym. A singular resource: mapping has no id segment and POST /book 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 and pays nothing for the feature.

Prior art for POST /books/$idupdate. This shape looks unusual next to a PUT, but it is the one AngularJS $resource shipped as its default. Its action set contains no PUT at all — save is POST — and the URL template fills the id from the instance:

var User = $resource('/user/:userId', {userId: '@id'});

$save() on a fetched object issues POST /user/123; on a new object it issues POST /user/. The URL alone distinguishes create from update, which is exactly the POST /books vs POST /books/$id split above.

Two caveats in fairness: {update: {method: 'PUT'}} was among the most commonly overridden $resource defaults, so this establishes the shape as workable and familiar rather than uncontroversial. And it does not extend to delete$resource sent a real DELETE, as any XHR client can. The /delete segment exists only for the one client that cannot: a browser form.

g:form targets the variant routes automatically, and stops emitting the _method field when nothing will read it. Scaffolded views and existing GSP templates therefore need no changes — <g:form resource="${book}" method="DELETE"> renders /books/1/delete with the override off and /books/1 plus _method with 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 literal url="/some/path") are left alone and log a warning.

allowedMethods gains POST for delete in RestfulController and in the scaffolding controller templates, so the variant routes reach their actions. update already permitted POST.

Also fixed: a startup failure that is new in 8.0

Boot's WebMvcAutoConfiguration registers its own hidden-method filter under the same hiddenHttpMethodFilter bean name, and its @ConditionalOnMissingBean keys on org.springframework.web.filter.HiddenHttpMethodFilter, which the Grails FilterRegistrationBean does not satisfy. With bean-definition overriding disabled by default, setting spring.mvc.hiddenmethod.filter.enabled=true therefore failed application startup with a BeanDefinitionOverrideException. Grails' registration now backs off when Boot's property is explicitly enabled.

This could not occur in 7.x, where @EnableWebMvc kept Boot's bean from existing at all, so it arrived with the @EnableWebMvc removal in 8.0.

Alternative considered

Resolving _method during URL matching instead — deleting the filter's pre-dispatch parameter read without adding any routes or touching g:form. It was prototyped and rejected: it changes only which method string reaches matchAll, while AllowedMethodsHelper.isAllowed (and interceptors, and controller code) still read request.method, which stays POST. A form submit routed to delete would then be rejected with a 405 by RestfulController's own allowedMethods. 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 update from delete, so a method-based security rule can be rewritten path-wise. Resolving _method at the mapping leaves POST /books/1 meaning both.

Behaviour change to be aware of

With the override disabled, update and delete become reachable by POST as well as by PUT and DELETE. A Spring Security rule or servlet filter matching only DELETE /books/** will not cover POST /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, including includes:/excludes:, nested resources, singular resources, and that nothing extra is generated in the default mode
  • FormTagLibHiddenMethodDisabledSpec — end-to-end through real URL mappings and real form rendering, covering resource=/action=, method=, the url=[resource:…] map shape used by the Spring Security scaffolded views, nested resources, and the literal-URL fallback
  • ControllersAutoConfigurationSpec — registration, the property gate, the startup warning, user-bean back-off, and the Boot collision
  • RestfulControllerSubclassSpecupdate and delete accepting a form POST
  • HiddenHttpMethodFilterTests — 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's

Documentation

grails-doc upgrade 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

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
@codeconsole

Copy link
Copy Markdown
Contributor Author

Alternative implementation opened as #16183: same grails.web.hiddenmethod.filter.enabled property, but with the filter off it relocates the _method override into the dispatcher rather than generating POST variant routes. The two are mutually exclusive — #16183 carries a comparison table. Whichever direction reviewers prefer, the other should be closed.

@codeconsole

Copy link
Copy Markdown
Contributor Author

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 (POST /books/$id → update and POST /books/$id/delete → delete) so that no server-side method override was needed at all. #16183 keeps the first of those routes but reaches delete through _method resolved inside the dispatcher, which avoids adding a second URL to every resource.

Two findings from this PR carry over and are worth preserving:

The FormTagLib restructure here — reordering method resolution so the form action could be rewritten to /books/$id/delete — is not needed by the converged design, which never changes the form's action URL. That was the riskiest change in either branch, so avoiding it is a real gain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant