Skip to content
highCVSS 8.1CWE-650A01:2021 – Broken Access Control

Method and Content-Type Switching

GET and POST routing, _method and X-HTTP-Method-Override, and body parsers that ignore the declared content type.

The gap between what HTML can send and what the server accepts

An HTML form is limited: GET or POST, and one of three content types. Modern APIs frequently want PUT, PATCH, DELETE, and application/json — outside what a form can produce, and outside the CORS safelist, so a fetch would preflight.

That gap looks like a defence, and teams often treat it as one. This guide is about closing it, using two independent tricks:

  1. Method switching — make the server treat a POST (or a GET) as the method you need, either because it routes both to the same handler or because it honours an override convention.
  2. Content-type switching — make the server parse a body it did not expect, because the parser ignores the declared type.

Either one alone is often enough. Both are found by replaying a single request with one field changed, which makes them among the cheapest tests available.

Method routing

Pythonthe patterns to look forVulnerable
# Flask: no methods list means GET only -- but this is the common mistake,
# where a developer adds POST and leaves GET in place.
@app.route('/account/delete', methods=['GET', 'POST'])
def delete_account():
    current_user.delete()          # reachable by GET, so Lax sends the cookie
    return redirect('/')

# Django: a view that never checks request.method handles every method.
def delete_account(request):
    request.user.delete()          # GET, POST, PUT -- all of them
    return redirect('/')

# Rails: `via: :all` registers every verb on the route.
# config/routes.rb
#   match '/account/delete', to: 'accounts#destroy', via: :all

# Express: app.all() does the same.
app.all('/account/delete', (req, res) => { ... })

# Spring: @RequestMapping with no method attribute maps ALL methods.
# @RequestMapping("/account/delete")            <- every verb
# @RequestMapping(value="/account/delete", method=RequestMethod.POST)  <- correct

# Why this matters beyond convenience: every CSRF framework exempts safe
# methods from token checking, on the assumption that safe methods are
# side-effect free. A state-changing GET is therefore not merely reachable --
# it is UNPROTECTED, because the framework deliberately skipped the check.

Method override

HTMLVulnerable
<!doctype html>
<html>
  <body>
    <!-- Rails, Laravel, Symfony, and method-override middleware in Express
         all support a _method parameter that rewrites the HTTP verb
         server-side. It exists precisely because HTML forms cannot emit PUT
         or DELETE -- which means it is a documented, supported way to reach
         those verbs from a form.

         From the attacker's side that is a gift: a form is a top-level
         navigation with cookies and no preflight, and now it can DELETE. -->
    <form id="poc" action="https://api.example/account/settings" method="POST">
      <input type="hidden" name="_method" value="PUT" />
      <input type="hidden" name="email" value="attacker@evil.example" />
    </form>
    <script>document.getElementById('poc').submit()</script>
  </body>
</html>

<!-- Parameter names to try:
       _method       Rails, Laravel, Symfony, method-override
       _METHOD       case variants are sometimes accepted
       X-HTTP-Method-Override   as a body or query parameter, not a header
       X-Method-Override
       _HttpMethod              some .NET stacks -->

Content-type switching

The second half. Take a legitimate request in your proxy and change only the Content-Type, then replay. The matrix worth walking:

OriginalTryWhat a success means
application/jsontext/plainThe parser ignores the header. The text/plain JSON smuggle works.
application/jsonapplication/x-www-form-urlencodedModel binding accepts form fields. Even easier — an ordinary form reaches it.
application/jsonmultipart/form-dataSame, via the multipart parser.
application/x-www-form-urlencodedmultipart/form-dataOften parsed by different middleware, which may sit before or after the CSRF check.
application/jsonapplication/json; charset=utf-8 or with trailing whitespaceTests whether the comparison is exact or normalised — relevant when a WAF and the app disagree.

The framework-specific cases worth knowing:

  • ASP.NET Core model binding will populate a model from form fields on an action that normally receives JSON, unless [FromBody] is used exclusively.
  • Spring @RequestBody with no consumes attribute accepts more than you expect.
  • Express with express.urlencoded() mounted globally parses form bodies on every route including JSON APIs.
  • Rails parameter wrapping merges form parameters into the same params hash the JSON body would have populated.

Middleware ordering is the real bug

Both bypasses usually come down to the same underlying mistake: the CSRF check runs at a different point in the pipeline than the method or body resolution.

The two orderings, and why one is exploitable:

BAD:   route match -> CSRF check (sees POST) -> method override -> handler (runs as DELETE)
GOOD:  route match -> method override -> CSRF check (sees DELETE) -> handler

In the bad ordering, the CSRF middleware makes its decision using the pre-override method. If it exempts safe methods and the request arrives as a GET carrying ?_method=DELETE, the check is skipped entirely and the handler runs a delete.

The same shape appears with content types: a CSRF filter that only inspects req.body after a specific parser has run will see an empty body — and therefore no token — when the request arrives with an unexpected content type. Depending on how it handles that, it either fails open or looks for the token in a place the attacker controls.

When reviewing code, the question is not "do we check CSRF?" but "does the CSRF check see the same request the handler will?"

Prevention

JavaScriptapp.jsVulnerable
const express = require('express')
const methodOverride = require('method-override')
const app = express()

app.use(express.json())
// Parses form bodies on EVERY route, including JSON-only APIs.
app.use(express.urlencoded({ extended: true }))

// CSRF check runs here, sees the ORIGINAL method...
app.use(csrfProtection)

// ...and the override rewrites it afterwards. A form POST carrying
// _method=DELETE is checked as a POST and executed as a DELETE. If the
// override is read from the query string, a GET is checked as a safe method
// -- skipped entirely -- and executed as a DELETE.
app.use(methodOverride('_method'))

app.delete('/account', (req, res) => { deleteAccount(req.session.userId) })

Checklist

  • Register exact verbs. No app.all, no via: :all, no @RequestMapping without a method, no view that ignores request.method.
  • Disable method override unless something genuinely needs it. If it is needed, read it from a header only and apply it before the CSRF check.
  • Enforce the content type with a 415, do not merely prefer it.
  • Mount only the body parsers the route needs, rather than every parser globally.
  • Verify the pipeline order — the CSRF check must observe the same method and body the handler will. Write a test that sends POST + _method=DELETE without a token and asserts a 403.
  • Keep GET side-effect free, which removes the most valuable target for all of this.