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:
- Method switching — make the server treat a
POST(or aGET) as the method you need, either because it routes both to the same handler or because it honours an override convention. - 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
# 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
<!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:
| Original | Try | What a success means |
|---|---|---|
application/json | text/plain | The parser ignores the header. The text/plain JSON smuggle works. |
application/json | application/x-www-form-urlencoded | Model binding accepts form fields. Even easier — an ordinary form reaches it. |
application/json | multipart/form-data | Same, via the multipart parser. |
application/x-www-form-urlencoded | multipart/form-data | Often parsed by different middleware, which may sit before or after the CSRF check. |
application/json | application/json; charset=utf-8 or with trailing whitespace | Tests 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
@RequestBodywith noconsumesattribute 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
paramshash 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
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, novia: :all, no@RequestMappingwithout a method, no view that ignoresrequest.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=DELETEwithout a token and asserts a 403. - Keep GET side-effect free, which removes the most valuable target for all of this.
Related
Smuggling a JSON document through enctype="text/plain", content-type confusion in body parsers, and GraphQL over GET and form encodings.
The urlencoded and multipart form: the only primitive that issues a cross-site request with a body, ambient credentials, and no preflight.
img, script, link, scripted link clicks, and top-level navigation — and why only the last one still carries a cookie.
Token not checked, checked only when present, empty accepted, not tied to the session, reused across users, or predictable.
Lax top-level GET, Chromium's 120-second Lax+POST window, on-site redirect gadgets against Strict, and schemeful downgrade.
csurf is deprecated and archived, csrf-csrf as the replacement, and the body-parser settings that reopen the text/plain vector.
Why [ApiController] plus cookie auth means no antiforgery at all, AutoValidateAntiforgeryToken, and the model-binding content-type gap.