Sitelet https://github.com/rack/rack/pull/2502
Skip to content

Exclude QUERY from _method form-parameter overrides - #2502

Open
jeremy wants to merge 1 commit into
rack:mainfrom
jeremy:method-override-query-removal
Open

jeremy wants to merge 1 commit into
rack:mainfrom
jeremy:method-override-query-removal

Conversation

@jeremy

@jeremy jeremy commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

#2474 added QUERY to MethodOverride's override targets. Before that ships in a release, this excludes QUERY from the _method form-parameter channel while keeping it available via the X-HTTP-Method-Override header — the split @samuel-williams-shopify suggested.

Frameworks CSRF-exempt QUERY on the strength of two structural facts: HTML forms cannot emit it, and cross-origin fetch() QUERY is never CORS-safelisted, so it always preflights. The _method parameter is reachable from a plain form POST, so overriding to QUERY there falsifies the first fact for any framework that checks the post-override method — the natural way to write the check.

The header channel carries no such risk: custom headers always force a CORS preflight, and header tunneling is the standing tool for infrastructure that rejects unfamiliar methods (Puma, for example, accepts only the standard eight by default). So the policy splits by channel: the parameter channel defaults to allowed_overrides - ["QUERY"], and a new allowed_param_overrides: keyword makes the parameter policy explicit for apps that want it wider or narrower.

Since #2474 and the override keywords are both unreleased, this is a no-op relative to the last release.

/cc @seanpdoyle

Comment thread lib/rack/method_override.rb Outdated
@jeremy
jeremy force-pushed the method-override-query-removal branch from 51dd5a4 to 1b591ec Compare August 14, 2026 06:01

@jeremyevans jeremyevans left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We allow overriding to GET, so not allowing overriding to QUERY seems weird. QUERY is supposed to be idempotent, basically GET but with a body. If you don't CSRF check GET, you shouldn't need to CSRF check QUERY.

In terms of CORS-safelisting, only GET, HEAD, and POST are safelisted, and we allow overriding to other non-safelisted methods.

In terms of upside, if you are making a request using a normal HTTP form to an endpoint that is idempotent and expects QUERY (relying on Rack::MethodOverride to convert browser requests), that wouldn't work after this change.

Maybe I'm missing something, but either I don't understand the reasoning, or I don't agree with it.

@samuel-williams-shopify

Copy link
Copy Markdown
Collaborator

I agree with Jeremy's concern here. MethodOverride exists to provide application-level method semantics even though the request remains a POST on the wire; losing intermediary caching/retry behavior is true for every tunneled method and doesn't make QUERY uniquely inappropriate.

A form using _method=query is a legitimate way to reach a safe, idempotent, body-bearing endpoint. Also, the shared allowlist governs both _method and X-HTTP-Method-Override, so this removes header-based QUERY overrides even though the form-CSRF rationale does not apply to that channel.

Frameworks should use rack.methodoverride.original_method when determining CSRF exemptions. If Rack wants additional defense in depth, I think we should separate the parameter and header policies rather than remove QUERY globally. On that basis, I don't think we should merge this as written.

@jeremy

jeremy commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

It does seem weird at first blush! But GET being in the list is the point, not the counterexample. Every override target today is one of two kinds:

  1. CSRF-exempt, but forgeable natively anyway (a tunneled GET adds no capability), or
  2. not CORS-safelisted but CSRF-verified (the token check applies regardless of how the request arrived).

In neither case can _method change a request's security treatment:

Forgeable natively Preflight-protected
CSRF-exempt GET, HEAD ✨QUERY✨
CSRF-verified POST PUT, PATCH, DELETE, LINK, UNLINK

QUERY is the first method in that empty cell: exempt AND preflight-protected. So it's the first where tunneling does change treatment. A plain form POST becomes a request the framework trusts on the grounds that forms can't produce it.

The natural get? || head? || query? implementation that most folks will reach for has this bug with exemption keyed on the post-override method.

Since the problem is specific to the form-param route, IMO Samuel's split is the precise fix. Updating the PR to suit.

Frameworks CSRF-exempt QUERY on the strength of two structural facts:
HTML forms cannot emit it, and cross-origin fetch() QUERY is never
CORS-safelisted, so it always preflights. The _method parameter is
reachable from a plain form POST, so overriding to QUERY there
falsifies the first fact for any framework that checks the
post-override method — the natural way to write the check.

The X-HTTP-Method-Override header carries no such risk: custom headers
always force a CORS preflight, and header tunneling is the standing
tool for infrastructure that rejects unfamiliar methods. So split the
policy by channel: the parameter channel defaults to allowed_overrides
minus QUERY, and the new allowed_param_overrides keyword makes the
parameter policy explicit for apps that want it wider or narrower.
@jeremy
jeremy force-pushed the method-override-query-removal branch from 1b591ec to c70f697 Compare August 14, 2026 19:28
@jeremy jeremy changed the title Remove QUERY from MethodOverride's override whitelist Exclude QUERY from _method form-parameter overrides Aug 14, 2026
@jeremy

jeremy commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Rescoped to the form-param/header split.

  • _method=query is a plain form POST and is verified like one.
  • X-HTTP-Method-Override: QUERY forces CORS preflight so it shares QUERY's protections.
Forgeable natively Preflight-protected
CSRF-exempt GET, HEAD QUERY, POST+X-HTTP-Method-Override: QUERY
CSRF-verified POST, POST+_method=query PUT, PATCH, DELETE, LINK, UNLINK

Parameter policy defaults to allowed_overrides - ["QUERY"] and gets its own allowed_param_overrides: kwarg so apps can widen/narrow as they wish.

@jeremyevans jeremyevans left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree that we should block _method=query. You haven't provided an explanation about why CSRF-exempt, Preflight-protected verbs shouldn't allow overrides using a parameter. No evidence is provided to back up: "A plain form POST becomes a request the framework trusts on the grounds that forms can't produce it."

Frameworks shouldn't automatically trust a QUERY request any more or less than a GET request, even if it is preflight protected. QUERY handling should be idempotent, just as GET handling should be. If a framework uses rack.methodoverride.original_method for CSRF exemptions, then all this change does is actively remove the ability to emulate QUERY via a POST form.

Samuel stated: "A form using _method=query is a legitimate way to reach a safe, idempotent, body-bearing endpoint." Even if we want to split the parameter and header policies, we should still allow QUERY by default for both. The split would allow the user to only turn it off for either header or parameter instead of forcing both. I don't think the complexity for the split is worth it.

@jeremy

jeremy commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

The claim is mechanical, not empirical:

  • QUERY's CSRF exemption is granted because HTML forms cannot send QUERY.
  • CSRF checks key on REQUEST_METHOD.
  • MethodOverride lets a plain form POST set REQUEST_METHOD to QUERY.
  • So a plain form POST arrives CSRF-exempt.

And it's already live: in Roda, route_csrf keys its denylist on request.request_method, so on Rack main a tunneled _method=query skips the check today with no QUERY support added anywhere. Fails open with CSRF bypass.

For Sinatra folks, rack-protection's safe? allowlist keys on the same post-override REQUEST_METHOD. It fails closed today, but flips the day someone adds QUERY to the allowlist (the natural "support QUERY" one-liner PR).

Both are reasonable code and both demonstrate the concern here. Keying on the post-override method is the natural and near-universal pattern, which is exactly why the default matters.

On trusting QUERY like GET: a forged GET adds no capability. Cross-origin GET is already unpreflighted. A forged QUERY reaches endpoints that the browser promises are preflight-gated. Fetch could have CORS-safelisted QUERY (safe, idempotent) and deliberately didn't. _method=query recreates at the Rack layer exactly the exposure the platform declined to create. And QUERY's primary use for expensive body-borne queries is also the worst case for XS-Search vulns and resource exhaustion where handler idempotence is no defense.

Emulation/override remains available via the header and per-app allowed_param_overrides: for the form param. The default protects the checks already shipped.

@jeremyevans

Copy link
Copy Markdown
Contributor
  • QUERY's CSRF exemption is granted because HTML forms cannot send QUERY.

QUERY is generally CSRF exempted because it is read only/idempotent (like GET), not because HTML forms cannot send it. HTML forms can send both GET and POST. GET is CSRF exempted as it is read only/idempotent, POST is not CSRF exempted as it is not. HTML forms cannot send PUT, and PUT is generally not CSRF exempted.

And it's already live: in Roda, route_csrf keys its denylist on request.request_method, so on Rack main a tunneled _method=query skips the check today with no QUERY support added anywhere. Fails open with CSRF bypass.

Roda doesn't use the Rack::MethodOverride middleware because it is a terrible idea from a security perspective. With Roda's defaults, it doesn't fail, because _method=query is treated as a normal parameter.

Keying on the post-override method is the natural and near-universal pattern, which is exactly why the default matters.

I think this discussion has shown that using the post-override method is a bad idea. CSRF protection should use rack.methodoverride.original_method if Rack::MethodOverride overrode the request method. Assuming that the CSRF protection in use does use rack.methodoverride.original_method if Rack::MethodOverride overrode the request method, can you provide a reason we should make the proposed change?

@jeremy

jeremy commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

We're in agreement that the exemption tracks safety/idempotence, then, and that keying CSRF on the post-override method is wrong. So native QUERY's exemption isn't in dispute. The only thing on the table is the tunneled _method=query POST.

The asymmetry with GET is why the tunnel is different: GET and native QUERY are safe to exempt for opposite reasons. GET's exemption never leaned on a preflight. An attacker can already forge cross-origin GETs freely, so exempting one grants nothing, and GET writes nothing. Native QUERY is the mirror image. The preflight is what makes it unforgeable cross-origin, and that gate is the whole reason exempting it is safe.

A _method=query form POST is unpreflighted, so a cross-origin attacker reaches the QUERY endpoint the gate was protecting. The exemption has nothing to fall back on. QUERY was never freely forgeable like GET, and unlike PUT or DELETE it's exempt from the token that stands in for the missing preflight. Idempotent isn't side-effect-free, either. A QUERY search endpoint is a textbook XS-Search oracle and a compute-amplification target. The Fetch spec made cross-origin QUERY opt-in via preflight on purpose. Our tunnel would then manufacture the unpreflighted path the platform declined to create.

That leaves us with one question: where does the fix belong? A CSRF layer that keys on original_method is immune. Most are not. Keying on REQUEST_METHOD is the natural choice and nothing marks original_method as security-relevant. A method that's safe only when every implementer reads an undocumented env key is exactly what a default should fix. Rack can't reach every CSRF layer, but it can make its own middleware safe and coherent at the chokepoint, for one kwarg + default.

If the concern is layering (why CSRF policy lives in a method-rewriting middleware), that's worth treating directly. Samuel's param/header split keeps this as a general channel policy rather than a QUERY special-case, and we can document original_method as the required key regardless of where the default lands.

@jeremyevans

Copy link
Copy Markdown
Contributor

We've established that the proposed change breaks a valid use case. We've also established that CSRF protection should look at the original method and not the overridden method if Rack::MethodOverride is used.

Your argument is that we cannot fix all CSRF protection, and it's worth breaking the valid use case in order to protect against cases where CSRF protection uses the overridden method.

Your other argument is that QUERY is a more serious issue than GET as it has a body. For XSS vulnerabilities, in general you can fit them in the query string limits and don't need to use a request body. For amplification attacks, this doesn't seem like a significant vector for most applications. Most (maybe all) applications that would be vulnerable to an amplification attack with this would also be vulnerable to an amplification attack without it.

You can look at the Rack::MethodOverride implementation to see that it parses the entire request body before even making a decision on whether to override the method. Not to mention that the body parsing is done and cached before the method override. This results in POST -> GET overrides retaining their body parameters (Request#POST and Request#params):

require 'rack'
require 'stringio'

app = proc do |env|
  r = Rack::Request.new(env)
  p [r.request_method, r.params]
end
Rack::MethodOverride.new(app).call(
  "REQUEST_METHOD" => "POST",
  "rack.input" => StringIO.new("_method=get&body_param=value")
)
# => ["GET", {"_method" => "get", "body_param" => "value"}]

I think the proper fix is at the CSRF protection layer. Fixing the issue there does not break the valid use case.

Your point about the lack of documentation is well taken. The fix there is to document the security issues with Rack::MethodOverride, recommend against its use, and describe the changes needed to mitigate the damage, such as using rack.methodoverride.original_method for CSRF and all other cases where the original method should be considered (in general, any security sensitive code). Could you please update this pull request to a documentation only change of that nature?

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants