Skip to content

Commit 918efe9

Browse files
committed
fix: preserve JSON body formatting when removing CSRF token
1 parent 9c62323 commit 918efe9

4 files changed

Lines changed: 297 additions & 3 deletions

File tree

‎system/Security/Security.php‎

Lines changed: 194 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,7 @@ private function removeTokenInRequest(IncomingRequest $request): void
300300

301301
if (is_object($json)) {
302302
if (property_exists($json, $tokenName)) {
303-
unset($json->{$tokenName});
304-
$request->setBody(json_encode($json));
303+
$request->setBody($this->removeJsonMember($body, $tokenName));
305304
}
306305

307306
return;
@@ -314,6 +313,199 @@ private function removeTokenInRequest(IncomingRequest $request): void
314313
$request->setBody(http_build_query($result));
315314
}
316315

316+
/**
317+
* Removes a top-level member from a JSON object without re-encoding the
318+
* document, so the original formatting of the remaining data is preserved.
319+
*/
320+
private function removeJsonMember(string $json, string $member): string
321+
{
322+
$length = strlen($json);
323+
$pos = $this->skipJsonWhitespace($json, 0);
324+
325+
if ($pos >= $length || $json[$pos] !== '{') {
326+
return $json;
327+
}
328+
329+
$pos++;
330+
331+
$memberStart = null;
332+
$memberEnd = null;
333+
$commaAfter = null;
334+
335+
while ($pos < $length) {
336+
$pos = $this->skipJsonWhitespace($json, $pos);
337+
338+
if ($pos >= $length || $json[$pos] === '}') {
339+
break;
340+
}
341+
342+
if ($json[$pos] !== '"') {
343+
return $json;
344+
}
345+
346+
$keyStart = $pos;
347+
$pos = $this->skipJsonString($json, $pos);
348+
$key = json_decode(substr($json, $keyStart, $pos - $keyStart));
349+
350+
$pos = $this->skipJsonWhitespace($json, $pos);
351+
352+
if ($pos >= $length || $json[$pos] !== ':') {
353+
return $json;
354+
}
355+
356+
$pos = $this->skipJsonWhitespace($json, $pos + 1);
357+
$pos = $this->skipJsonValue($json, $pos);
358+
359+
if ($key === $member) {
360+
$memberStart = $keyStart;
361+
$memberEnd = $pos;
362+
363+
// Drop the comma and the whitespace that follows it when there
364+
// is a next member.
365+
$after = $this->skipJsonWhitespace($json, $pos);
366+
367+
if ($after < $length && $json[$after] === ',') {
368+
$commaAfter = $this->skipJsonWhitespace($json, $after + 1);
369+
}
370+
371+
break;
372+
}
373+
374+
// Skip the separator comma between members.
375+
$pos = $this->skipJsonWhitespace($json, $pos);
376+
377+
if ($pos < $length && $json[$pos] === ',') {
378+
$pos++;
379+
}
380+
}
381+
382+
if ($memberStart === null) {
383+
return $json;
384+
}
385+
386+
if ($commaAfter !== null) {
387+
// The member is not the last one.
388+
return substr($json, 0, $memberStart) . substr($json, $commaAfter);
389+
}
390+
391+
// The member is the last one: drop the preceding comma if there is one.
392+
$before = $memberStart - 1;
393+
394+
while ($before >= 0 && ctype_space($json[$before])) {
395+
$before--;
396+
}
397+
398+
if ($before >= 0 && $json[$before] === ',') {
399+
return substr($json, 0, $before) . substr($json, $memberEnd);
400+
}
401+
402+
// The member is the only one: keep the surrounding whitespace clean.
403+
$open = $memberStart;
404+
405+
while ($open > 0 && ctype_space($json[$open - 1])) {
406+
$open--;
407+
}
408+
409+
return substr($json, 0, $open) . substr($json, $memberEnd);
410+
}
411+
412+
/**
413+
* Returns the position just after the JSON string that starts at the given
414+
* position (which must point to the opening quote).
415+
*/
416+
private function skipJsonString(string $json, int $pos): int
417+
{
418+
$length = strlen($json);
419+
$pos++;
420+
421+
while ($pos < $length) {
422+
if ($json[$pos] === '\\') {
423+
$pos += 2;
424+
425+
continue;
426+
}
427+
428+
if ($json[$pos] === '"') {
429+
return $pos + 1;
430+
}
431+
432+
$pos++;
433+
}
434+
435+
return $pos;
436+
}
437+
438+
/**
439+
* Returns the position after the JSON value that starts at the given
440+
* position.
441+
*/
442+
private function skipJsonValue(string $json, int $pos): int
443+
{
444+
$length = strlen($json);
445+
446+
if ($pos >= $length) {
447+
return $pos;
448+
}
449+
450+
$char = $json[$pos];
451+
452+
if ($char === '"') {
453+
return $this->skipJsonString($json, $pos);
454+
}
455+
456+
if ($char !== '{' && $char !== '[') {
457+
// Number, true, false or null.
458+
while ($pos < $length && ! ctype_space($json[$pos]) && $json[$pos] !== ',' && $json[$pos] !== '}' && $json[$pos] !== ']') {
459+
$pos++;
460+
}
461+
462+
return $pos;
463+
}
464+
465+
$open = $char;
466+
$close = $char === '{' ? '}' : ']';
467+
$depth = 0;
468+
469+
while ($pos < $length) {
470+
$current = $json[$pos];
471+
472+
if ($current === '"') {
473+
$pos = $this->skipJsonString($json, $pos);
474+
475+
continue;
476+
}
477+
478+
if ($current === $open) {
479+
$depth++;
480+
} elseif ($current === $close) {
481+
$depth--;
482+
483+
if ($depth === 0) {
484+
return $pos + 1;
485+
}
486+
}
487+
488+
$pos++;
489+
}
490+
491+
return $pos;
492+
}
493+
494+
/**
495+
* Returns the position just after the whitespace starting at the given
496+
* position.
497+
*/
498+
private function skipJsonWhitespace(string $json, int $pos): int
499+
{
500+
$length = strlen($json);
501+
502+
while ($pos < $length && ctype_space($json[$pos])) {
503+
$pos++;
504+
}
505+
506+
return $pos;
507+
}
508+
317509
private function getPostedToken(IncomingRequest $request): ?string
318510
{
319511
$tokenName = $this->config->tokenName;

‎tests/system/Security/SecurityTest.php‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,104 @@ public function testCsrfVerifyHeaderWithJsonBodyStripsTokenFromBody(): void
237237
$this->assertSame('{"foo":"bar"}', $request->getBody());
238238
}
239239

240+
#[DataProvider('provideCsrfVerifyJsonBodyPreservesFormatting')]
241+
public function testCsrfVerifyJsonBodyPreservesFormatting(string $body, string $expected): void
242+
{
243+
service('superglobals')
244+
->setServer('REQUEST_METHOD', 'POST')
245+
->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH);
246+
247+
$security = $this->createMockSecurity();
248+
$request = $this->createIncomingRequest()->setBody($body);
249+
250+
$this->assertInstanceOf(Security::class, $security->verify($request));
251+
$this->assertSame($expected, $request->getBody());
252+
}
253+
254+
/**
255+
* @return iterable<string, array{string, string}>
256+
*/
257+
public static function provideCsrfVerifyJsonBodyPreservesFormatting(): iterable
258+
{
259+
$hash = self::CORRECT_CSRF_HASH;
260+
261+
yield 'pretty printed preserves indentation' => [
262+
"{\n \"csrf_test_name\": \"{$hash}\",\n \"foo\": \"bar\"\n}",
263+
"{\n \"foo\": \"bar\"\n}",
264+
];
265+
266+
yield 'compact token first' => [
267+
"{\"csrf_test_name\":\"{$hash}\",\"foo\":\"bar\"}",
268+
'{"foo":"bar"}',
269+
];
270+
271+
yield 'token last keeps preceding member intact' => [
272+
"{\"foo\": \"bar\", \"csrf_test_name\": \"{$hash}\"}",
273+
'{"foo": "bar"}',
274+
];
275+
276+
yield 'token only yields empty object' => [
277+
"{\"csrf_test_name\":\"{$hash}\"}",
278+
'{}',
279+
];
280+
281+
yield 'spaces around colon and comma preserved' => [
282+
"{ \"csrf_test_name\" : \"{$hash}\" , \"foo\" : \"bar\" }",
283+
'{ "foo" : "bar" }',
284+
];
285+
286+
yield 'unicode is not escaped' => [
287+
"{\"csrf_test_name\":\"{$hash}\",\"name\":\"café\"}",
288+
'{"name":"café"}',
289+
];
290+
291+
yield 'forward slashes are not escaped' => [
292+
"{\"csrf_test_name\":\"{$hash}\",\"url\":\"http://example.com/a/b\"}",
293+
'{"url":"http://example.com/a/b"}',
294+
];
295+
296+
yield 'number representation is preserved' => [
297+
"{\"csrf_test_name\":\"{$hash}\",\"price\":1.10,\"big\":12345678901234567890}",
298+
'{"price":1.10,"big":12345678901234567890}',
299+
];
300+
301+
yield 'nested member with same name is untouched' => [
302+
"{\"csrf_test_name\":\"{$hash}\",\"nested\":{\"csrf_test_name\":\"keep\"}}",
303+
'{"nested":{"csrf_test_name":"keep"}}',
304+
];
305+
306+
yield 'key name inside a string value is untouched' => [
307+
"{\"csrf_test_name\":\"{$hash}\",\"note\":\"csrf_test_name\"}",
308+
'{"note":"csrf_test_name"}',
309+
];
310+
311+
yield 'escaped quote inside a string value' => [
312+
"{\"csrf_test_name\":\"{$hash}\",\"note\":\"a\\\"b,c:1\"}",
313+
'{"note":"a\"b,c:1"}',
314+
];
315+
}
316+
317+
public function testCsrfVerifyHeaderWithPrettyJsonBodyStripsTokenPreservingFormatting(): void
318+
{
319+
service('superglobals')
320+
->setServer('REQUEST_METHOD', 'POST')
321+
->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH);
322+
323+
$security = $this->createMockSecurity();
324+
$request = $this->createIncomingRequest();
325+
326+
$request->setHeader('X-CSRF-TOKEN', self::CORRECT_CSRF_HASH);
327+
$request->setBody(
328+
"{\n \"csrf_test_name\": \"" . self::CORRECT_CSRF_HASH . "\",\n \"foo\": \"bar\"\n}",
329+
);
330+
331+
$this->assertInstanceOf(Security::class, $security->verify($request));
332+
$this->assertSame(
333+
"{\n \"foo\": \"bar\"\n}",
334+
$request->getBody(),
335+
);
336+
}
337+
240338
public function testCsrfVerifyPutBodyThrowsExceptionOnNoMatch(): void
241339
{
242340
service('superglobals')

‎user_guide_src/source/changelogs/v4.7.6.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ Deprecations
3030
Bugs Fixed
3131
**********
3232

33+
- **Security:** Fixed a bug where removing the CSRF token from a JSON request
34+
body re-encoded the whole document, losing the original formatting of the
35+
remaining data. The formatting is now preserved.
36+
3337
See the repo's
3438
`CHANGELOG.md <https://github.com/codeigniter4/CodeIgniter4/blob/develop/CHANGELOG.md>`_
3539
for a complete list of bugs fixed.

‎user_guide_src/source/libraries/security.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ The order of checking the availability of the CSRF token is as follows:
219219

220220
1. ``$_POST`` array
221221
2. HTTP header
222-
3. ``php://input`` (JSON request) - bear in mind that this approach is the slowest one since we have to decode JSON and then re-encode it
222+
3. ``php://input`` (JSON request) - bear in mind that this approach is the slowest one since we have to decode JSON
223223
4. ``php://input`` (raw body) - for PUT, PATCH, and DELETE type of requests
224224

225225
.. note:: ``php://input`` (raw body) is checked since v4.4.2.

0 commit comments

Comments
 (0)