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 …');
$.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