From 057521015e8a5e7b6a242ffaf71f4ac19c76b0fc Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sat, 10 Oct 2026 15:33:59 +0200 Subject: [PATCH 1/2] fix: preserve JSON request body when CSRF token is not in it --- system/Security/Security.php | 12 +++++-- tests/system/Security/SecurityTest.php | 35 +++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.6.rst | 4 +++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/system/Security/Security.php b/system/Security/Security.php index 4ac0de3f8ff8..16b35ebd949a 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -293,9 +293,11 @@ private function removeTokenInRequest(IncomingRequest $request): void // If the token is found in JSON data, we can safely remove it. try { - $json = json_decode($body, flags: JSON_THROW_ON_ERROR); + $json = json_decode($body, flags: JSON_THROW_ON_ERROR); + $isJson = true; } catch (JsonException) { - $json = null; + $json = null; + $isJson = false; } if (is_object($json)) { @@ -307,6 +309,12 @@ private function removeTokenInRequest(IncomingRequest $request): void return; } + // A valid JSON body that is not an object cannot contain the CSRF token, + // so leave it untouched. + if ($isJson) { + return; + } + // If the token is found in form-encoded data, we can safely remove it. parse_str($body, $result); diff --git a/tests/system/Security/SecurityTest.php b/tests/system/Security/SecurityTest.php index db80d91cbd92..0b8906ee8d34 100644 --- a/tests/system/Security/SecurityTest.php +++ b/tests/system/Security/SecurityTest.php @@ -237,6 +237,41 @@ public function testCsrfVerifyHeaderWithJsonBodyStripsTokenFromBody(): void $this->assertSame('{"foo":"bar"}', $request->getBody()); } + #[DataProvider('provideCsrfVerifyPreservesNonObjectJsonBody')] + public function testCsrfVerifyPreservesNonObjectJsonBody(string $body): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest(); + + $request->setHeader('X-CSRF-TOKEN', self::CORRECT_CSRF_HASH); + $request->setBody($body); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertSame($body, $request->getBody()); + } + + /** + * @return iterable + */ + public static function provideCsrfVerifyPreservesNonObjectJsonBody(): iterable + { + yield 'JSON array' => ['[{"foo":"bar"}]']; + + yield 'JSON array of scalars' => ['[1, 2, 3]']; + + yield 'JSON number' => ['123']; + + yield 'JSON boolean' => ['true']; + + yield 'JSON null' => ['null']; + + yield 'JSON string' => ['"foo"']; + } + public function testCsrfVerifyPutBodyThrowsExceptionOnNoMatch(): void { service('superglobals') diff --git a/user_guide_src/source/changelogs/v4.7.6.rst b/user_guide_src/source/changelogs/v4.7.6.rst index d2641f99b7cf..04eb1be1d52b 100644 --- a/user_guide_src/source/changelogs/v4.7.6.rst +++ b/user_guide_src/source/changelogs/v4.7.6.rst @@ -30,6 +30,10 @@ Deprecations Bugs Fixed ********** +- **Security:** Fixed a bug where a valid JSON request body that is not an object + (for example a JSON array) was corrupted after CSRF verification when the + token was sent via the ``X-CSRF-TOKEN`` header. + See the repo's `CHANGELOG.md `_ for a complete list of bugs fixed. From 05e6ec8bdb5e4045c06e150aa46a560ce2578d5f Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 11 Oct 2026 21:34:28 +0200 Subject: [PATCH 2/2] refactor: optimize CSRF token removal in request body by restructuring JSON and form-encoded checks --- system/Security/Security.php | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/system/Security/Security.php b/system/Security/Security.php index 16b35ebd949a..583a26593976 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -293,33 +293,26 @@ private function removeTokenInRequest(IncomingRequest $request): void // If the token is found in JSON data, we can safely remove it. try { - $json = json_decode($body, flags: JSON_THROW_ON_ERROR); - $isJson = true; + $json = json_decode($body, flags: JSON_THROW_ON_ERROR); } catch (JsonException) { - $json = null; - $isJson = false; - } + // The body is not valid JSON - check for form-encoded data. + parse_str($body, $result); - if (is_object($json)) { - if (property_exists($json, $tokenName)) { - unset($json->{$tokenName}); - $request->setBody(json_encode($json)); + if (! array_key_exists($tokenName, $result)) { + return; } - return; - } + unset($result[$tokenName]); + $request->setBody(http_build_query($result)); - // A valid JSON body that is not an object cannot contain the CSRF token, - // so leave it untouched. - if ($isJson) { return; } - // If the token is found in form-encoded data, we can safely remove it. - parse_str($body, $result); - - unset($result[$tokenName]); - $request->setBody(http_build_query($result)); + // Valid JSON never falls through to form-encoded processing. + if (is_object($json) && property_exists($json, $tokenName)) { + unset($json->{$tokenName}); + $request->setBody(json_encode($json)); + } } private function getPostedToken(IncomingRequest $request): ?string