diff --git a/src/Common/CsrfCounterMeasure.php b/src/Common/CsrfCounterMeasure.php index 348c4eeb..ae2dace2 100644 --- a/src/Common/CsrfCounterMeasure.php +++ b/src/Common/CsrfCounterMeasure.php @@ -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 * @@ -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; } } diff --git a/tests/Common/CsrfCounterMeasureTest.php b/tests/Common/CsrfCounterMeasureTest.php new file mode 100644 index 00000000..023a5711 --- /dev/null +++ b/tests/Common/CsrfCounterMeasureTest.php @@ -0,0 +1,69 @@ +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'); + } +}