Skip to content

Commit cc9077d

Browse files
authored
Fix CsrfCounterMeasure bypass for crafted cross-site requests (#405)
The `Sec-Fetch-Site` based element decided the cross-site rejection from the submitted `CSRFToken` value instead of the server-computed result, so a crafted request could bypass the countermeasure in two ways: 1. An empty value nulled the element, and since it was not required its validator was skipped entirely and the request treated as valid. 2. Any other non-empty value was coerced to the validator's bool parameter, where every truthy string passed as safe. The element now ignores the submitted value: it always reports a value so the validator runs regardless of input, and the validator rejects based solely on the `Sec-Fetch-Site` result. This restores the invariant that a cross-site request cannot be accepted no matter what the client sends.
2 parents c2418f8 + 81f44e5 commit cc9077d

2 files changed

Lines changed: 37 additions & 10 deletions

File tree

src/Common/CsrfCounterMeasure.php

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Error;
66
use ipl\Html\Contract\FormElement;
77
use ipl\Html\FormElement\HiddenElement;
8+
use ipl\Validator\CallbackValidator;
89

910
trait CsrfCounterMeasure
1011
{
@@ -55,17 +56,17 @@ protected function createCsrfCounterMeasure($uniqueId)
5556
{
5657
$requestIsSafe = $this->requestIsSafe();
5758
if ($requestIsSafe !== null) {
58-
return new HiddenElement('CSRFToken', [
59+
return new class ('CSRFToken', [
5960
'ignore' => true,
60-
'value' => $requestIsSafe,
61-
'validators' => ['Callback' => function (bool $requestIsSafe) {
62-
if ($requestIsSafe) {
63-
return true;
64-
}
65-
66-
throw new Error('Rejecting cross-site request');
67-
}]
68-
]);
61+
'validators' => [
62+
new CallbackValidator(fn() => $requestIsSafe ?: throw new Error('Rejecting cross-site request')),
63+
],
64+
]) extends HiddenElement {
65+
public function hasValue(): bool
66+
{
67+
return true; // The validator must run even if no value was submitted
68+
}
69+
};
6970
}
7071

7172
$hashAlgo = in_array('sha3-256', hash_algos(), true) ? 'sha3-256' : 'sha256';

tests/Common/CsrfCounterMeasureTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,32 @@ public function testUnsafeCrossSiteRequestIsRejected(): void
8484
$this->makeForm()->handleRequest($this->requestMock('POST'));
8585
}
8686

87+
public function testCrossSiteRequestIsRejectedWithNonEmptyToken(): void
88+
{
89+
$_SERVER['HTTP_SEC_FETCH_SITE'] = 'cross-site';
90+
91+
$this->expectException(Error::class);
92+
$this->expectExceptionMessage('Rejecting cross-site request');
93+
94+
$form = $this->makeForm();
95+
$form->populate(['CSRFToken' => 'anything']);
96+
$form->ensureAssembled();
97+
$form->isValid();
98+
}
99+
100+
public function testCrossSiteRequestIsRejectedWithEmptyToken(): void
101+
{
102+
$_SERVER['HTTP_SEC_FETCH_SITE'] = 'cross-site';
103+
104+
$this->expectException(Error::class);
105+
$this->expectExceptionMessage('Rejecting cross-site request');
106+
107+
$form = $this->makeForm();
108+
$form->populate(['CSRFToken' => '']);
109+
$form->ensureAssembled();
110+
$form->isValid();
111+
}
112+
87113
public function testCreateReturnsDummyElementForSafeRequest(): void
88114
{
89115
$_SERVER['HTTP_SEC_FETCH_SITE'] = 'same-origin';

0 commit comments

Comments
 (0)