From 0e4dfbf9746a2bd72fe6fe3c7f5fd54268d0550d Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Wed, 26 Aug 2026 10:35:25 +0200 Subject: [PATCH] fix(core): bound the compact serializers' segment split The compact JWS and JWE serializers split the whole input on every "." before checking the segment count, so a delimiter-heavy string was first expanded into one array entry per delimiter. That costs about 25 times the size of the input in memory: a 2 MB token allocates ~48 MB before it is rejected as malformed. The split is now bounded to one more segment than a valid token has, which is enough to detect and reject longer input while keeping the allocation proportional to the token itself. The accepted and rejected inputs are unchanged. Reported by Team Atlanta. --- .../Serializer/CompactSerializer.php | 8 ++- .../Serializer/CompactSerializer.php | 8 ++- .../Encryption/CompactSerializerTest.php | 52 +++++++++++++++++++ .../Signature/CompactSerializerTest.php | 52 +++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 tests/Component/Encryption/CompactSerializerTest.php create mode 100644 tests/Component/Signature/CompactSerializerTest.php diff --git a/src/Library/Encryption/Serializer/CompactSerializer.php b/src/Library/Encryption/Serializer/CompactSerializer.php index 026b2f916..e42b6c553 100644 --- a/src/Library/Encryption/Serializer/CompactSerializer.php +++ b/src/Library/Encryption/Serializer/CompactSerializer.php @@ -54,10 +54,16 @@ public function serialize(JWE $jwe, ?int $recipientIndex = null): string ); } + /** + * The split is bounded to six segments: a valid compact JWE has exactly five, so a sixth one is enough to + * detect and reject any longer input. Without that bound, a delimiter-heavy string would be expanded into one + * array entry per delimiter before the segment count is checked, which costs about twenty-five times the size + * of the input in memory. + */ #[Override] public function unserialize(string $input): JWE { - $parts = explode('.', $input); + $parts = explode('.', $input, 6); if (count($parts) !== 5) { throw new InvalidArgumentException('Unsupported input'); } diff --git a/src/Library/Signature/Serializer/CompactSerializer.php b/src/Library/Signature/Serializer/CompactSerializer.php index 4d1a46261..c7af874a9 100644 --- a/src/Library/Signature/Serializer/CompactSerializer.php +++ b/src/Library/Signature/Serializer/CompactSerializer.php @@ -58,10 +58,16 @@ public function serialize(JWS $jws, ?int $signatureIndex = null): string ); } + /** + * The split is bounded to four segments: a valid compact JWS has exactly three, so a fourth one is enough to + * detect and reject any longer input. Without that bound, a delimiter-heavy string would be expanded into one + * array entry per delimiter before the segment count is checked, which costs about twenty-five times the size + * of the input in memory. + */ #[Override] public function unserialize(string $input): JWS { - $parts = explode('.', $input); + $parts = explode('.', $input, 4); if (count($parts) !== 3) { throw new InvalidArgumentException('Unsupported input'); } diff --git a/tests/Component/Encryption/CompactSerializerTest.php b/tests/Component/Encryption/CompactSerializerTest.php new file mode 100644 index 000000000..e1461b5f0 --- /dev/null +++ b/tests/Component/Encryption/CompactSerializerTest.php @@ -0,0 +1,52 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported input'); + + (new CompactSerializer())->unserialize('eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4R0NNIn0.....'); + } + + /** + * A delimiter-heavy token must be rejected without expanding it into one array entry per delimiter. The + * threshold is deliberately generous: the unbounded split of this input allocates roughly twenty-five times its + * size, while the bounded one stays proportional to it. + */ + #[Test] + public function aDelimiterHeavyTokenIsRejectedWithoutExhaustingMemory(): void + { + $token = str_repeat('.', 2_000_000); + $serializer = new CompactSerializer(); + memory_reset_peak_usage(); + $before = memory_get_usage(); + + try { + $serializer->unserialize($token); + static::fail('The token should have been rejected.'); + } catch (InvalidArgumentException $e) { + static::assertSame('Unsupported input', $e->getMessage()); + } + + static::assertLessThan(8_000_000, memory_get_peak_usage() - $before); + } +} diff --git a/tests/Component/Signature/CompactSerializerTest.php b/tests/Component/Signature/CompactSerializerTest.php new file mode 100644 index 000000000..93221ee02 --- /dev/null +++ b/tests/Component/Signature/CompactSerializerTest.php @@ -0,0 +1,52 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported input'); + + (new CompactSerializer())->unserialize('eyJhbGciOiJub25lIn0...'); + } + + /** + * A delimiter-heavy token must be rejected without expanding it into one array entry per delimiter. The + * threshold is deliberately generous: the unbounded split of this input allocates roughly twenty-five times its + * size, while the bounded one stays proportional to it. + */ + #[Test] + public function aDelimiterHeavyTokenIsRejectedWithoutExhaustingMemory(): void + { + $token = str_repeat('.', 2_000_000); + $serializer = new CompactSerializer(); + memory_reset_peak_usage(); + $before = memory_get_usage(); + + try { + $serializer->unserialize($token); + static::fail('The token should have been rejected.'); + } catch (InvalidArgumentException $e) { + static::assertSame('Unsupported input', $e->getMessage()); + } + + static::assertLessThan(8_000_000, memory_get_peak_usage() - $before); + } +}