VYPR
Moderate severityOSV Advisory· Published Jan 13, 2026· Updated Jan 13, 2026

TYPO3 CMS Allows Insecure Deserialization via Mailer File Spool

CVE-2026-0859

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.

PackageAffected versionsPatched versions
typo3/cms-corePackagist
>= 14.0.0, < 14.0.214.0.2
typo3/cms-corePackagist
>= 13.0.0, < 13.4.2313.4.23
typo3/cms-corePackagist
>= 12.0.0, < 12.4.4112.4.41
typo3/cms-corePackagist
>= 11.0.0, < 11.5.4911.5.49
typo3/cms-corePackagist
>= 10.0.0, < 10.4.5510.4.55

Affected products

1

Patches

3
3225d705080a

[SECURITY] Harden message deserialization in `FileSpool` transport

https://github.com/TYPO3/typo3Elias HäußlerJan 13, 2026via ghsa
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 added
  • typo3/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

https://github.com/TYPO3/typo3Elias HäußlerJan 13, 2026via ghsa
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 added
  • typo3/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

https://github.com/TYPO3/typo3Elias HäußlerJan 13, 2026via ghsa
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 added
  • typo3/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

News mentions

0

No linked articles in our index yet.