There are many different ways that web applications implement protection against Cross-Site Request Forgery (CSRF) attacks. The protections that are more implicit than explicit are generally riskier, especially when they rely on browsers never adjusting what is permitted.
One interesting attempt at CSRF protection is the rejection of requests with a Content-Type header not equal to application/json. The effectiveness of this comes from browsers only allowing application/x-www-form-urlencoded, multipart/form-data and text/plain (and possibly a few other exceptions) to be sent cross-site. It is possible to send arbitrary values, but only after the receiving website has granted permission via Cross-Origin Resource Sharing (CORS).
Content-Type
application/json
application/x-www-form-urlencoded
multipart/form-data
text/plain
In 2011 this protection was bypassed by kuza55 using 307 redirects with Adobe Flash Player, resulting in CVE-2011-0059 in Firefox and CVE-2011-0447 in Ruby on Rails. Then in 2015, navigator.sendBeacon in Chrome was found to allow cross-site POST requests with an arbitrary Content-Type header, tracked as bug #490015.
navigator.sendBeacon
As these old tricks are no longer useful, I would like to share an interesting caveat where the protection can be bypassed if implemented as in the example application below.
Figure-1: Sinatra application that is vulnerable to CSRF
require "sinatra" require "json" before do # ... authentication check ... end post "/transfer-funds" do if request.content_type && request.content_type != "application/json" halt 403, "CSRF detected" end transaction = JSON.parse(request.body.read) # ... authorization check and process transaction ... end
There is a gotcha due to the fetch API not only accepting String objects for the body parameter, but also Blob objects. This is relevant as Blob objects are more complex than strings, containing not just data but also an associated type. Or even no type at all. By creating a Blob object without a type, then passing it to the fetch function, a HTTP POST request can be sent cross-site, without CORS, that will not have a Content-Type request header. This isn't just limited to empty request bodies either, as the data passed to Blob will become the HTTP request body.
fetch
String
body
Blob
Figure-2: fetch with POST without Content-Type
fetch("https://victim.com", { method: "POST", body: new Blob(["payload"]) });
Figure-3: Resulting HTTP request that is a POST with Content-Length but missing Content-Type
POST / HTTP/1.1 Host: victim.com Connection: keep-alive Content-Length: 7 sec-ch-ua: "Google Chrome"[snip...] sec-ch-ua-mobile: ?0 User-Agent: Mozilla/5.0 [snip...] sec-ch-ua-platform: "Linux" Accept: */* Origin: https://nastystereo.com Sec-Fetch-Site: cross-site Sec-Fetch-Mode: no-cors Sec-Fetch-Dest: empty Referer: https://nastystereo.com/ Accept-Encoding: gzip, deflate, br Accept-Language: en-GB,en;q=0.9 payload