What is XSRF?
An XSRF vulnerability is one which allows a malicious user (or website) to make an unsuspecting user perform an action on your site which they didn't want to happen.
As a basic example, imagine you allow users to post images in your comments. If a malicious user puts "http://example.com/logout.php" as the image's URL, where example.com is your domain, then any time a logged in user views that comment they will be logged out if you don't protect against XSRF. It's not a valid URL for the image, but that doesn't matter as the unsuspecting user's browser will still make the request and your site will perform the action thinking the user wanted it.
A more dangerous example could be that you allow a user account to be deleted without confirming the action or protecting it from XSRF in any way, so any user visiting the page would then get their account deleted instantly!
I thought I'd make a comment on your site. Check out this cool image!
<img src='http://example.com/delete_my_account.php" />
Even if you have no user submitted content on your site, you can still be vulnerable. If a malicious website contains a form which the unsuspecting user submits, it can submit the information to a different site which the user is logged in to, and that site will think the request came from the logged in user and will just perform the update as if the user had done it intentionally.
It doesn't even have to be a link/button the user clicks on, as shown in the first example it could happen even if just viewing a site. Obviously, this could become quite annoying for your users.
While annoying is one thing, it can also be dangerous. For example, let's say I'm logged into an account on a simple shopping cart site. I then go and browse to another unrelated website. The other website has a button which just says "Click here to register". Seems simple enough, this other website wants me to register an account. However, this is a malicious site and when I click the link, it's actually submitting a request to the original shopping cart website as if I'd clicked the "Order me 1000 of some expensive item in one-click" button. If the shopping cart website is susceptible to XSRF then it will think the request to order 1000 items was genuinely submitted by me, and I'll get a nice surprise in the mail and on my credit card statements.
It's a particularly difficult type of attack to get your head around as it's very subtle, but once you understand how it works it's not that difficult to protect against it. It's very easy to go down the bad route though and think you're safe when in reality you're still wide open to attack.
Protecting against XSRF - The bad way
So what can you do as a web developer to prevent such attacks? Effectively you're just wanting to make sure a request came from your site and was actually intended to be run by the user, so as a first step you can just check the referer header to make sure the request came from your own site.
<?php
$url = parse_url($_SERVER['HTTP_REFERER']);
if ($url['host'] != "wblinks.com")
{
die("You're not coming from my site. Possible XSRF attack");
exit();
}
?>
While this may work in some cases, it's going to be about as effective as an underwater hair dryer as far as stopping an XSRF attack. Altering the referer header is pretty trivial and it will also have the downside of making the site unusable for lots of people, since many browser or proxies can strip the referer header when in "private" mode.
This also wouldn't protect against the first example of XSRF, where someone just uses the logout URL as an image URL in a comment. As the request would come from the correct referer in that case.
So not only will you not prevent XSRF attacks, but you'll also annoy some of your users. Not a good solution.
Protecting against XSRF - The good way
What we really need is a nonce (one-time key/token) which allows us to validate that the request came from a form we presented to the user intentionally. The following code samples show the method I use to achieve this on my site.
For the purposes of the following code examples, you can assume the Session class is just a wrapper for the $_SESSION superglobal. It actually does some other stuff, but that doesn't matter for this.
I have an Auth class which handles everything to do with user authentication on my sites as well as authenticating that a request was valid, etc. In this class I have 3 very simple static functions,
// This is a nonce used to stop CSRF/XSRF attacks. It's stored in the user session.
public static function getNewRKey()
{
$rkey = substr(sha1(uniqid(rand(), true)), 0, 20);
$rkeys = Session::getInstance()->getVar("rkeys"); // $keys = $_SESSION['rkeys'];
$rkeys[] = $rkey;
Session::getInstance()->setVar("rkeys", $rkeys); // $_SESSION['rkeys'] = $rkeys;
return $rkey;
}
// This will determine if an rkey is valid
public static function validateRKey($rkey)
{
$rkeys = Session::getInstance()->getVar("rkeys"); // $rkeys = $_SESSION['rkeys']
if ($rkeys == null) { return false; }
return in_array($rkey, $rkeys);
}
// Creates the form input to use
public static function formRkey()
{
return "<input type=\"hidden\" name=\"rkey\" value=\"".Auth::getNewRKey()."\" />";
}
So what's going on here? Anytime I have a form on the site which POSTs data, I will output the formRKey() function to add a hidden field to this form. This hidden field contains the value returned from getNewRKey(), this method generates a random 20 character hash value, and stores it into the user session (I'll get to why that's an array in a moment) and then returns this hash so it can be put into the hidden input.
<form action=�do_something.php� method=�POST�> <fieldset> <?=Auth::formRKey();?> <input ... /> </fieldset> </form>
So, the user session will now contain an array called �rkeys�, which contains a random 20 character hash value.
$_SESSION['rkeys'] array(1) =>
{
[0] => '3748ab53cf129d536eca'
}
This has also added a hidden field to the HTML form which will look like this,
<input type="hidden" name="rkey" value="3748ab53cf129d536eca" />";
So when the user submits this form, the idea is that we take the rkey value and check it against the ones we've stored in the user session. If it's there, then the request is valid and came from a form which the site generated. If not, then it's a possible XSRF attack and the output should be stopped and logged. This validation is done in the validateRKey() method.
Now the final step, is to run something like the following code on any page that takes input. I actually have it in my bootstrap file which is run on every page request to the site.
if (count($_POST) > 0 && class_exists("Auth"))
{
if (!Auth::validateRKey(Sanitizer::sanitize($_POST['rkey'])))
{
Log::create("XSRF", "Possible XSRF attack", LOG::NOTIFY, LOG::NOTIFY_ADMIN);
die("Detected a possible cross site request forgery attack. "
."Perhaps you tried to refresh a POST request?");
exit();
}
}
// Clear any current rkeys
Session::getInstance()->clearVar("rkeys"); // unset($_SESSION['rkeys']);
So if anything has been POSTed to the page load, and the Auth class exists (this is because it's part of the framework I use on a lot of sites.. not all of which require Auth stuff). I validate that the POSTed rkey is in the list of expected ones. If not then I log the error, kill the page load and present an error to the user. You could perhaps redirect there instead of just calling die(), and present something more friendly to the user, it's up to you. For my purposes, this suffices.
Importantly, you also need to clear the session of acceptable rkeys, since a request has now come in and been validated, so all keys are now expired. If you don't do this then it's pointless, since someone can just screenscrape the rkey and it would always be valid in the user session. Though you may now be thinking, what if the user doesn't make another request? The rkey will remain valid until they do, allowing someone to use the value. Well, that's because we can improve this method further by including an expiry time.
Making it a little bit better!
I lied a little bit when saying the above code is how I do it. It's how I used to do it before having the thought above regarding if the user doesn't make another request. So the above method can improved slightly by adding an expiry time to the rkey when we first generate and store it in the session. When the rkey is validated it can then be checked to see if it has expired or not. This gets around the issue of never expiring an rkey because the user didn't revisit the site and allow us to clear the keys manually. Only the following two functions need to change,
// This is the nonce used to stop CSRF/XSRF attacks. It's stored in the user session.
public static function getNewRKey()
{
$rkey = substr(sha1(uniqid(rand(), true)), 0, 20);
$rkeys = Session::getInstance()->getVar("rkeys"); // $keys = $_SESSION['rkeys'];
$rkeys[$rkey] = strtotime("+15 mins");
Session::getInstance()->setVar("rkeys", $rkeys); // $_SESSION['rkeys'] = $rkeys;
return $rkey;
}
// This will determine if an rkey is valid
public static function validateRKey($rkey)
{
$rkeys = Session::getInstance()->getVar("rkeys"); // $rkeys = $_SESSION['rkeys']
if ($rkeys == null) { return false; }
// Check that the rkey exists, and time has not expired
foreach ($rkeys as $key => $expires)
{
if ($key == $rkey
&& time() <= $expires)
{
return true;
}
}
return false;
}
The "rkeys" array in the user session will now look like this instead, where the hash is the array key and the expiry time of that hash is the array value.
$_SESSION['rkeys'] array(1) =>
{
["3748ab53cf129d536eca"] => int(1275675864)
}
When a request comes in, we now not only validate that the rkey exists, but that it also hasn't expired. Restricting the validity time of the rkey reduces the likelyhood of an attack succeeding since it would have to be mounted quickly. Note that you should still clear all of the rkeys on every request once you've dealt with it though rather than relying entirely on the expiry time. If you know the key is done with then invalidate it instantly rather than waiting for a timeout.
Why an array of tokens?
Originally I coded this as just one rkey instead of an array, but that presented a problem. If I had more than one form on the page, only the final form would be valid, any other form would present the XSRF error, since a new rkey was generated each time a form was created and then only that new rkey was valid. An easy mistake to make, but something I noticed quite quickly when using lots of forms. Of course, you don't have to generate a fresh token for each form, you can just use one per user and apply that to every form on the page. But this is the way I've implemented it.
Why only POST?
You may be wondering why I'm only checking POST variables to prevent XSRF rather than both POST and GET. The answer is because GET should never be susceptible to such an attack if you're using it correctly.
So when should you use GET and when should you use POST? Well, it's all in the name. GET variables ideally should be used when the contents of the page are read-only, so nothing on the page gets changed by the request. So a GET request should be idempotent (I should be able to trigger the same GET request as many times as I want and it shouldn't affect the result I get. Basically, you want to make sure it doesn't have any side-effects). POST should be used whenever it causes a destructive action (I don't just mean deletes... I mean destructive as in something changes). So login, logout, updates, creation, deletion, comments, voting, etc.
This is why browsers will generally prompt you to confirm when resending a POST request, whereas they won't with a GET request. This is because a POST request will generally change the outcome each time it is run so you want to make sure you're not going to accidentally run it again and change something you didn't want to. A GET request shouldn't change any data that way, and so it doesn't need to be confirmed.
If you do want to use a GET for a destructive action, say you want it to be an anchor tag rather than a button otherwise your style will look strange, then you must make sure that you at least redirect to a confirmation page to confirm the YES/NO, ideally this confirmation page should use POST.
If not, then say you were to have a link in your admin area which deleted an item using GET, without any form of confirmation. Some browsers do what's called pre-fetching, where they examine the links on a page, and pre-fetch the websites you'd get by cliking these links, under the assumption that you will go to them. Then when you do click them, the page can be displayed very quickly. If you just delete with a GET, then simple visiting the admin page could cause the browser to follow all those links in the background and delete everything. Obviously not something you want. Yes, this happened to me in the past, so please learn from my mistakes.
POST Refresh
Another thing to keep in mind, is that you should redirect users to a GET based URL once the POST request has been handled. This means users will be able to hit F5 and refresh the result page without being prompted if they want to resubmit the POST request (since that will now cause an XSRF error to be displayed). This is common practice on most sites and something users now expect to be able to do. So they'll probably not be too happy if they have to re-send requests and then get errors saying they're XSRF attacking the site.
Comments
Charles
Fri 20th Apr at 07:53
Rich → http://wblinks.com
Fri 20th Apr at 09:31