Skip to content

Commit bf9aaaf

Browse files
committed
src: json_encode() throws when UTF-8 key repair collides two keys
Malformed key bytes become � so json_encode() never fails, but two different bad keys can repair to the same text, and the rebuild kept whichever came last - silent data loss. A collision now throws naming the repaired key (HTML-encoded - keys are data, and handlers often echo exception messages into a page). One-bad-key repair is unchanged.
1 parent 0c2581e commit bf9aaaf

3 files changed

Lines changed: 58 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ Everything else is hardening and fixes.
2525
reflected XSS vector
2626
- **`json_encode()` survives malformed UTF-8 in keys** - bad bytes become
2727
� instead of the whole document returning false (values were already
28-
handled)
28+
handled). If the substitution would make two keys identical, it throws
29+
rather than silently dropping a record
2930
- **`debug()` and `help()` escape `</xmp`** - a stored value containing
3031
`</xmp>` ended the debug block early, so the rest of it parsed as live
3132
HTML

src/SmartArrayBase.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1838,9 +1838,11 @@ private function wrappingIterator(): Iterator
18381838
*
18391839
* Substitutes malformed UTF-8 in keys and values with � (U+FFFD) so json_encode($smartArray)
18401840
* returns valid JSON instead of false. Nested SmartArrays scrub themselves when json_encode()
1841-
* descends into them.
1841+
* descends into them. If substitution makes two keys identical, throws instead of silently
1842+
* dropping a record.
18421843
*
18431844
* @return array The internal data array.
1845+
* @throws RuntimeException If key substitution makes two keys identical
18441846
*/
18451847
public function jsonSerialize(): array
18461848
{
@@ -1867,6 +1869,10 @@ public function jsonSerialize(): array
18671869
if (is_string($value) && preg_match('//u', $value) !== 1) {
18681870
$value = json_decode(json_encode($value, JSON_INVALID_UTF8_SUBSTITUTE));
18691871
}
1872+
if (array_key_exists($key, $data)) { // original keys were unique, so a repeat means substitution collapsed two keys
1873+
$keyDisplay = self::htmlEncode((string) $key); // SECURITY: keys are data, and exception handlers often echo messages into a page
1874+
throw new RuntimeException("jsonSerialize(): key '$keyDisplay' appears twice after malformed UTF-8 bytes were replaced with \u{FFFD}, which would silently drop a record. Fix the key encoding before calling json_encode().");
1875+
}
18701876
$data[$key] = $value;
18711877
}
18721878
return $data;

tests/Unit/ConversionTest.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
namespace Itools\SmartArray\Tests\Unit;
55

66
use InvalidArgumentException;
7+
use RuntimeException;
78
use Itools\SmartArray\SmartArray;
89
use Itools\SmartArray\SmartArrayBase;
910
use Itools\SmartArray\SmartArrayHtml;
@@ -464,5 +465,53 @@ public function testJsonSerializeKeepsMalformedKeyInPosition(string $class): voi
464465
$this->assertSame(['a', "caf\u{FFFD}", 'z'], array_keys($sa->jsonSerialize()));
465466
}
466467

468+
#[DataProvider('modeProvider')]
469+
public function testJsonSerializeThrowsWhenScrubbedKeysCollide(string $class): void
470+
{
471+
// "caf\xE9" and "caf\xC0" both scrub to "caf�" - silently keeping only
472+
// one record would be data loss, so it throws instead
473+
$sa = $class::new(["caf\xE9" => 1, "caf\xC0" => 2]);
474+
475+
$this->expectException(RuntimeException::class);
476+
$this->expectExceptionMessage("key 'caf\u{FFFD}' appears twice after malformed UTF-8");
477+
478+
$sa->jsonSerialize();
479+
}
480+
481+
#[DataProvider('modeProvider')]
482+
public function testJsonSerializeThrowsWhenScrubbedKeyMatchesLiteralReplacementChar(string $class): void
483+
{
484+
// A malformed key can also collide with a valid key that already spells
485+
// U+FFFD; both insert orders throw
486+
$arrays = [
487+
["caf\xE9" => 1, "caf\u{FFFD}" => 2],
488+
["caf\u{FFFD}" => 1, "caf\xE9" => 2],
489+
];
490+
foreach ($arrays as $array) {
491+
$thrown = null;
492+
try {
493+
$class::new($array)->jsonSerialize();
494+
} catch (RuntimeException $e) {
495+
$thrown = $e;
496+
}
497+
$this->assertInstanceOf(RuntimeException::class, $thrown, 'collision must throw in both insert orders');
498+
}
499+
}
500+
501+
#[DataProvider('modeProvider')]
502+
public function testJsonSerializeCollisionMessageEncodesTheKey(string $class): void
503+
{
504+
// Keys are data, and exception handlers often echo messages into a page
505+
$sa = $class::new(["<b>\xE9" => 1, "<b>\xC0" => 2]);
506+
507+
try {
508+
$sa->jsonSerialize();
509+
$this->fail('Expected RuntimeException for colliding keys');
510+
} catch (RuntimeException $e) {
511+
$this->assertStringContainsString('&lt;b&gt;', $e->getMessage(), 'markup in the key must be encoded');
512+
$this->assertStringNotContainsString('<b>', $e->getMessage());
513+
}
514+
}
515+
467516
//endregion
468517
}

0 commit comments

Comments
 (0)