Skip to content

feat: disable the hidden HTTP method filter by default - #16183

Open
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:feat/hiddenmethod-handler-mapping
Open

feat: disable the hidden HTTP method filter by default#16183
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:feat/hiddenmethod-handler-mapping

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Description

Grails registers a servlet filter that rewrites a POST into a PUT, PATCH or DELETE when the request carries a _method parameter or an X-HTTP-Method-Override header. It runs ahead of the dispatcher — and ahead of the Spring Security filter chain — and there is no way to turn it off.

This PR disables it by default and resolves the override inside the dispatcher instead. Browser forms keep working with no template changes.

No approved issue exists, so — background:

Why the default should change

The filter reads a request parameter before the dispatcher runs. ControllersAutoConfiguration attaches a MultipartConfigElement to the dispatcher servlet registration, and the container resolves it at filter time, so reading _method on a multipart/form-data POST forces the container to parse the entire body — writing its temporary files — before the request has been routed or authenticated. GrailsFilters.HIDDEN_HTTP_METHOD_FILTER is order -170; the Spring Security chain is -100. An unauthenticated request can cause uploads to be written to disk.

Spring Boot disabled its equivalent in 2.2 for the same reason — it "causes early consumption of a request body if the body may contain parameters" — and reported the filter taking up to 80% of processing time on some endpoints.

The Grails filter is also wider than Spring's:

Spring's filter Grails' filter This PR (in-dispatcher)
_method parameter yes yes yes
X-HTTP-Method-Override header no yes no
Methods accepted PUT, PATCH, DELETE any, including GET PUT, PATCH, DELETE

What changes

GrailsDispatcherServlet resolves the override in checkMultipart — after multipart resolution and after the filter chain — and publishes the wrapped request through GrailsWebRequest. That publication is what makes it work end to end: a controller's request, allowedMethods, interceptors and URL mapping resolution all read through the Grails API and therefore agree on the method. UrlMappingsHandlerMapping resolves the same override independently so mapping resolution is correct on its own terms; both delegate to a new org.grails.web.util.HiddenHttpMethod. Requests with no override are returned untouched, leaving multipart handling exactly as it was.

POST /$controller/$idupdate is now generated by a resources mapping. RestfulController has declared update: ['PUT', 'POST'] since #9926 — raised because AngularJS $resource, and the client libraries 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 for nine years. This is also what lets a form submit without a _method parameter at all.

<g:form method="PUT"> stops emitting _method. It submits a plain POST to the same URL, which the route above answers — the action attribute is unchanged. 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().

So in the new default, _method appears in exactly one place: a delete form.

Restoring the old behaviour is one property, which also suppresses the POST member route so a resources block generates exactly the mappings it does today:

grails:
    web:
        hiddenmethod:
            filter:
                enabled: true

Also fixed: a startup failure that is new in 8.0

Boot's WebMvcAutoConfiguration registers its own 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, spring.mvc.hiddenmethod.filter.enabled=true failed application startup with a BeanDefinitionOverrideException. Grails' registration now backs off. This could not occur in 7.x, where @EnableWebMvc kept Boot's bean from existing.

The one thing that fails silently in the unlikely that you would have such a specific url mapping rule

Spring Security now receives the actual request urls instead of the fake ones created by HttpHiddenMethodFilter

Servlet filters and the Spring Security chain now see a form delete as a bare POST /books/1, because the rewrite happens inside the dispatcher. A security rule matching DELETE /books/** will no longer fire, and since the URL is unchanged, update and delete cannot be distinguished by path either. This is called out at the top of the upgrade-guide section, and it is the strongest argument for reviewers who may prefer to ship the property in 8.0 and defer the default flip to 9.0 — that is a one-line change to this PR if the team prefers it.

A note on scope

This flips a default for every application on upgrade. 8.0 is the release where that is permitted, and the alternative — shipping the property defaulted to true — leaves every app paying pre-authentication multipart parsing that Spring Boot decided against six years ago. I have written it as the flip, but the decision is the team's and the property makes either choice cheap.

Testing

  • HiddenHttpMethodSpec — resolution rules, including that the header is not read and GET/TRACE/unknown names are refused
  • GrailsDispatcherServletHiddenMethodSpec — the dispatched request and GrailsWebRequest.currentRequest both report the overridden method (what allowedMethods reads), and a request without an override is returned untouched
  • HiddenHttpMethodHandlerMappingSpec — form POSTs reaching PUT/PATCH/DELETE routes, the $resource-style POST reaching update with no parameter, and nothing generated when the filter is enabled
  • ControllersAutoConfigurationSpec — default registration, the restore property, the Boot collision, user-bean back-off
  • FormTagLibResourceTests — updated for the new default, plus delete coverage that did not previously exist
  • RestfulResourceMappingSpec, AnsiConsoleUrlMappingsRendererSpec — mapping counts and the rendered mappings report updated for the added route

Documentation

grails-doc upgrade guide section 48 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

…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
@codeconsole
codeconsole force-pushed the feat/hiddenmethod-handler-mapping branch from 19f55a3 to d17f416 Compare August 20, 2026 23:16
@codeconsole codeconsole changed the title feat: move the hidden HTTP method override into the dispatcher when the filter is off (alternative to #16182) feat: disable the hidden HTTP method filter by default Aug 20, 2026
@codeconsole

Copy link
Copy Markdown
Contributor Author

Note for anyone weighing the authorization risk called out above — it is concrete rather than theoretical, and it has a known remedy.

Where it bites. AbstractFilterInvocationDefinition.findConfigAttributes compares a rule's declared method to the request's actual method (grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/AbstractFilterInvocationDefinition.groovy:129):

if (requestMethod && iu.httpMethod && iu.httpMethod != HttpMethod.valueOf(requestMethod)) {
    log.debug "Request '{} {}' doesn't match '{} {}'", requestMethod, url, iu.httpMethod, iu.pattern
    continue
}

With the filter enabled it runs at order -170, ahead of the security chain at -100, so requestMethod is already DELETE by the time this executes. With the filter disabled it is POST, and every Requestmap or intercept.map entry declared with httpMethod: DELETE is skipped for a browser form delete. The action still runs; the rule guarding it simply does not match, and nothing errors.

A remedy already sits in the tree, unused. grails.plugin.springsecurity.web.filter.HttpMethodOverrideDetector reads _method and returns the intended method. It is referenced by nothing but its own spec — it arrived with the Spring Security plugin merge and was never wired up, which makes sense, because while the filter is enabled it can only tell you what request.getMethod() already says. Once the filter is off it becomes the missing piece: it would let the security layer resolve the intended method for itself.

One constraint if that is pursued. Reading a request parameter inside the security chain re-creates, for multipart/form-data, the pre-authentication body parsing this PR exists to eliminate. That is avoidable by resolving _method only for application/x-www-form-urlencoded requests — a delete form is always form-encoded, and a multipart POST is a file upload, never a delete. The gap closes without any upload body being touched before authentication.

Deliberately not part of this PR: different module, security-sensitive, and it deserves its own review. Raising it here so the risk is not mistaken for an unsolvable one.

@codeconsole

Copy link
Copy Markdown
Contributor Author

Added 7cdb38535f as a separate commit — a build fix that is independent of the feature and easy to review or split out.

The bug. 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 resolved no candidate classes and reported NO-SOURCE. The main test task excludes exactly those patterns, so 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 other four, and a defect they expose. They assert the validation-error branch of a controller action — an invalid domain instance should re-render the create or edit view. They fail because domain validation is not enforced in this unit-test harness. A minimal probe:

PROBE blankValid=true nullValid=true okValid=true
PROBE constrainedProperties=[title]

against

@Entity
class Probe {
    String title
    static constraints = { title blank: false }
}

The constraint is registered and constrainedProperties reports it, but validate() returns true even for title: null, whose nullable constraint defaults to false. So the "invalid" instance is valid, the action takes its success path, and the error-view assertions never run.

That is pre-existing and unrelated to this PR — it was simply invisible while the tasks reported NO-SOURCE. I have marked those four @PendingFeature rather than adjust them to match the broken behaviour: the annotation fails the build if validation is fixed and they start passing, so it cannot outlive the bug. Happy to raise it as its own issue if that is preferred.

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
@codeconsole
codeconsole force-pushed the feat/hiddenmethod-handler-mapping branch from 7cdb385 to 75fb4ed Compare August 21, 2026 00:48
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.00000% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.7628%. Comparing base (1c005d7) to head (6e58f8b).

Files with missing lines Patch % Lines
...ils/web/servlet/mvc/GrailsDispatcherServlet.groovy 70.0000% 1 Missing and 2 partials ⚠️
...vy/org/grails/plugins/web/taglib/FormTagLib.groovy 33.3333% 0 Missing and 2 partials ⚠️
...plugins/web/mapping/UrlMappingsGrailsPlugin.groovy 50.0000% 0 Missing and 1 partial ⚠️
...s/web/mapping/mvc/UrlMappingsHandlerMapping.groovy 75.0000% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16183        +/-   ##
==================================================
+ Coverage     53.7450%   53.7628%   +0.0179%     
- Complexity      19821      19846        +25     
==================================================
  Files            2086       2087         +1     
  Lines           99667      99712        +45     
  Branches        17603      17616        +13     
==================================================
+ Hits            53566      53608        +42     
- Misses          38434      38435         +1     
- Partials         7667       7669         +2     
Files with missing lines Coverage Δ
.../web/controllers/ControllersAutoConfiguration.java 95.2381% <100.0000%> (+0.1561%) ⬆️
...core/src/main/groovy/grails/config/Settings.groovy 100.0000% <ø> (ø)
...ing/UrlMappingsBeanDefinitionsPostProcessor.groovy 88.0952% <100.0000%> (+1.6088%) ⬆️
...n/groovy/org/grails/web/util/HiddenHttpMethod.java 100.0000% <100.0000%> (ø)
...grails/web/mapping/DefaultUrlMappingEvaluator.java 78.6441% <100.0000%> (+0.2935%) ⬆️
...plugins/web/mapping/UrlMappingsGrailsPlugin.groovy 42.8571% <50.0000%> (-1.2605%) ⬇️
...s/web/mapping/mvc/UrlMappingsHandlerMapping.groovy 58.9474% <75.0000%> (+1.3387%) ⬆️
...vy/org/grails/plugins/web/taglib/FormTagLib.groovy 76.9679% <33.3333%> (-0.0789%) ⬇️
...ils/web/servlet/mvc/GrailsDispatcherServlet.groovy 33.8710% <70.0000%> (+6.9479%) ⬆️

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codeconsole
codeconsole requested review from borinquenkid, jamesfredley, jdaugherty, matrei and sbglasius and removed request for jdaugherty and matrei August 21, 2026 06:38
…handler-mapping

# Conflicts:
#	grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@testlens-app

testlens-app Bot commented Aug 21, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

CI / Functional Tests (Java 21, indy=false, shard 1) > :grails-test-examples-app1:integrationTest

Test Runs Flakiness
RedirectWithAndWithoutParamsFunctionalSpec > Params are not added to the url after a redirect even if they are passed to the redirect ❌ ✅ 1% 🟡

🏷️ Commit: 6e58f8b
▶️ Tests: 21694 executed
⚪️ Checks: 85/85 completed


Learn more about TestLens at testlens.app/docs.

@jdaugherty

jdaugherty commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

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.

@codeconsole

codeconsole commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

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 RestfulServiceController and g:form and has always been there. It is used in the scaffolding plugin.

<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 PUT /books instead of POST /books (what it really is)

This PR keeps the actual request and uses _method just for DELETE and utilizes the existing mapping urls for the rest of the requests. Alternatively, you could add 1 more rule for every mapping, BUT that creates O(n) mappings so if you have a lot of rules, it just overcomplicates.

I a follow up PR would also be to not use _method and use actual javascript to send a DELETE but that also requires javascript working in the browser.

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

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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants