Working CSRF AJAX in CakePHP 4 - looking for criticism!

Howdy.

I have spent considerable time in making a simple AJAX interface which has CSRF throughout. I spent a fair amount of time trying to use the form helper but it was hijacking my button with a ‘post’, so I gave up decided to implement my own.

What I am looking for please, is any security weaknesses in my approach, as well as any observations on how it should be done with CakePHP methodology.

One thing that springs to mind is identifying the initial call of my controller - at the moment it is identified as ‘not ajax’!

You should be able to duplicate this project by creating a new app and putting in these 2 files: -

<!-- templates/Tests/simple.php -->
<h2>Simple AJAX</h2>
<label for="edit">Show me:</label>
<input type="text" id="edit" value="!pirt dnuor">
<button id="button" data-rel="<?= $this->Url->build(['_ext' => 'json']) ?>">Reverse text</button>
<div id="result-container" style="font-size: 200%;"></div>

<script src="//code.jquery.com/jquery-3.5.0.min.js"></script>
<script>
$(function() {
	$('#button').click(function() {
    $('#result-container').html('Pending...');
		var targeturl = $(this).data('rel');
    var data = 'csrfToken=<?= $csrfToken ?>&edit=' + $('#edit').val();
    console.log(data);
		$.ajax({
			type: 'get',
			url: targeturl,
			data: data,
			beforeSend: function(xhr) {
				xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
			},
			success: function(response) {
				if (response.result) {
					var result = response.result;
          if (result.csrfToken !== '<?= $csrfToken ?>') //Admittedly if a hacker got this far
            $('#result-container').html('Invalid CSRF Token, try refreshing the page'); // he has his data in result.edit anyway
          else
            $('#result-container').html(result.edit);
				}
			},
			error: function(e) {
				alert("An error occurred: " + e.responseText.message);
				console.log(e);
			}
		});
	});

});
</script>

and

<?php // src/Controller/TestController.php
declare(strict_types=1);

namespace App\Controller;

class TestsController extends AppController {
  
	public function simple() {
    $ss = $this->getRequest()->getSession(); //an alias only
    $cc = 'tests_simple_csrf'; //session var unique to this controller + view
		if (!$this->request->is(['ajax'])) { //prepare for first time (note the not!)
      $csrfToken = bin2hex(random_bytes(24)); //create our token
      $_serialize = ['csrfToken'];  
      $this->set(compact('csrfToken', '_serialize'));
      $ss->write($cc, $csrfToken); // could there be race condition / session issues?
    } else { //our ajax reply, check token & process data
      $new_csrfToken = $this->request->getQuery('csrfToken');
      $csrfToken = $ss->read($cc);
      $return = __('Invalid CSRF Token, try refreshing the page');
      if ($csrfToken === $new_csrfToken) //raise exception on else?
        $return = strrev($this->request->getQuery('edit')); //the data we generate
			$result = ['edit' => $return, 'csrfToken' => $csrfToken]; //pass back original token!
      $_serialize = ['result'];
      //If you wanted to be more secure you could pass a new token on 
      // this ajax return and use that for next request.
      //But, if the current token has be acquired then the new one will be too.
			$this->set(compact('result', '_serialize'));
		}
	}
	
}

and you must add this line to config/bootstrap.php : -
Router::extensions(['json']);

The URL for this is /Tests/simple like http://localhost:8765/Tests/simple
Personally I change this line in config/routes.php from
$builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);
to
$builder->connect('/', ['controller' => 'Tests', 'action' => 'simple']);
and just use the domain home address; eg. http://localhost:8765 .

I hope this snippet of code can help others too with CakePHP 4 & AJAX as it took me a long time to get it to work. The demonstrations at https://sandbox.dereuromark.de/sandbox/ajax-examples/simple did help, but its near impossible to extract a stand-alone app from it. They rely on several other plugins and the code needs to be exorcised from the sandbox plugin. (It took many, many hours to realise that https://github.com/dereuromark/cakephp-sandbox/blob/master/plugins/Sandbox/src/Controller/AjaxExamplesController.php#L48 FORMAT_DB_DATETIME is not a PHP value, and came from a toolbox - which was killing the AJAX but not showing errors! Also, the ‘simple’ test requires this script https://sandbox.dereuromark.de/js/cjs/js-combined.v1585517020.js yea…!)

So please, let me know if my code is the wrong approach to our methodology here, or the security is flawed. For instance I am not using X-CSRF-Token headers, should I be? If so, how?!?

Cheers
Jonathan

I think I found a security weakness. If the packet is generated cross script, and no csrf is provided as a parameter then it may pass the check as there will be no session variable either, and "" === "".

So a > "" check needs to be done against both the parameter and the session variable.

Should I be using a session variable for this? I would think I need a super global in this instance, but am willing to be corrected!

Yay triple post.

Whilst my approach works its not the CakePHP way - its more like use Cake to wrap PHP (such irony).

I do now have it working using the csrf middleware which also inherently supports Authentication and Authorization (but not FormProtection as that cannot apply to AJAX).

If there is any interest in seeing working code please ask as I won’t go to the effort of building it otherwise!

Hello @jawfin, would you share the working code/files? Are you using any helpers?

I’m struggling to understand:

  • where the $csrfToken is first established because simple() isn’t called until the users submits the form
  • other tutorials I’ve seen reference the controller & action - simple() - in the ajax url field and your code doesn’t
    eg. website.com + <?=this ->Url->build([“controller”=>“Test”,“action”=>“simple”]);

I haven’t been able to get to get an ajax form to work and I’ve been through a bunch of different examples but nothing seems to work for CakePHP4. So I really appreciate any help you can give me or if you could point me in the direction to understanding!!

(I’m trying to save information that a user inputs on a services page (db: services) to a database of users, so it’s a cross post form. Basically, I want to allow a user to fill out a search field and store his email in another database).

This is a complete build of a running AJAX using CakePHP4 with CSRF and Form Tampering protection. It reverses a user entered string.

Create the ajax project: -
composer self-update && composer create-project --prefer-dist cakephp/app:4.* ajax

Edit config/app_local.php

...
		'username' => 'root',
		'password' => 'secret',
		'database' => 'cake_ajax',
...

Or whatever your database is.
For this demonstration I am using this table: -

DROP TABLE IF EXISTS `myajax`;
CREATE TABLE `myajax` (
  `myajax_id` int(11) NOT NULL AUTO_INCREMENT,
  `reverse_text` text,
  `solution_text` text,
  `created` datetime NOT NULL,
  `modified` datetime NOT NULL,
  PRIMARY KEY (`myajax_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

As CakePHP is ORM this is just a dummy table for filling in.

Edit config/bootstrap.php and add this line at the bottom of the file: -

Router::extensions(['json']);

Edit config/routes.php

<?php
...
//***You do not need to add this if using the latest build which loads CSRF in the src/Application.php
//Add this to the uses clauses
use Cake\Http\Middleware\CsrfProtectionMiddleware;
...
$routes->scope('/', function (RouteBuilder $builder) {
//Insert after the line above
	$builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([
		'httpOnly' => true,
	]));
	$builder->applyMiddleware('csrf');
//***Done CSRF		
...
	$builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);
//Replace the line above with this
	$builder->connect('/', ['controller' => 'MyAjax', 'action' => 'reverse']);
...		

The form protection / csrf protection relies on routing being consistent, be wary of how this is managed.

Edit src/Controller/AppController.php

...
//Uncomment this line that's near the bottom
	$this->loadComponent('FormProtection');
...
//Add this function at the bottom of the file
	public function beforeFilter(\Cake\Event\EventInterface $event)
	{
		parent::beforeFilter($event);
		$this->FormProtection->setConfig('unlockedActions', ['reverse']); //disable for the actual AJAX
	}
...		

Create src/Controller/MyAjaxController.php

<?php
declare(strict_types=1);

namespace App\Controller;

class MyAjaxController extends AppController
{
	public function reverse()
	{
//		$this->Authorization->skipAuthorization(); //Uncomment if needed
		$this->request->allowMethod(['get', 'put']);
		$myajax = $this->MyAjax->newEmptyEntity();
		if ($this->request->is(['get'])) //priming the form
		{
			$myajax->reverse_text = '!pirt dnuor'; //text to be reversed
		}
		if ($this->request->is(['ajax'])) //handling the ajax put call
		{
			$myajax = $this->MyAjax->patchEntity($myajax, $this->request->getData());
			$myajax->solution_text = strrev($myajax->reverse_text); //the data we generate
			$this->MyAjax->save($myajax); //for my purposes here I don't care if this fails
			$this->viewBuilder()->setOption('serialize', true); //echo'ing our result
		}
		$this->set(compact('myajax')); //returns both the original content, also the ajax primed data :)
	}
}

Create src/Model/Entity/MyAjax.php

<?php
declare(strict_types=1);

namespace App\Model\Entity;

use Cake\ORM\Entity;

/**
 * MyAjax Entity
 *
 * @property int myajax_id
 * @property string $reverse_text
 * @property string $solution_text
 */
class MyAjax extends Entity
{
	protected $_accessible = [
		'myajax_id' => true,
		'reverse_text' => true,
		'solution_text' => true,
	];    
}

Create src/Model/Table/MyAjaxTable.php

<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\ORM\Table;

class MyAjaxTable extends Table
{
  
	public function initialize(array $config): void
	{
		parent::initialize($config);

		$this->setTable('myajax');
		$this->setDisplayField('myajax_id');
		$this->setPrimaryKey('myajax_id');

		$this->addBehavior('Timestamp');
	}

}

Create folder & file templates/MyAjax/reverse.php

<h2>Simple AJAX</h2>
<?= $this->Form->create($myajax, ['id' => 'frmMyAjax']) /*Form has tamper protection, ID needed for AJAX*/ ?>
<?= $this->Form->control('reverse_text', ['label' => 'Reverse text:']) ?>
<?= $this->Form->end() /*This "form" has no submit button, its just so I can group the AJAX call*/ ?> 
<!-- NOTE: This button is outside the form, it is not a submit -->
<?= $this->Form->button('Reverse text',
				['id' => 'btn-rev-text',
				 'data-rel' => $this->Url->build(['_ext' => 'json'])]) ?>
<div id="div-result" style="font-size: 150%;"></div>

<script src="//code.jquery.com/jquery-3.5.0.min.js"></script>
<script>
$.ajaxSetup({cache: false});
var $btnrevtext = $('#btn-rev-text');
var $result = $('#div-result');
var targeturl = $btnrevtext.data('rel');
$(function() {
  $btnrevtext.click(function() {
    $btnrevtext.blur(); //so stupid hover effect doesn't get stuck
    var data = $('#frmMyAjax').serialize(); //wrap up the request, using the form, also gets csrf
//    console.log(data); //uncomment to see what's getting sent - edit it to test form tamper & csrf
    $result.html('Pending &hellip;');
    $.ajax({
      type: 'put',
      url: targeturl,
      data: data,
			beforeSend: function(xhr) {
				xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
			},
      success: function(response) {
        if (response && response.myajax) {
          $result.text(response.myajax.solution_text); //sanitise, etc!
        } else {
          $result.text('Unexpected AJAX result :/');
          console.log(response);
        }
      },
      error: function(xhr, ajaxOptions, thrownError) {
        console.log(xhr, ajaxOptions, thrownError);
        $result.text('Crashed :(');
      }
    });
  });
});
</script>

If anything is not Cake proper please let me know / correct it.

Hope this helps / works!!

Cheers
Jonathan