Cross-Site Request Forgery is a method by which an action on a particular website is undertaken in secret, powered by malicious code hosted elsewhere. For example, let's say a form on your Concrete CMS site enables the changing of a user's password. If a malicious user somewhere knows where that form exists, how it's structured, and what parameters it takes, they can send a link to a user that, when the user opens the link, uses JavaScript to execute a post-back to their Concrete site, with the proper POST parameters in place. If the user is logged into their Concrete site in the background, they may have just unknowingly changed their password.
This is a cross-site request forgery attack. Protecting against these types of attacks requires the simple addition of some code within the form itself, as well as within the controller code that takes care of validating and performing the action.
Let's take a look at a vulnerable form, and a vulnerable piece of controller code.
<form method="post" action="<?=$view->action('change_password')?>
<input type="password" name="new_password" />
<button type="submit">Change Password</button>
</form>
Then here's the relevant single page controller method:
public function change_password()
{
$u = new User();
if ($u->isRegistered()) {
$ui = $u->getUserInfoObject();
$ui->changPassword($this->request->request->get('new_password'));
}
}
(Obviously, this is a contrived example – there's no validation that a new password is being posted, there's no success message, etc... but it's for explanatory purposes.) These two snippets of code will work together to successfully change a user's password. It checks to see if the user is logged in, and if so, retrieves the UserInfo object for the logged-in user, and changes their password. This code is vulnerable to cross-site request forgery! If a malicious user crafts a web page that uses an iframe to POST to this page, with a valid new_password password, as long as the user is logged in to their Concrete site their password will be changed without them knowing it!
Let's fix that. We're going to do this by adding a token to the password form. A validation token is simply a unique string that the website is able to generate, that cannot be practically forged by a malicious user.
<form method="post" action="<?=$view->action('change_password')?>
<?=Core::make('token')->generate('perform_change_password');?>
<input type="password" name="new_password" />
<button type="submit">Change Password</button>
</form>
Core::make('token')
retrieves the shared Validation Token library, Concrete\Core\Validation\CSRF\Token. output()
generates a hidden input field with the name ccm_token
(although you don't need to care about the name) based on the unique token value you pass in. This will now be included in the post.
This is only half of what you have to do. The other half? Modify the controller method to check for the valid presence of this token.
public function change_password()
{
$u = new User();
$token = \Core::make("token");
if ($u->isRegistered() && $token->validate('perform_change_password')) {
$ui = $u->getUserInfoObject();
$ui->changPassword($this->request->request->get('new_password'));
}
}
That's it! The request value will automatically be checked for the presence of this token. It's encoded and can't easily be forged by some malicious party who doesn't have access to the website source code.
Dashboard Pages
Token-based validation is easy in Concrete Dashboard pages. As long as your dashboard page extends the base Dashboard Controller Concrete\Core\Page\Controller\DashboardPageController (which is shoul always do) you'll have access to the token validator in your controller and in your view.
Controller
If the change_password
method above were in a Dashboard page, you could simply check it like this:
public function change_password()
{
$u = new User();
if ($u->isRegistered() && $this->token->validate('perform_change_password')) {
$ui = $u->getUserInfoObject();
$ui->changPassword($this->request->request->get('new_password'));
}
}
View
No need to use Core::make()
from within your Dashboard page view if you're trying to retrieve the token validation service; it's already there:
<form method="post" action="<?=$view->action('change_password')?>
<?=$token->generate('perform_change_password');?>
<input type="password" name="new_password" />
<button type="submit">Change Password</button>
</form>
Dashboard pages - Without a Form
All the examples above use a form and the token is generated as a hidden field.
Sometimes, however, you do not have a form and want to use a simple button or a link. An example of this would be the button to remove themes on the dashboard themes' page.
Controller
Say you are calling a function remove_object
. You would write it as follow:
public function remove_object($token)
{
$valt = Core::make('token');
if (!$valt->validate('remove_object_string', $token)) {
throw new Exception($valt->getErrorMessage());
// $this->redirect('/page_not_found');
}
// your code here if token validates
}
Personally I prefer to redirect to a different page, potentially throw a 404. That's the reason for the "redirect" code that's commented out.
Let's explain.
There can be 2 reasons why a token doesn't validate:
- it expired
- it was absent or incorrect
In the first case, really the page should be reloaded and an error message provided.
In the second situation, however, we can assume something fishy is taking place. And in that case I'd rather not provide the attacker with too much information, hence the redirect.
Ultimately, it is a matter of preference, security, and of good user experience.
View
In the view, since we are in the dashboard, I would use the dashboard UI helper. The same technique can be used with normal links as well.
<?php
$valt = Core::make('token');
$bt = Core::make('helper/concrete/ui');
echo $bt->button(t('Remove Object'), $view->url('/relative/path/to/page', 'remove_object', $valt->generate('remove_object_string')), 'btn-danger');
?>
What we have here for the "button" function is:
- First argument: the link or button's label or text
- Second argument: the url or action
- Third argument: that's just some Bootstrap class to make the button red
The "url" function takes the following arguments:
- First argument: the relative path to the single page which controller hosts the function we are calling
- Second argument: the function we are calling (inside the single page's controller)
- Third argument: the token to validate
The "url" function is smart enough to understand that arguments after the first 2 (page's path and function name) are all arguments to send the function that is being called.
Say the function you are calling had 2 or more arguments, you could write the code like this:
<?php
$valt = Core::make('token');
$bt = Core::make('helper/concrete/ui');
echo $bt->button(t('Remove Object'), $view->url('/relative/path/to/page', 'remove_object', $valt->generate('remove_object_string'), $arg2, $arg3), 'btn-danger');
?>
The result would be a link with a url looking like this:
/relative/path/to/page/remove_object/token_generate/arg2/arg3
One thing to note is the button could be on a page and call a function in a different page's controller by simply providing the path to the other page in the second argument.
A different way of writing this code is:
<?php
$valt = Core::make('token');
$bt = Core::make('helper/concrete/ui');
echo $bt->button(t('Remove Object'), this->action('remove_object', $valt->generate('remove_object_string')), 'btn-danger');
?>
The difference here is the page path is not needed anymore as the code will look for the function right where it is, in the current page's controller.