feat: disable the hidden HTTP method filter by default - #16183
feat: disable the hidden HTTP method filter by default#16183codeconsole wants to merge 9 commits into
Conversation
…he filter is off 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. When it is false the override is not lost: it moves into the dispatcher, which resolves it after multipart handling — so a multipart body is parsed once, by the dispatcher — and after the filter chain, which therefore sees the request's real POST method. GrailsDispatcherServlet resolves the override in checkMultipart and publishes the wrapped request through GrailsWebRequest, so everything reading the request through the Grails API agrees on the method: a controller's 'request', allowedMethods, interceptors, and URL mapping resolution. Without that the generated allowedMethods check would still read POST and a form submit routed to 'delete' would be rejected with a 405. UrlMappingsHandlerMapping resolves the same override independently, keeping mapping resolution correct on its own terms. Both delegate to org.grails.web.util.HiddenHttpMethod so the rules cannot drift. The mapping-time resolution is deliberately narrower than the filter it stands in for: it reads only the _method parameter, not the X-HTTP-Method-Override header any client can set, and accepts only PUT, PATCH and DELETE — the three methods a browser form cannot submit itself — matching the set Spring's own HiddenHttpMethodFilter permits. The filter applies any method name it is given, including GET. Forms, scaffolded views and GSP templates are unchanged in either mode. 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
19f55a3 to
d17f416
Compare
The previous commit made the filter optional and moved the override into the dispatcher when it was switched off. This makes that the default, because the reasons to switch it off apply to every application rather than a few. Reading a request parameter before the dispatcher runs is not free: a MultipartConfigElement is attached to the dispatcher servlet registration, so the container resolves it at filter time and reading _method on a multipart/form-data POST parses the entire body, writing its temporary files, before the request has been routed or authenticated. The filter is order -170 and the Spring Security chain is -100, so an unauthenticated request can cause uploads to be written to disk. Spring Boot disabled its equivalent filter in 2.2 for the same reason — it "causes early consumption of a request body if the body may contain parameters". For browser forms to keep working without the filter, two things change. A 'resources' mapping now also generates POST /$controller/$id -> update. RestfulController has declared update: ['PUT', 'POST'] since apache#9926 — raised because AngularJS $resource, and the clients modelled on it, POST to the member URL to save an existing object — but no route was ever generated, so that permission has been unreachable through a resources block. Generating it is also what lets a form submit reach update with no parameter at all. <g:form method="PUT"> therefore stops emitting _method and submits a plain POST to the same URL; the action attribute is unchanged, so no template needs editing. method="DELETE" still emits it, because delete and update share a URL and the parameter is what distinguishes them. method="PATCH" behaves as PUT, since RestfulController.patch() delegates to update(). In the new default _method appears in exactly one place: a delete form. Setting grails.web.hiddenmethod.filter.enabled back to true restores the Grails 7 behaviour exactly, including the X-HTTP-Method-Override header and the unrestricted method names, and suppresses the POST member route so a resources block generates the mappings it did before. The behavioural change to review when upgrading: servlet filters and the Spring Security chain now see a form delete as a bare POST to the member URL, so a rule matching DELETE /books/** no longer fires for it, and the URL does not distinguish update from delete. This is the one consequence that fails silently and it leads the upgrade note. Tests and the URL mappings report are updated for the added route, and FormTagLibResourceTests gains the delete coverage it never had — now the only place _method is expected. Claude-Session: https://claude.ai/code/session_01Pwd8dRc4WWHEPpbgrmxZmn
The five isolated Test tasks in grails-test-suite-uber were registered without testClassesDirs or classpath. A manually registered Test task inherits neither from the test source set, so each task resolved no candidate classes and reported NO-SOURCE. Because the main test task excludes exactly these patterns, the isolated classes ran nowhere: isolatedTestsOne, isolatedTestsTwo, isolatedRestRendererTests, isolatedPersonTests, isolatedRestfulControllerTests Wiring both properties to the test source set brings 40 tests back into the build. 36 pass. The remaining four assert the validation-error branch of a controller action — that an invalid domain instance re-renders the create or edit view — and are marked @PendingFeature because domain validation is not enforced in this unit-test harness: constraints are registered, and constrainedProperties reports them, but validate() returns true even for a null value on a property whose nullable constraint defaults to false. The invalid instance is therefore valid, the action takes its success path, and the assertions never see the error view. That is a pre-existing defect, unrelated to this change and merely revealed by it; the annotation fails the build if the behaviour is fixed and the tests start passing, so it cannot outlive the bug. Claude-Session: https://claude.ai/code/session_01Pwd8dRc4WWHEPpbgrmxZmn
…handler-mapping # Conflicts: # grails-doc/src/en/guide/upgrading/upgrading80x.adoc
7cdb385 to
75fb4ed
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16183 +/- ##
==================================================
+ Coverage 54.1488% 54.7310% +0.5822%
- Complexity 20258 20452 +194
==================================================
Files 2098 2103 +5
Lines 100920 101025 +105
Branches 17898 17920 +22
==================================================
+ Hits 54647 55292 +645
+ Misses 38484 37864 -620
- Partials 7789 7869 +80
🚀 New features to boost your workflow:
|
…handler-mapping # Conflicts: # grails-doc/src/en/guide/upgrading/upgrading80x.adoc
|
We removed the dispatch actions in Grails 8 already (deprecated in Grails 7). Is _method just left over from that? I had no idea you could even change the method for a form - why wouldn't you either set the form to the intended method or just set the formmethod? It seems like _method is from an earlier implementation when there weren't built in constructs to change the method in html5. |
No, it is for <g:form method="PUT" action="/books">creates <form method="POST" action="/books">
<input type="hidden" name="_method" value="PUT">then HiddenMethodFilter wraps the entire request so the entire filter stack (including Spring Security thinks the request is This PR keeps the actual request and uses I a follow up PR would also be to not use If you only use POST and GET in your forms, this PR will only help you not forcing multipart resolution at the beginning of the filter stack |
…handler-mapping # Conflicts: # grails-doc/src/en/guide/upgrading/upgrading80x.adoc
|
The way I understand it, this is the current PR behavior: // Pseudo-code
if (grailsFilterEnabled) {
if (springFilterEnabled) {
use Spring HiddenHttpMethodFilter
}
else {
use Grails HiddenHttpMethodFilter
}
disable Grails dispatcher-level override
}
else {
if (springFilterEnabled) {
use Spring HiddenHttpMethodFilter
ALSO enable Grails dispatcher-level override
}
else {
no servlet HiddenHttpMethodFilter
enable Grails dispatcher-level override
}
}Wouldn't this be more appropriate? // Pseudo-code
if (springFilterEnabled) {
use Spring HiddenHttpMethodFilter
Grails compatibility/filter mode = true
}
else if (grailsFilterEnabled) {
use Grails HiddenHttpMethodFilter
Grails compatibility/filter mode = true
}
else {
no servlet HiddenHttpMethodFilter
use Grails dispatcher-level override
} |
Corrects the commit before this one, which passed `disable-releaser` to an action that has no such input. The workflow's own run said so - "Unexpected input(s) 'disable-releaser'" - and then tried to create a release and failed. The releaser therefore still ran on pull requests, so the concurrency isolation added there did not prevent the concurrent writes it claimed to. The pull request trigger was there so the autolabeler could label pull requests, and the action pinned here cannot do that. release-drafter 7 split labelling into a second entrypoint, release-drafter/release-drafter/autolabeler, and the entrypoint used here has no labelling in it at all - its source mentions labels zero times, against twenty three in the autolabeler's. So a pull request run applied no labels. On a pull request from a fork it could not write a draft either: the token is read only, and the run failed with "Resource not accessible by integration", hidden by continue-on-error. Runs that could only fail also cancelled each other, because every pull request against a release branch computed the same concurrency group - which is how apache#16183 lost its check twelve seconds in, to an unrelated pull request that pushed after it. Nothing is lost by removing the trigger and the collision goes with it. Drafts are still written on every push to a release branch, which is when a merge can change them, and the concurrency group is a branch again because a branch is the only subject left. Restoring labels needs the autolabeler entrypoint, which is a separate action and not on the ASF approved list - approved_patterns.yml carries only release-drafter/release-drafter@* - so it wants an INFRA request first, and its own change.
…handler-mapping # Conflicts: # grails-test-suite-uber/build.gradle
This comment has been minimized.
This comment has been minimized.
…Boot's gap Only the Grails property decided whether the override was resolved in the dispatcher, whether a 'resources' mapping generated the POST member route, and whether g:form emitted _method. Spring Boot's spring.mvc.hiddenmethod.filter property was read in exactly one place: the condition on the Grails filter bean, which exists to stop the two colliding on a shared bean name. So an application that enabled Boot's filter got Spring's filter and, at the same time, the routing and rendering of an application with no filter at all: g:form stopped emitting _method for PUT and the POST member route appeared, so a form update reached the security chain as a POST. That is the silent authorization change the upgrade note warns about, landing on the application that opted in to avoid it. Derive the mode from both properties through HiddenHttpMethod.isServletFilterMode, used by the dispatcher, the handler mapping, the URL mapping evaluator and the form tag alike, so the four cannot drift. That is only sound if a filter really is present whenever either property is set, and it was not. Boot's filter is contributed by WebMvcAutoConfiguration, which backs off entirely for an application declaring @EnableWebMvc; asking for Boot's filter there produced no filter at all, while the Grails one had already backed off on the property. Move the registration into its own auto-configuration ordered after WebMvcAutoConfiguration, so Boot's filter is visible to @ConditionalOnMissingBean rather than assumed from a property, and contribute the Grails filter whenever Boot has not. Grails already solves the same problem this way for the form-content filter. Reported by @matrei in review of apache#16183. Claude-Session: https://claude.ai/code/session_01Pwd8dRc4WWHEPpbgrmxZmn
…g' into feat/hiddenmethod-handler-mapping # Conflicts: # grails-doc/src/en/guide/upgrading/upgrading80x.adoc # grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy
|
Accurate description of the behaviour, and fixed in 482fa1c — your structure, with one addition. The mode now comes from That is only sound if a filter really exists whenever either property is set, and it did not: Boot's filter comes from Worth noting the case you found was worse than redundant: with Covered by |
|
Consolidated into #16149, which now covers the request path as one reviewable change — per-request work, hidden HTTP method handling and multipart together. This branch was merged in with its history intact; no commits were squashed or dropped, and Closing in favour of that. The reason is review flow rather than anything wrong here: these branches collided in |
Summary
Grails currently rewrites hidden HTTP methods in a servlet filter that runs before the dispatcher and Spring Security. Reading
_methodthere can force a multipart request body to be parsed—and temporary upload files written—before routing or authentication.This PR disables the Grails hidden-method filter by default and resolves form method overrides inside the dispatcher instead. Existing browser forms continue to work without template changes.
What changes
GrailsDispatcherServletresolves_methodafter multipart handling and the filter chain.PUT,PATCH, andDELETE;X-HTTP-Method-Overrideis no longer used in the new default mode.UrlMappingsHandlerMappingapplies the same resolution when matching routes.resourcesmappings addPOST /$controller/$id -> update, matching thePOSTmethod already accepted byRestfulController.update.<g:form method="PUT">andPATCHsubmit to that POST update route without_method.<g:form method="DELETE">still emits_method, because update and delete share the same URL.hiddenHttpMethodFilterbean-name collision introduced in Grails 8.Compatibility and authorization
Servlet filters—including Spring Security—now see the real request method. For example, a browser delete form is seen by the filter chain as
POST /books/1; it becomesDELETEonly inside Grails dispatching.Applications with method-specific security rules such as
DELETE /books/**must review those rules before upgrading. Otherwise, the DELETE-specific rule will not match the form's POST request. This behavior and migration concern are documented in the Grails 8 upgrade guide.The legacy filter behavior can be restored with:
When enabled, the dispatcher-level override and additional POST update mapping are disabled.
Why change the default?
The existing filter reads request parameters before authentication. For
multipart/form-data, that can trigger early parsing of the entire upload. Spring Boot disabled its equivalent filter by default for the same early-consumption concern.Grails 8 is the appropriate compatibility boundary for changing this default, while the configuration property provides an opt-out for applications that need time to migrate security rules.