Skip to content

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

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

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

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Grails currently rewrites hidden HTTP methods in a servlet filter that runs before the dispatcher and Spring Security. Reading _method there 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

  • GrailsDispatcherServlet resolves _method after multipart handling and the filter chain.
  • Overrides are limited to PUT, PATCH, and DELETE; X-HTTP-Method-Override is no longer used in the new default mode.
  • UrlMappingsHandlerMapping applies the same resolution when matching routes.
  • resources mappings add POST /$controller/$id -> update, matching the POST method already accepted by RestfulController.update.
  • <g:form method="PUT"> and PATCH submit to that POST update route without _method.
  • <g:form method="DELETE"> still emits _method, because update and delete share the same URL.
  • Grails backs off when Spring Boot's hidden-method filter is enabled, avoiding the hiddenHttpMethodFilter bean-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 becomes DELETE only 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:

grails:
    web:
        hiddenmethod:
            filter:
                enabled: true

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.

…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
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 84.48276% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.7310%. Comparing base (351652b) to head (90e0842).
⚠️ Report is 76 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ils/web/servlet/mvc/GrailsDispatcherServlet.groovy 63.6364% 2 Missing and 2 partials ⚠️
...vy/org/grails/plugins/web/taglib/FormTagLib.groovy 33.3333% 0 Missing and 2 partials ⚠️
.../web/controllers/ControllersAutoConfiguration.java 66.6667% 0 Missing and 1 partial ⚠️
...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     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     
Files with missing lines Coverage Δ
...GrailsHiddenHttpMethodFilterAutoConfiguration.java 100.0000% <100.0000%> (ø)
...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.6078% <100.0000%> (+0.2573%) ⬆️
.../web/controllers/ControllersAutoConfiguration.java 93.1034% <66.6667%> (-1.9785%) ⬇️
...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.3848% <33.3333%> (-0.0772%) ⬇️
...ils/web/servlet/mvc/GrailsDispatcherServlet.groovy 33.8710% <63.6364%> (+6.9479%) ⬆️

... and 54 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
@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

…handler-mapping

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

matrei commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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
}

codeconsole added a commit to codeconsole/grails-core that referenced this pull request Aug 25, 2026
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
@testlens-app

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

codeconsole commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Accurate description of the behaviour, and fixed in 482fa1c — your structure, with one addition.

The mode now comes from HiddenHttpMethod.isServletFilterMode (either property), used by the dispatcher, handler mapping, URL mapping evaluator and g:form alike so they cannot drift.

That is only sound if a filter really exists whenever either property is set, and it did not: Boot's filter comes from WebMvcAutoConfiguration, which backs off entirely under @EnableWebMvc. Asking for Boot's filter there produced no filter, while the Grails one had already backed off on the property. So the registration moved to its own auto-config ordered after Boot's, gating on @ConditionalOnMissingBean — detecting Boot's filter instead of assuming it — and contributing the Grails filter when Boot has not. Same pattern GrailsFormContentFilterAutoConfiguration already uses.

Worth noting the case you found was worse than redundant: with spring=true, g:form stopped emitting _method for PUT and the POST member route appeared, so form updates hit the security chain as a POST — the exact silent authorization change the upgrade note warns about, landing on whoever opted in to avoid it.

Covered by GrailsHiddenHttpMethodFilterAutoConfigurationSpec, including the @EnableWebMvc case.

@codeconsole

Copy link
Copy Markdown
Contributor Author

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 git merge-base --is-ancestor confirms every commit here is contained in #16149.

Closing in favour of that. The reason is review flow rather than anything wrong here: these branches collided in GrailsDispatcherServlet.checkMultipart and UrlMappingsHandlerMapping, so reviewing them separately meant tracking a merge order, which is not a reasonable thing to ask of a reviewer.

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.

3 participants