TYPO3 CMS Allows Insecure Deserialization via Mailer File Spool
Description
TYPO3's mail‑file spool deserialization flaw lets local users with write access to the spool directory craft a malicious file that is deserialized during the mailer:spool:send command, enabling arbitrary PHP code execution on the web server. This issue affects TYPO3 CMS versions 10.0.0-10.4.54, 11.0.0-11.5.48, 12.0.0-12.4.40, 13.0.0-13.4.22 and 14.0.0-14.0.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
typo3/cms-corePackagist | >= 14.0.0, < 14.0.2 | 14.0.2 |
typo3/cms-corePackagist | >= 13.0.0, < 13.4.23 | 13.4.23 |
typo3/cms-corePackagist | >= 12.0.0, < 12.4.41 | 12.4.41 |
typo3/cms-corePackagist | >= 11.0.0, < 11.5.49 | 11.5.49 |
typo3/cms-corePackagist | >= 10.0.0, < 10.4.55 | 10.4.55 |
Affected products
1Patches
33225d705080a[SECURITY] Harden message deserialization in `FileSpool` transport
6 files changed · +357 −19
typo3/sysext/core/Classes/Mail/FileSpool.php+39 −18 modified@@ -18,16 +18,17 @@ namespace TYPO3\CMS\Core\Mail; use Psr\Log\LoggerInterface; -use Symfony\Component\Mailer\DelayedEnvelope; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractTransport; use Symfony\Component\Mailer\Transport\TransportInterface; -use Symfony\Component\Mime\Email; -use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Header\HeaderInterface; +use Symfony\Component\Mime\Header\Headers; use Symfony\Component\Mime\RawMessage; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use TYPO3\CMS\Core\Serializer\PolymorphicDeserializer; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -58,7 +59,8 @@ class FileSpool extends AbstractTransport implements DelayedTransportInterface public function __construct( protected string $path, ?EventDispatcherInterface $dispatcher = null, - protected readonly ?LoggerInterface $logger = null + protected readonly ?LoggerInterface $logger = null, + protected readonly PolymorphicDeserializer $deserializer = new PolymorphicDeserializer(), ) { parent::__construct($dispatcher, $logger); @@ -140,20 +142,39 @@ public function flushQueue(TransportInterface $transport): int /* We try a rename, it's an atomic operation, and avoid locking the file */ if (rename($file, $file . '.sending')) { - $message = unserialize((string)file_get_contents($file . '.sending'), [ - 'allowedClasses' => [ - RawMessage::class, - Message::class, - Email::class, - DelayedEnvelope::class, - Envelope::class, - ], - ]); - - $transport->send($message->getMessage(), $message->getEnvelope()); - $count++; - - unlink($file . '.sending'); + try { + $message = $this->deserializer->deserialize( + (string)file_get_contents($file . '.sending'), + [ + SentMessage::class, + RawMessage::class, + Envelope::class, + Address::class, + Headers::class, + HeaderInterface::class, + ] + ); + + if ($message instanceof SentMessage) { + $transport->send($message->getMessage(), $message->getEnvelope()); + $count++; + } else { + $this->logger?->error( + 'Serialized message from {fileName} was rejected, because {className} is not an instance of SentMessage.', + [ + 'fileName' => $file, + 'className' => get_debug_type($message), + ], + ); + } + } catch (\Throwable) { + $this->logger?->error( + 'Serialized message from {fileName} was rejected, because it contains a disallowed class object.', + ['fileName' => $file], + ); + } finally { + unlink($file . '.sending'); + } } else { /* This message has just been caught by another process */ continue;
typo3/sysext/core/Classes/Serializer/Exception/PolymorphicDeserializerException.php+27 −0 added@@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer\Exception; + +use TYPO3\CMS\Core\Exception; + +/** + * An exception if de-serializing an object failed + * + * @internal + */ +class PolymorphicDeserializerException extends Exception {}
typo3/sysext/core/Classes/Serializer/PolymorphicDeserializer.php+118 −0 added@@ -0,0 +1,118 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use TYPO3\CMS\Core\Serializer\Exception\PolymorphicDeserializerException; + +/** + * @internal Only to be used by TYPO3 core + */ +final readonly class PolymorphicDeserializer +{ + /** + * Validates the serialized payload by checking a static list of base classes or interfaces to be included in the + * de-serialized output. If a non-allowed class is hit, the method throws an PolymorphicDeserializerException. + * If the serialized payload is syntactically incorrect, PolymorphicDeserializerException is thrown as well. + * + * @param list<class-string> $allowedClasses + * @throws PolymorphicDeserializerException + */ + public function deserialize(string $payload, array $allowedClasses): mixed + { + // When allowing inheritance, extract all class names from payload and validate them + $classNames = $this->parseClassNames($payload); + + foreach ($classNames as $className) { + if (!$this->isInstanceOf($className, $allowedClasses)) { + throw new PolymorphicDeserializerException('Invalid class name "' . $className . '" found in payload', 1767987405); + } + + // Add the class if it's a valid subclass of any allowed class + if (!in_array($className, $allowedClasses, true)) { + $allowedClasses[] = $className; + } + } + + $result = @unserialize($payload, ['allowed_classes' => $allowedClasses]); + if ($result === false) { + if ($payload === serialize(false)) { + // Do not throw an exception in case the serialized string is *actually* false + // See https://www.php.net/manual/en/function.unserialize.php#refsect1-function.unserialize-notes + return false; + } + $exceptionMessage = 'Syntax error in payload, unable to de-serialize'; + $lastError = error_get_last(); + if ($lastError !== null) { + $exceptionMessage .= ': ' . $lastError['message']; + } + throw new PolymorphicDeserializerException($exceptionMessage, 1768212616); + } + + return $result; + } + + public function parseClassNames(string $payload): array + { + $classNames = []; + if (preg_match_all('/[CO]:(?P<length>\d+):"(?P<className>[^"]+)"/', $payload, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches['className'] as $i => $classNameMatch) { + $className = $classNameMatch[0]; + // Offset of the full O:... pattern + $matchOffset = (int)$matches[0][$i][1]; + $declaredLength = (int)$matches['length'][$i][0]; + + // Validate: 1) length matches, 2) not inside a string value + if (strlen($className) === $declaredLength && !$this->isInsideString($payload, $matchOffset)) { + $classNames[] = $className; + } + } + } + return $classNames; + } + + private function isInsideString(string $payload, int $offset): bool + { + if (preg_match_all('/s:(\d+):"/', $payload, $stringMatches, PREG_OFFSET_CAPTURE)) { + foreach ($stringMatches[0] as $i => $match) { + $stringDefOffset = $match[1]; + $stringLength = (int)$stringMatches[1][$i][0]; + // String content starts after s:LENGTH:" + $contentStart = $stringDefOffset + strlen($match[0]); + $contentEnd = $contentStart + $stringLength; + + if ($offset >= $contentStart && $offset < $contentEnd) { + return true; + } + } + } + return false; + } + + /** + * @param list<class-string> $allowedClassNames + */ + private function isInstanceOf(string $className, array $allowedClassNames): bool + { + foreach ($allowedClassNames as $allowedClassName) { + if (is_a($className, $allowedClassName, true) || is_subclass_of($className, $allowedClassName)) { + return true; + } + } + return false; + } +}
typo3/sysext/core/Tests/Functional/Serializer/Fixtures/BoE4HIpmXv.message+0 −0 addedtypo3/sysext/core/Tests/Functional/Serializer/PolymorphicDeserializerTest.php+124 −0 added@@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Serializer; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Header\HeaderInterface; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Component\Mime\RawMessage; +use TYPO3\CMS\Core\Serializer\Exception\PolymorphicDeserializerException; +use TYPO3\CMS\Core\Serializer\PolymorphicDeserializer; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class PolymorphicDeserializerTest extends FunctionalTestCase +{ + protected bool $initializeDatabase = false; + + private PolymorphicDeserializer $subject; + + protected function setUp(): void + { + parent::setUp(); + $this->subject = new PolymorphicDeserializer(); + } + + #[Test] + public function spooledMailMessageCanBeDeserialized(): void + { + $payload = file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'); + $result = $this->subject->deserialize($payload, [ + SentMessage::class, + RawMessage::class, + Envelope::class, + Address::class, + Headers::class, + HeaderInterface::class, + ]); + self::assertInstanceOf(SentMessage::class, $result); + } + + public static function spooledMailMessageCannotBeDeserializedDataProvider(): \Generator + { + yield [ + file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'), + 'Invalid class name "TYPO3\CMS\Core\Mail\FluidEmail" found in payload', + 1767987405, + ]; + yield [ + 's:foo:broken', + 'Syntax error in payload, unable to de-serialize: unserialize(): Error at offset 0 of 12 bytes', + 1768212616, + ]; + } + + #[Test] + #[DataProvider('spooledMailMessageCannotBeDeserializedDataProvider')] + public function spooledMailMessageCannotBeDeserialized(string $payload, string $expectedExceptionMessage, int $expectedExceptionCode): void + { + $this->expectException(PolymorphicDeserializerException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + $this->expectExceptionCode($expectedExceptionCode); + + $result = $this->subject->deserialize($payload, [SentMessage::class]); + self::assertInstanceOf(SentMessage::class, $result); + } + + public static function canParseClassNamesDataProvider(): iterable + { + yield 'simple example' => [ + 'a:2:{i:0;O:10:"ValidClass":0:{}i:1;s:21:" O:12:"InvalidClass":0:{} ";i:2;O:333:"IncorrectLengthClass":0:{}}', + ['ValidClass'], + ]; + yield 'serialized mail message' => [ + file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'), + [ + \Symfony\Component\Mailer\SentMessage::class, + \TYPO3\CMS\Core\Mail\FluidEmail::class, + \Symfony\Component\Mime\Header\Headers::class, + \Symfony\Component\Mime\Header\MailboxListHeader::class, + \Symfony\Component\Mime\Address::class, + \Symfony\Component\Mime\Header\MailboxListHeader::class, + \Symfony\Component\Mime\Address::class, + \Symfony\Component\Mime\Header\UnstructuredHeader::class, + \Symfony\Component\Mime\Header\UnstructuredHeader::class, + \Symfony\Component\Mime\RawMessage::class, + \Symfony\Component\Mailer\DelayedEnvelope::class, + ], + ]; + } + + #[Test] + #[DataProvider('canParseClassNamesDataProvider')] + public function canParseClassNames(string $payload, array $expectedClassNames): void + { + self::assertEquals($expectedClassNames, $this->subject->parseClassNames($payload)); + } + + #[Test] + public function falseValueCanBeDeserialized(): void + { + $payload = 'b:0;'; + $result = $this->subject->deserialize($payload, []); + + self::assertFalse($result); + } +}
typo3/sysext/core/Tests/Unit/Mail/FileSpoolTest.php+49 −1 modified@@ -19,6 +19,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Transport\NullTransport; use Symfony\Component\Mime\Address; @@ -31,12 +33,17 @@ final class FileSpoolTest extends UnitTestCase { protected bool $resetSingletonInstances = true; + private string $spoolPath; + private LoggerInterface&MockObject $loggerMock; private FileSpool $subject; protected function setUp(): void { parent::setUp(); - $this->subject = new FileSpool(Environment::getVarPath() . '/spool/'); + + $this->spoolPath = Environment::getVarPath() . '/spool/'; + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->subject = new FileSpool($this->spoolPath, null, $this->loggerMock); $this->subject->setMessageLimit(10); $this->subject->setTimeLimit(1); } @@ -55,6 +62,47 @@ public function spoolsMessagesCorrectly(int $count): void self::assertEquals($count, $this->subject->flushQueue(new NullTransport())); } + #[Test] + public function flushQueueSkipsDisallowedSerializedMessages(): void + { + $disallowedMessage = $this->spoolPath . 'disallowed.message'; + $disallowedMessageSending = $this->spoolPath . 'invalid.message.sending'; + + file_put_contents($disallowedMessage, serialize(new \stdClass())); + + $this->loggerMock->expects($this->once())->method('error')->with( + 'Serialized message from {fileName} was rejected, because it contains a disallowed class object.', + ['fileName' => $disallowedMessage], + ); + + self::assertFileExists($disallowedMessage); + self::assertSame(0, $this->subject->flushQueue(new NullTransport())); + self::assertFileDoesNotExist($disallowedMessage); + self::assertFileDoesNotExist($disallowedMessageSending); + } + + #[Test] + public function flushQueueSkipsUnsupportedSerializedMessages(): void + { + $invalidMessage = $this->spoolPath . 'invalid.message'; + $invalidMessageSending = $this->spoolPath . 'invalid.message.sending'; + + file_put_contents($invalidMessage, serialize(new RawMessage('Hello World'))); + + $this->loggerMock->expects($this->once())->method('error')->with( + 'Serialized message from {fileName} was rejected, because {className} is not an instance of SentMessage.', + [ + 'fileName' => $invalidMessage, + 'className' => RawMessage::class, + ], + ); + + self::assertFileExists($invalidMessage); + self::assertSame(0, $this->subject->flushQueue(new NullTransport())); + self::assertFileDoesNotExist($invalidMessage); + self::assertFileDoesNotExist($invalidMessageSending); + } + /** * Data provider for message spooling test *
e0f0ceee480c[SECURITY] Harden message deserialization in `FileSpool` transport
6 files changed · +358 −20
typo3/sysext/core/Classes/Mail/FileSpool.php+39 −18 modified@@ -18,16 +18,17 @@ namespace TYPO3\CMS\Core\Mail; use Psr\Log\LoggerInterface; -use Symfony\Component\Mailer\DelayedEnvelope; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractTransport; use Symfony\Component\Mailer\Transport\TransportInterface; -use Symfony\Component\Mime\Email; -use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Header\HeaderInterface; +use Symfony\Component\Mime\Header\Headers; use Symfony\Component\Mime\RawMessage; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use TYPO3\CMS\Core\Serializer\PolymorphicDeserializer; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -58,7 +59,8 @@ class FileSpool extends AbstractTransport implements DelayedTransportInterface public function __construct( protected string $path, ?EventDispatcherInterface $dispatcher = null, - protected readonly ?LoggerInterface $logger = null + protected readonly ?LoggerInterface $logger = null, + protected readonly PolymorphicDeserializer $deserializer = new PolymorphicDeserializer(), ) { parent::__construct($dispatcher, $logger); @@ -140,20 +142,39 @@ public function flushQueue(TransportInterface $transport): int /* We try a rename, it's an atomic operation, and avoid locking the file */ if (rename($file, $file . '.sending')) { - $message = unserialize((string)file_get_contents($file . '.sending'), [ - 'allowedClasses' => [ - RawMessage::class, - Message::class, - Email::class, - DelayedEnvelope::class, - Envelope::class, - ], - ]); - - $transport->send($message->getMessage(), $message->getEnvelope()); - $count++; - - unlink($file . '.sending'); + try { + $message = $this->deserializer->deserialize( + (string)file_get_contents($file . '.sending'), + [ + SentMessage::class, + RawMessage::class, + Envelope::class, + Address::class, + Headers::class, + HeaderInterface::class, + ] + ); + + if ($message instanceof SentMessage) { + $transport->send($message->getMessage(), $message->getEnvelope()); + $count++; + } else { + $this->logger?->error( + 'Serialized message from {fileName} was rejected, because {className} is not an instance of SentMessage.', + [ + 'fileName' => $file, + 'className' => get_debug_type($message), + ], + ); + } + } catch (\Throwable) { + $this->logger?->error( + 'Serialized message from {fileName} was rejected, because it contains a disallowed class object.', + ['fileName' => $file], + ); + } finally { + unlink($file . '.sending'); + } } else { /* This message has just been caught by another process */ continue;
typo3/sysext/core/Classes/Serializer/Exception/PolymorphicDeserializerException.php+27 −0 added@@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer\Exception; + +use TYPO3\CMS\Core\Exception; + +/** + * An exception if de-serializing an object failed + * + * @internal + */ +class PolymorphicDeserializerException extends Exception {}
typo3/sysext/core/Classes/Serializer/PolymorphicDeserializer.php+118 −0 added@@ -0,0 +1,118 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use TYPO3\CMS\Core\Serializer\Exception\PolymorphicDeserializerException; + +/** + * @internal Only to be used by TYPO3 core + */ +final readonly class PolymorphicDeserializer +{ + /** + * Validates the serialized payload by checking a static list of base classes or interfaces to be included in the + * de-serialized output. If a non-allowed class is hit, the method throws an PolymorphicDeserializerException. + * If the serialized payload is syntactically incorrect, PolymorphicDeserializerException is thrown as well. + * + * @param list<class-string> $allowedClasses + * @throws PolymorphicDeserializerException + */ + public function deserialize(string $payload, array $allowedClasses): mixed + { + // When allowing inheritance, extract all class names from payload and validate them + $classNames = $this->parseClassNames($payload); + + foreach ($classNames as $className) { + if (!$this->isInstanceOf($className, $allowedClasses)) { + throw new PolymorphicDeserializerException('Invalid class name "' . $className . '" found in payload', 1767987405); + } + + // Add the class if it's a valid subclass of any allowed class + if (!in_array($className, $allowedClasses, true)) { + $allowedClasses[] = $className; + } + } + + $result = @unserialize($payload, ['allowed_classes' => $allowedClasses]); + if ($result === false) { + if ($payload === serialize(false)) { + // Do not throw an exception in case the serialized string is *actually* false + // See https://www.php.net/manual/en/function.unserialize.php#refsect1-function.unserialize-notes + return false; + } + $exceptionMessage = 'Syntax error in payload, unable to de-serialize'; + $lastError = error_get_last(); + if ($lastError !== null) { + $exceptionMessage .= ': ' . $lastError['message']; + } + throw new PolymorphicDeserializerException($exceptionMessage, 1768212616); + } + + return $result; + } + + public function parseClassNames(string $payload): array + { + $classNames = []; + if (preg_match_all('/[CO]:(?P<length>\d+):"(?P<className>[^"]+)"/', $payload, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches['className'] as $i => $classNameMatch) { + $className = $classNameMatch[0]; + // Offset of the full O:... pattern + $matchOffset = (int)$matches[0][$i][1]; + $declaredLength = (int)$matches['length'][$i][0]; + + // Validate: 1) length matches, 2) not inside a string value + if (strlen($className) === $declaredLength && !$this->isInsideString($payload, $matchOffset)) { + $classNames[] = $className; + } + } + } + return $classNames; + } + + private function isInsideString(string $payload, int $offset): bool + { + if (preg_match_all('/s:(\d+):"/', $payload, $stringMatches, PREG_OFFSET_CAPTURE)) { + foreach ($stringMatches[0] as $i => $match) { + $stringDefOffset = $match[1]; + $stringLength = (int)$stringMatches[1][$i][0]; + // String content starts after s:LENGTH:" + $contentStart = $stringDefOffset + strlen($match[0]); + $contentEnd = $contentStart + $stringLength; + + if ($offset >= $contentStart && $offset < $contentEnd) { + return true; + } + } + } + return false; + } + + /** + * @param list<class-string> $allowedClassNames + */ + private function isInstanceOf(string $className, array $allowedClassNames): bool + { + foreach ($allowedClassNames as $allowedClassName) { + if (is_a($className, $allowedClassName, true) || is_subclass_of($className, $allowedClassName)) { + return true; + } + } + return false; + } +}
typo3/sysext/core/Tests/Functional/Serializer/Fixtures/BoE4HIpmXv.message+0 −0 addedtypo3/sysext/core/Tests/Functional/Serializer/PolymorphicDeserializerTest.php+124 −0 added@@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Serializer; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Header\HeaderInterface; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Component\Mime\RawMessage; +use TYPO3\CMS\Core\Serializer\Exception\PolymorphicDeserializerException; +use TYPO3\CMS\Core\Serializer\PolymorphicDeserializer; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class PolymorphicDeserializerTest extends FunctionalTestCase +{ + protected bool $initializeDatabase = false; + + private PolymorphicDeserializer $subject; + + protected function setUp(): void + { + parent::setUp(); + $this->subject = new PolymorphicDeserializer(); + } + + #[Test] + public function spooledMailMessageCanBeDeserialized(): void + { + $payload = file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'); + $result = $this->subject->deserialize($payload, [ + SentMessage::class, + RawMessage::class, + Envelope::class, + Address::class, + Headers::class, + HeaderInterface::class, + ]); + self::assertInstanceOf(SentMessage::class, $result); + } + + public static function spooledMailMessageCannotBeDeserializedDataProvider(): \Generator + { + yield [ + file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'), + 'Invalid class name "TYPO3\CMS\Core\Mail\FluidEmail" found in payload', + 1767987405, + ]; + yield [ + 's:foo:broken', + 'Syntax error in payload, unable to de-serialize: unserialize(): Error at offset 0 of 12 bytes', + 1768212616, + ]; + } + + #[Test] + #[DataProvider('spooledMailMessageCannotBeDeserializedDataProvider')] + public function spooledMailMessageCannotBeDeserialized(string $payload, string $expectedExceptionMessage, int $expectedExceptionCode): void + { + $this->expectException(PolymorphicDeserializerException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + $this->expectExceptionCode($expectedExceptionCode); + + $result = $this->subject->deserialize($payload, [SentMessage::class]); + self::assertInstanceOf(SentMessage::class, $result); + } + + public static function canParseClassNamesDataProvider(): iterable + { + yield 'simple example' => [ + 'a:2:{i:0;O:10:"ValidClass":0:{}i:1;s:21:" O:12:"InvalidClass":0:{} ";i:2;O:333:"IncorrectLengthClass":0:{}}', + ['ValidClass'], + ]; + yield 'serialized mail message' => [ + file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'), + [ + \Symfony\Component\Mailer\SentMessage::class, + \TYPO3\CMS\Core\Mail\FluidEmail::class, + \Symfony\Component\Mime\Header\Headers::class, + \Symfony\Component\Mime\Header\MailboxListHeader::class, + \Symfony\Component\Mime\Address::class, + \Symfony\Component\Mime\Header\MailboxListHeader::class, + \Symfony\Component\Mime\Address::class, + \Symfony\Component\Mime\Header\UnstructuredHeader::class, + \Symfony\Component\Mime\Header\UnstructuredHeader::class, + \Symfony\Component\Mime\RawMessage::class, + \Symfony\Component\Mailer\DelayedEnvelope::class, + ], + ]; + } + + #[Test] + #[DataProvider('canParseClassNamesDataProvider')] + public function canParseClassNames(string $payload, array $expectedClassNames): void + { + self::assertEquals($expectedClassNames, $this->subject->parseClassNames($payload)); + } + + #[Test] + public function falseValueCanBeDeserialized(): void + { + $payload = 'b:0;'; + $result = $this->subject->deserialize($payload, []); + + self::assertFalse($result); + } +}
typo3/sysext/core/Tests/Unit/Mail/FileSpoolTest.php+50 −2 modified@@ -19,6 +19,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Transport\NullTransport; use Symfony\Component\Mime\Address; @@ -31,12 +33,17 @@ final class FileSpoolTest extends UnitTestCase { protected bool $resetSingletonInstances = true; - protected ?FileSpool $subject; + private string $spoolPath; + private LoggerInterface&MockObject $loggerMock; + private FileSpool $subject; protected function setUp(): void { parent::setUp(); - $this->subject = new FileSpool(Environment::getVarPath() . '/spool/'); + + $this->spoolPath = Environment::getVarPath() . '/spool/'; + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->subject = new FileSpool($this->spoolPath, null, $this->loggerMock); $this->subject->setMessageLimit(10); $this->subject->setTimeLimit(1); } @@ -55,6 +62,47 @@ public function spoolsMessagesCorrectly(int $count): void self::assertEquals($count, $this->subject->flushQueue(new NullTransport())); } + #[Test] + public function flushQueueSkipsDisallowedSerializedMessages(): void + { + $disallowedMessage = $this->spoolPath . 'disallowed.message'; + $disallowedMessageSending = $this->spoolPath . 'invalid.message.sending'; + + file_put_contents($disallowedMessage, serialize(new \stdClass())); + + $this->loggerMock->expects($this->once())->method('error')->with( + 'Serialized message from {fileName} was rejected, because it contains a disallowed class object.', + ['fileName' => $disallowedMessage], + ); + + self::assertFileExists($disallowedMessage); + self::assertSame(0, $this->subject->flushQueue(new NullTransport())); + self::assertFileDoesNotExist($disallowedMessage); + self::assertFileDoesNotExist($disallowedMessageSending); + } + + #[Test] + public function flushQueueSkipsUnsupportedSerializedMessages(): void + { + $invalidMessage = $this->spoolPath . 'invalid.message'; + $invalidMessageSending = $this->spoolPath . 'invalid.message.sending'; + + file_put_contents($invalidMessage, serialize(new RawMessage('Hello World'))); + + $this->loggerMock->expects($this->once())->method('error')->with( + 'Serialized message from {fileName} was rejected, because {className} is not an instance of SentMessage.', + [ + 'fileName' => $invalidMessage, + 'className' => RawMessage::class, + ], + ); + + self::assertFileExists($invalidMessage); + self::assertSame(0, $this->subject->flushQueue(new NullTransport())); + self::assertFileDoesNotExist($invalidMessage); + self::assertFileDoesNotExist($invalidMessageSending); + } + /** * Data provider for message spooling test *
722bf71c118b[SECURITY] Harden message deserialization in `FileSpool` transport
6 files changed · +358 −20
typo3/sysext/core/Classes/Mail/FileSpool.php+39 −18 modified@@ -18,16 +18,17 @@ namespace TYPO3\CMS\Core\Mail; use Psr\Log\LoggerInterface; -use Symfony\Component\Mailer\DelayedEnvelope; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractTransport; use Symfony\Component\Mailer\Transport\TransportInterface; -use Symfony\Component\Mime\Email; -use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Header\HeaderInterface; +use Symfony\Component\Mime\Header\Headers; use Symfony\Component\Mime\RawMessage; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use TYPO3\CMS\Core\Serializer\PolymorphicDeserializer; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -58,7 +59,8 @@ class FileSpool extends AbstractTransport implements DelayedTransportInterface public function __construct( protected string $path, ?EventDispatcherInterface $dispatcher = null, - protected readonly ?LoggerInterface $logger = null + protected readonly ?LoggerInterface $logger = null, + protected readonly PolymorphicDeserializer $deserializer = new PolymorphicDeserializer(), ) { parent::__construct($dispatcher, $logger); @@ -140,20 +142,39 @@ public function flushQueue(TransportInterface $transport): int /* We try a rename, it's an atomic operation, and avoid locking the file */ if (rename($file, $file . '.sending')) { - $message = unserialize((string)file_get_contents($file . '.sending'), [ - 'allowedClasses' => [ - RawMessage::class, - Message::class, - Email::class, - DelayedEnvelope::class, - Envelope::class, - ], - ]); - - $transport->send($message->getMessage(), $message->getEnvelope()); - $count++; - - unlink($file . '.sending'); + try { + $message = $this->deserializer->deserialize( + (string)file_get_contents($file . '.sending'), + [ + SentMessage::class, + RawMessage::class, + Envelope::class, + Address::class, + Headers::class, + HeaderInterface::class, + ] + ); + + if ($message instanceof SentMessage) { + $transport->send($message->getMessage(), $message->getEnvelope()); + $count++; + } else { + $this->logger?->error( + 'Serialized message from {fileName} was rejected, because {className} is not an instance of SentMessage.', + [ + 'fileName' => $file, + 'className' => get_debug_type($message), + ], + ); + } + } catch (\Throwable) { + $this->logger?->error( + 'Serialized message from {fileName} was rejected, because it contains a disallowed class object.', + ['fileName' => $file], + ); + } finally { + unlink($file . '.sending'); + } } else { /* This message has just been caught by another process */ continue;
typo3/sysext/core/Classes/Serializer/Exception/PolymorphicDeserializerException.php+27 −0 added@@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer\Exception; + +use TYPO3\CMS\Core\Exception; + +/** + * An exception if de-serializing an object failed + * + * @internal + */ +class PolymorphicDeserializerException extends Exception {}
typo3/sysext/core/Classes/Serializer/PolymorphicDeserializer.php+118 −0 added@@ -0,0 +1,118 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use TYPO3\CMS\Core\Serializer\Exception\PolymorphicDeserializerException; + +/** + * @internal Only to be used by TYPO3 core + */ +final class PolymorphicDeserializer +{ + /** + * Validates the serialized payload by checking a static list of base classes or interfaces to be included in the + * de-serialized output. If a non-allowed class is hit, the method throws an PolymorphicDeserializerException. + * If the serialized payload is syntactically incorrect, PolymorphicDeserializerException is thrown as well. + * + * @param list<class-string> $allowedClasses + * @throws PolymorphicDeserializerException + */ + public function deserialize(string $payload, array $allowedClasses): mixed + { + // When allowing inheritance, extract all class names from payload and validate them + $classNames = $this->parseClassNames($payload); + + foreach ($classNames as $className) { + if (!$this->isInstanceOf($className, $allowedClasses)) { + throw new PolymorphicDeserializerException('Invalid class name "' . $className . '" found in payload', 1767987405); + } + + // Add the class if it's a valid subclass of any allowed class + if (!in_array($className, $allowedClasses, true)) { + $allowedClasses[] = $className; + } + } + + $result = @unserialize($payload, ['allowed_classes' => $allowedClasses]); + if ($result === false) { + if ($payload === serialize(false)) { + // Do not throw an exception in case the serialized string is *actually* false + // See https://www.php.net/manual/en/function.unserialize.php#refsect1-function.unserialize-notes + return false; + } + $exceptionMessage = 'Syntax error in payload, unable to de-serialize'; + $lastError = error_get_last(); + if ($lastError !== null) { + $exceptionMessage .= ': ' . $lastError['message']; + } + throw new PolymorphicDeserializerException($exceptionMessage, 1768212616); + } + + return $result; + } + + public function parseClassNames(string $payload): array + { + $classNames = []; + if (preg_match_all('/[CO]:(?P<length>\d+):"(?P<className>[^"]+)"/', $payload, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches['className'] as $i => $classNameMatch) { + $className = $classNameMatch[0]; + // Offset of the full O:... pattern + $matchOffset = (int)$matches[0][$i][1]; + $declaredLength = (int)$matches['length'][$i][0]; + + // Validate: 1) length matches, 2) not inside a string value + if (strlen($className) === $declaredLength && !$this->isInsideString($payload, $matchOffset)) { + $classNames[] = $className; + } + } + } + return $classNames; + } + + private function isInsideString(string $payload, int $offset): bool + { + if (preg_match_all('/s:(\d+):"/', $payload, $stringMatches, PREG_OFFSET_CAPTURE)) { + foreach ($stringMatches[0] as $i => $match) { + $stringDefOffset = $match[1]; + $stringLength = (int)$stringMatches[1][$i][0]; + // String content starts after s:LENGTH:" + $contentStart = $stringDefOffset + strlen($match[0]); + $contentEnd = $contentStart + $stringLength; + + if ($offset >= $contentStart && $offset < $contentEnd) { + return true; + } + } + } + return false; + } + + /** + * @param list<class-string> $allowedClassNames + */ + private function isInstanceOf(string $className, array $allowedClassNames): bool + { + foreach ($allowedClassNames as $allowedClassName) { + if (is_a($className, $allowedClassName, true) || is_subclass_of($className, $allowedClassName)) { + return true; + } + } + return false; + } +}
typo3/sysext/core/Tests/Functional/Serializer/Fixtures/BoE4HIpmXv.message+0 −0 addedtypo3/sysext/core/Tests/Functional/Serializer/PolymorphicDeserializerTest.php+124 −0 added@@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Serializer; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\Mailer\Envelope; +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Header\HeaderInterface; +use Symfony\Component\Mime\Header\Headers; +use Symfony\Component\Mime\RawMessage; +use TYPO3\CMS\Core\Serializer\Exception\PolymorphicDeserializerException; +use TYPO3\CMS\Core\Serializer\PolymorphicDeserializer; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class PolymorphicDeserializerTest extends FunctionalTestCase +{ + protected bool $initializeDatabase = false; + + private PolymorphicDeserializer $subject; + + protected function setUp(): void + { + parent::setUp(); + $this->subject = new PolymorphicDeserializer(); + } + + #[Test] + public function spooledMailMessageCanBeDeserialized(): void + { + $payload = file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'); + $result = $this->subject->deserialize($payload, [ + SentMessage::class, + RawMessage::class, + Envelope::class, + Address::class, + Headers::class, + HeaderInterface::class, + ]); + self::assertInstanceOf(SentMessage::class, $result); + } + + public static function spooledMailMessageCannotBeDeserializedDataProvider(): \Generator + { + yield [ + file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'), + 'Invalid class name "TYPO3\CMS\Core\Mail\FluidEmail" found in payload', + 1767987405, + ]; + yield [ + 's:foo:broken', + 'Syntax error in payload, unable to de-serialize: unserialize(): Error at offset 0 of 12 bytes', + 1768212616, + ]; + } + + #[Test] + #[DataProvider('spooledMailMessageCannotBeDeserializedDataProvider')] + public function spooledMailMessageCannotBeDeserialized(string $payload, string $expectedExceptionMessage, int $expectedExceptionCode): void + { + $this->expectException(PolymorphicDeserializerException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + $this->expectExceptionCode($expectedExceptionCode); + + $result = $this->subject->deserialize($payload, [SentMessage::class]); + self::assertInstanceOf(SentMessage::class, $result); + } + + public static function canParseClassNamesDataProvider(): iterable + { + yield 'simple example' => [ + 'a:2:{i:0;O:10:"ValidClass":0:{}i:1;s:21:" O:12:"InvalidClass":0:{} ";i:2;O:333:"IncorrectLengthClass":0:{}}', + ['ValidClass'], + ]; + yield 'serialized mail message' => [ + file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'), + [ + \Symfony\Component\Mailer\SentMessage::class, + \TYPO3\CMS\Core\Mail\FluidEmail::class, + \Symfony\Component\Mime\Header\Headers::class, + \Symfony\Component\Mime\Header\MailboxListHeader::class, + \Symfony\Component\Mime\Address::class, + \Symfony\Component\Mime\Header\MailboxListHeader::class, + \Symfony\Component\Mime\Address::class, + \Symfony\Component\Mime\Header\UnstructuredHeader::class, + \Symfony\Component\Mime\Header\UnstructuredHeader::class, + \Symfony\Component\Mime\RawMessage::class, + \Symfony\Component\Mailer\DelayedEnvelope::class, + ], + ]; + } + + #[Test] + #[DataProvider('canParseClassNamesDataProvider')] + public function canParseClassNames(string $payload, array $expectedClassNames): void + { + self::assertEquals($expectedClassNames, $this->subject->parseClassNames($payload)); + } + + #[Test] + public function falseValueCanBeDeserialized(): void + { + $payload = 'b:0;'; + $result = $this->subject->deserialize($payload, []); + + self::assertFalse($result); + } +}
typo3/sysext/core/Tests/Unit/Mail/FileSpoolTest.php+50 −2 modified@@ -19,6 +19,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Transport\NullTransport; use Symfony\Component\Mime\Address; @@ -31,12 +33,17 @@ final class FileSpoolTest extends UnitTestCase { protected bool $resetSingletonInstances = true; - protected ?FileSpool $subject; + private string $spoolPath; + private LoggerInterface&MockObject $loggerMock; + private FileSpool $subject; protected function setUp(): void { parent::setUp(); - $this->subject = new FileSpool(Environment::getVarPath() . '/spool/'); + + $this->spoolPath = Environment::getVarPath() . '/spool/'; + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->subject = new FileSpool($this->spoolPath, null, $this->loggerMock); $this->subject->setMessageLimit(10); $this->subject->setTimeLimit(1); } @@ -55,6 +62,47 @@ public function spoolsMessagesCorrectly(int $count): void self::assertEquals($count, $this->subject->flushQueue(new NullTransport())); } + #[Test] + public function flushQueueSkipsDisallowedSerializedMessages(): void + { + $disallowedMessage = $this->spoolPath . 'disallowed.message'; + $disallowedMessageSending = $this->spoolPath . 'invalid.message.sending'; + + file_put_contents($disallowedMessage, serialize(new \stdClass())); + + $this->loggerMock->expects(self::once())->method('error')->with( + 'Serialized message from {fileName} was rejected, because it contains a disallowed class object.', + ['fileName' => $disallowedMessage], + ); + + self::assertFileExists($disallowedMessage); + self::assertSame(0, $this->subject->flushQueue(new NullTransport())); + self::assertFileDoesNotExist($disallowedMessage); + self::assertFileDoesNotExist($disallowedMessageSending); + } + + #[Test] + public function flushQueueSkipsUnsupportedSerializedMessages(): void + { + $invalidMessage = $this->spoolPath . 'invalid.message'; + $invalidMessageSending = $this->spoolPath . 'invalid.message.sending'; + + file_put_contents($invalidMessage, serialize(new RawMessage('Hello World'))); + + $this->loggerMock->expects(self::once())->method('error')->with( + 'Serialized message from {fileName} was rejected, because {className} is not an instance of SentMessage.', + [ + 'fileName' => $invalidMessage, + 'className' => RawMessage::class, + ], + ); + + self::assertFileExists($invalidMessage); + self::assertSame(0, $this->subject->flushQueue(new NullTransport())); + self::assertFileDoesNotExist($invalidMessage); + self::assertFileDoesNotExist($invalidMessageSending); + } + /** * Data provider for message spooling test *
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/TYPO3/typo3/commit/3225d705080a1bde57a66689621c947da5a4782fghsapatchWEB
- github.com/TYPO3/typo3/commit/722bf71c118b0a8e4f2c2494854437d846799a13ghsapatchWEB
- github.com/TYPO3/typo3/commit/e0f0ceee480c203fbb60b87454f5f193e541d27fghsapatchWEB
- github.com/advisories/GHSA-7vp9-x248-9vr9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-0859ghsaADVISORY
- typo3.org/security/advisory/typo3-core-sa-2026-004ghsavendor-advisoryWEB
- github.com/TYPO3/typo3/security/advisories/GHSA-7vp9-x248-9vr9ghsaWEB
News mentions
0No linked articles in our index yet.