From de792246fb2d73930d6fd1202a6661ec9aa8ed12 Mon Sep 17 00:00:00 2001 From: michalsn Date: Mon, 23 Mar 2026 08:08:24 +0100 Subject: [PATCH 1/3] fix: preserve JSON body when CSRF token is sent in header --- system/Security/Security.php | 5 +++ .../Security/SecurityCSRFSessionTest.php | 31 +++++++++++++++++ tests/system/Security/SecurityTest.php | 33 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/system/Security/Security.php b/system/Security/Security.php index 873fee7469a8..5ab94fa187cb 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -307,6 +307,11 @@ private function removeTokenInRequest(IncomingRequest $request): void // If the token is found in form-encoded data, we can safely remove it. parse_str($body, $result); + + if (! array_key_exists($tokenName, $result)) { + return; + } + unset($result[$tokenName]); $request->setBody(http_build_query($result)); } diff --git a/tests/system/Security/SecurityCSRFSessionTest.php b/tests/system/Security/SecurityCSRFSessionTest.php index 60eb30aa9931..5c7aaf336e1f 100644 --- a/tests/system/Security/SecurityCSRFSessionTest.php +++ b/tests/system/Security/SecurityCSRFSessionTest.php @@ -251,6 +251,37 @@ public function testCSRFVerifyJsonReturnsSelfOnMatch(): void $this->assertSame('{"foo":"bar"}', $request->getBody()); } + public function testCSRFVerifyHeaderWithJsonBodyPreservesBody(): void + { + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + + $request = $this->createIncomingRequest(); + $body = '{"foo":"bar"}'; + + $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); + $request->setBody($body); + $security = $this->createSecurity(); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + $this->assertSame($body, $request->getBody()); + } + + public function testCSRFVerifyHeaderWithJsonBodyStripsTokenFromBody(): void + { + service('superglobals')->setServer('REQUEST_METHOD', 'POST'); + + $request = $this->createIncomingRequest(); + + $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); + $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005a","foo":"bar"}'); + $security = $this->createSecurity(); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertLogged('info', 'CSRF token verified.'); + $this->assertSame('{"foo":"bar"}', $request->getBody()); + } + public function testRegenerateWithFalseSecurityRegenerateProperty(): void { service('superglobals') diff --git a/tests/system/Security/SecurityTest.php b/tests/system/Security/SecurityTest.php index 90f2139b2ccd..932dfc0df2c0 100644 --- a/tests/system/Security/SecurityTest.php +++ b/tests/system/Security/SecurityTest.php @@ -204,6 +204,39 @@ public function testCsrfVerifyJsonReturnsSelfOnMatch(): void $this->assertSame('{"foo":"bar"}', $request->getBody()); } + public function testCsrfVerifyHeaderWithJsonBodyPreservesBody(): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest(); + $body = '{"foo":"bar"}'; + + $request->setHeader('X-CSRF-TOKEN', self::CORRECT_CSRF_HASH); + $request->setBody($body); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertSame($body, $request->getBody()); + } + + public function testCsrfVerifyHeaderWithJsonBodyStripsTokenFromBody(): 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('{"csrf_test_name":"' . self::CORRECT_CSRF_HASH . '","foo":"bar"}'); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertSame('{"foo":"bar"}', $request->getBody()); + } + public function testCsrfVerifyPutBodyThrowsExceptionOnNoMatch(): void { service('superglobals') From cfb4ad3a0cb324549d15fb22a57fe3bcad0d9fa8 Mon Sep 17 00:00:00 2001 From: michalsn Date: Mon, 23 Mar 2026 08:14:59 +0100 Subject: [PATCH 2/3] add changelog --- user_guide_src/source/changelogs/v4.7.2.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/user_guide_src/source/changelogs/v4.7.2.rst b/user_guide_src/source/changelogs/v4.7.2.rst index 653aa825c2a0..d38dbdc8c183 100644 --- a/user_guide_src/source/changelogs/v4.7.2.rst +++ b/user_guide_src/source/changelogs/v4.7.2.rst @@ -30,6 +30,8 @@ Deprecations Bugs Fixed ********** +- **Security:** Fixed a bug where the CSRF filter could corrupt JSON request bodies after successful verification when the CSRF token was provided via the ``X-CSRF-TOKEN`` header. This caused ``IncomingRequest::getJSON()`` to fail on valid ``application/json`` requests. + See the repo's `CHANGELOG.md `_ for a complete list of bugs fixed. From 64f947a3399935f5150a4c5b05b77a5cfc271f62 Mon Sep 17 00:00:00 2001 From: michalsn Date: Mon, 23 Mar 2026 10:44:41 +0100 Subject: [PATCH 3/3] apply suggestions --- system/Security/Security.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/system/Security/Security.php b/system/Security/Security.php index 5ab94fa187cb..4ac0de3f8ff8 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -298,9 +298,11 @@ private function removeTokenInRequest(IncomingRequest $request): void $json = null; } - if (is_object($json) && property_exists($json, $tokenName)) { - unset($json->{$tokenName}); - $request->setBody(json_encode($json)); + if (is_object($json)) { + if (property_exists($json, $tokenName)) { + unset($json->{$tokenName}); + $request->setBody(json_encode($json)); + } return; } @@ -308,10 +310,6 @@ private function removeTokenInRequest(IncomingRequest $request): void // If the token is found in form-encoded data, we can safely remove it. parse_str($body, $result); - if (! array_key_exists($tokenName, $result)) { - return; - } - unset($result[$tokenName]); $request->setBody(http_build_query($result)); }