Skip to content

Commit

Permalink
CsrfCounterMeasure: Only validate transmitted tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
nilmerg committed Aug 5, 2024
1 parent 6dd2276 commit 492336f
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 25 deletions.
58 changes: 33 additions & 25 deletions src/Common/CsrfCounterMeasure.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

namespace ipl\Web\Common;

use Error;
use ipl\Html\Contract\FormElement;
use ipl\Html\Form;
use ipl\Html\FormElement\HiddenElement;

trait CsrfCounterMeasure
{
/**
* Create a form element to counter measure CSRF attacks
* Create a form element to countermeasure CSRF attacks
*
* @param string $uniqueId A unique ID that persists through different requests
*
Expand All @@ -21,28 +22,35 @@ protected function createCsrfCounterMeasure($uniqueId)
$seed = random_bytes(16);
$token = base64_encode($seed) . '|' . hash($hashAlgo, $uniqueId . $seed);

/** @var Form $this */
return $this->createElement(
'hidden',
'CSRFToken',
[
'ignore' => true,
'required' => true,
'value' => $token,
'validators' => ['Callback' => function ($token) use ($uniqueId, $hashAlgo) {
if (strpos($token, '|') === false) {
die('Invalid CSRF token provided');
}

list($seed, $hash) = explode('|', $token);

if ($hash !== hash($hashAlgo, $uniqueId . base64_decode($seed))) {
die('Invalid CSRF token provided');
}

return true;
}]
]
);
$options = [
'ignore' => true,
'required' => true,
'validators' => ['Callback' => function ($token) use ($uniqueId, $hashAlgo) {
if (empty($token) || strpos($token, '|') === false) {
throw new Error('Invalid CSRF token provided');
}

list($seed, $hash) = explode('|', $token);

if ($hash !== hash($hashAlgo, $uniqueId . base64_decode($seed))) {
throw new Error('Invalid CSRF token provided');
}

return true;
}]
];

$element = new class ('CSRFToken', $options) extends HiddenElement {
public function hasValue(): bool
{
return true; // The validator must run even if the value is empty
}
};

$element->getAttributes()->registerAttributeCallback('value', function () use ($token) {
return $token;
});

return $element;
}
}
69 changes: 69 additions & 0 deletions tests/Common/CsrfCounterMeasureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace ipl\Tests\Web\Common;

use ipl\Html\Contract\FormElement;
use ipl\Html\Form;
use ipl\Html\FormElement\HiddenElement;
use ipl\Tests\Web\TestCase;
use ipl\Web\Common\CsrfCounterMeasure;

class CsrfCounterMeasureTest extends TestCase
{
public function testTokenCreation()
{
$token = $this->createElement();

$this->assertInstanceOf(HiddenElement::class, $token);
$this->assertMatchesRegularExpression(
'/ value="[^"]+\|[^"]+"/',
(string) $token,
'The value is not rendered or does not contain a seed and a hash'
);
}

public function testMissingToken()
{
$token = $this->createElement();

$this->assertNull($token->getValue(), 'The default value must only be set after the form is rendered');

$this->expectError();
$this->expectErrorMessage('Invalid CSRF token provided');

$token->isValid();
}

public function testValidToken()
{
$token = $this->createElement();

$this->assertSame(1, preg_match('/ value="([^"]+)"/', (string) $token, $matches));

$token->setValue($matches[1]);
$this->assertTrue($token->isValid(), 'Token should be valid with the default value');
}

public function testInvalidToken()
{
$token = $this->createElement();

$token->setValue('invalid');

$this->expectError();
$this->expectErrorMessage('Invalid CSRF token provided');

$token->isValid();
}

private function createElement(): FormElement
{
$form = new class extends Form {
use CsrfCounterMeasure {
createCsrfCounterMeasure as public;
}
};

return $form->createCsrfCounterMeasure('uniqueId');
}
}

0 comments on commit 492336f

Please sign in to comment.