Skip to content

Commit e0f0cee

Browse files
eliashaeusslerohader
authored andcommitted
[SECURITY] Harden message deserialization in FileSpool transport
Messages used for delayed transport using the `FileSpool` transport are serialized `SentMessage` object representations. Deserializing queued messages must therefore accept only `SentMessage` and related classes. An existing security measure to limit deserialization to said classes is already in place, but contained a typo. Hence, the security measures did not have any effect. This is now fixed and all explicitly allowed classes are now properly configured for the `unserialize` command. However, since the current `symfony/mailer` implementations are constructed to include a various range of related classes, which may also be extended by custom implementations, it's quite hard to list all allowed classes in a static list. In order to cover this specific scenario, a new `PolymorphicDeserializer` component is introduced. It performs an extended deserialization, which is able to construct the list of allowed classes by inspecting the serialized payload: All extracted objects must match a predefined list of allowed classes or at least implement any of the listed interfaces, otherwise deserialization will fail. The `PolymorphicDeserializer` component is now consumed by the `FileSpool` transport. In addition, the `flushQueue` operation was slightly adjusted to enforce `SentMessage` objects and skip other serialized messages. In case any serialized messages contain disallowed or unsupported objects, an error will now be logged. The serialized message will be removed in any case – either after transport or when being skipped due to the described behavior – to avoid having invalid messages in place. Resolves: #108610 Releases: main, 14.0, 13.4, 12.4 Change-Id: I22db2d7f0ff46d51d15c76e61296c0ef8ac0e23c Security-Bulletin: TYPO3-CORE-SA-2026-004 Security-References: CVE-2026-0859 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/92302 Reviewed-by: Oliver Hader <oliver.hader@typo3.org> Tested-by: Oliver Hader <oliver.hader@typo3.org>
1 parent efb9528 commit e0f0cee

6 files changed

Lines changed: 358 additions & 20 deletions

File tree

typo3/sysext/core/Classes/Mail/FileSpool.php

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,17 @@
1818
namespace TYPO3\CMS\Core\Mail;
1919

2020
use Psr\Log\LoggerInterface;
21-
use Symfony\Component\Mailer\DelayedEnvelope;
2221
use Symfony\Component\Mailer\Envelope;
2322
use Symfony\Component\Mailer\Exception\TransportException;
2423
use Symfony\Component\Mailer\SentMessage;
2524
use Symfony\Component\Mailer\Transport\AbstractTransport;
2625
use Symfony\Component\Mailer\Transport\TransportInterface;
27-
use Symfony\Component\Mime\Email;
28-
use Symfony\Component\Mime\Message;
26+
use Symfony\Component\Mime\Address;
27+
use Symfony\Component\Mime\Header\HeaderInterface;
28+
use Symfony\Component\Mime\Header\Headers;
2929
use Symfony\Component\Mime\RawMessage;
3030
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
31+
use TYPO3\CMS\Core\Serializer\PolymorphicDeserializer;
3132
use TYPO3\CMS\Core\Utility\GeneralUtility;
3233

3334
/**
@@ -58,7 +59,8 @@ class FileSpool extends AbstractTransport implements DelayedTransportInterface
5859
public function __construct(
5960
protected string $path,
6061
?EventDispatcherInterface $dispatcher = null,
61-
protected readonly ?LoggerInterface $logger = null
62+
protected readonly ?LoggerInterface $logger = null,
63+
protected readonly PolymorphicDeserializer $deserializer = new PolymorphicDeserializer(),
6264
) {
6365
parent::__construct($dispatcher, $logger);
6466

@@ -140,20 +142,39 @@ public function flushQueue(TransportInterface $transport): int
140142

141143
/* We try a rename, it's an atomic operation, and avoid locking the file */
142144
if (rename($file, $file . '.sending')) {
143-
$message = unserialize((string)file_get_contents($file . '.sending'), [
144-
'allowedClasses' => [
145-
RawMessage::class,
146-
Message::class,
147-
Email::class,
148-
DelayedEnvelope::class,
149-
Envelope::class,
150-
],
151-
]);
152-
153-
$transport->send($message->getMessage(), $message->getEnvelope());
154-
$count++;
155-
156-
unlink($file . '.sending');
145+
try {
146+
$message = $this->deserializer->deserialize(
147+
(string)file_get_contents($file . '.sending'),
148+
[
149+
SentMessage::class,
150+
RawMessage::class,
151+
Envelope::class,
152+
Address::class,
153+
Headers::class,
154+
HeaderInterface::class,
155+
]
156+
);
157+
158+
if ($message instanceof SentMessage) {
159+
$transport->send($message->getMessage(), $message->getEnvelope());
160+
$count++;
161+
} else {
162+
$this->logger?->error(
163+
'Serialized message from {fileName} was rejected, because {className} is not an instance of SentMessage.',
164+
[
165+
'fileName' => $file,
166+
'className' => get_debug_type($message),
167+
],
168+
);
169+
}
170+
} catch (\Throwable) {
171+
$this->logger?->error(
172+
'Serialized message from {fileName} was rejected, because it contains a disallowed class object.',
173+
['fileName' => $file],
174+
);
175+
} finally {
176+
unlink($file . '.sending');
177+
}
157178
} else {
158179
/* This message has just been caught by another process */
159180
continue;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 CMS project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*
12+
* For the full copyright and license information, please read the
13+
* LICENSE.txt file that was distributed with this source code.
14+
*
15+
* The TYPO3 project - inspiring people to share!
16+
*/
17+
18+
namespace TYPO3\CMS\Core\Serializer\Exception;
19+
20+
use TYPO3\CMS\Core\Exception;
21+
22+
/**
23+
* An exception if de-serializing an object failed
24+
*
25+
* @internal
26+
*/
27+
class PolymorphicDeserializerException extends Exception {}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 CMS project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*
12+
* For the full copyright and license information, please read the
13+
* LICENSE.txt file that was distributed with this source code.
14+
*
15+
* The TYPO3 project - inspiring people to share!
16+
*/
17+
18+
namespace TYPO3\CMS\Core\Serializer;
19+
20+
use TYPO3\CMS\Core\Serializer\Exception\PolymorphicDeserializerException;
21+
22+
/**
23+
* @internal Only to be used by TYPO3 core
24+
*/
25+
final readonly class PolymorphicDeserializer
26+
{
27+
/**
28+
* Validates the serialized payload by checking a static list of base classes or interfaces to be included in the
29+
* de-serialized output. If a non-allowed class is hit, the method throws an PolymorphicDeserializerException.
30+
* If the serialized payload is syntactically incorrect, PolymorphicDeserializerException is thrown as well.
31+
*
32+
* @param list<class-string> $allowedClasses
33+
* @throws PolymorphicDeserializerException
34+
*/
35+
public function deserialize(string $payload, array $allowedClasses): mixed
36+
{
37+
// When allowing inheritance, extract all class names from payload and validate them
38+
$classNames = $this->parseClassNames($payload);
39+
40+
foreach ($classNames as $className) {
41+
if (!$this->isInstanceOf($className, $allowedClasses)) {
42+
throw new PolymorphicDeserializerException('Invalid class name "' . $className . '" found in payload', 1767987405);
43+
}
44+
45+
// Add the class if it's a valid subclass of any allowed class
46+
if (!in_array($className, $allowedClasses, true)) {
47+
$allowedClasses[] = $className;
48+
}
49+
}
50+
51+
$result = @unserialize($payload, ['allowed_classes' => $allowedClasses]);
52+
if ($result === false) {
53+
if ($payload === serialize(false)) {
54+
// Do not throw an exception in case the serialized string is *actually* false
55+
// See https://www.php.net/manual/en/function.unserialize.php#refsect1-function.unserialize-notes
56+
return false;
57+
}
58+
$exceptionMessage = 'Syntax error in payload, unable to de-serialize';
59+
$lastError = error_get_last();
60+
if ($lastError !== null) {
61+
$exceptionMessage .= ': ' . $lastError['message'];
62+
}
63+
throw new PolymorphicDeserializerException($exceptionMessage, 1768212616);
64+
}
65+
66+
return $result;
67+
}
68+
69+
public function parseClassNames(string $payload): array
70+
{
71+
$classNames = [];
72+
if (preg_match_all('/[CO]:(?P<length>\d+):"(?P<className>[^"]+)"/', $payload, $matches, PREG_OFFSET_CAPTURE)) {
73+
foreach ($matches['className'] as $i => $classNameMatch) {
74+
$className = $classNameMatch[0];
75+
// Offset of the full O:... pattern
76+
$matchOffset = (int)$matches[0][$i][1];
77+
$declaredLength = (int)$matches['length'][$i][0];
78+
79+
// Validate: 1) length matches, 2) not inside a string value
80+
if (strlen($className) === $declaredLength && !$this->isInsideString($payload, $matchOffset)) {
81+
$classNames[] = $className;
82+
}
83+
}
84+
}
85+
return $classNames;
86+
}
87+
88+
private function isInsideString(string $payload, int $offset): bool
89+
{
90+
if (preg_match_all('/s:(\d+):"/', $payload, $stringMatches, PREG_OFFSET_CAPTURE)) {
91+
foreach ($stringMatches[0] as $i => $match) {
92+
$stringDefOffset = $match[1];
93+
$stringLength = (int)$stringMatches[1][$i][0];
94+
// String content starts after s:LENGTH:"
95+
$contentStart = $stringDefOffset + strlen($match[0]);
96+
$contentEnd = $contentStart + $stringLength;
97+
98+
if ($offset >= $contentStart && $offset < $contentEnd) {
99+
return true;
100+
}
101+
}
102+
}
103+
return false;
104+
}
105+
106+
/**
107+
* @param list<class-string> $allowedClassNames
108+
*/
109+
private function isInstanceOf(string $className, array $allowedClassNames): bool
110+
{
111+
foreach ($allowedClassNames as $allowedClassName) {
112+
if (is_a($className, $allowedClassName, true) || is_subclass_of($className, $allowedClassName)) {
113+
return true;
114+
}
115+
}
116+
return false;
117+
}
118+
}
Binary file not shown.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 CMS project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*
12+
* For the full copyright and license information, please read the
13+
* LICENSE.txt file that was distributed with this source code.
14+
*
15+
* The TYPO3 project - inspiring people to share!
16+
*/
17+
18+
namespace TYPO3\CMS\Core\Tests\Functional\Serializer;
19+
20+
use PHPUnit\Framework\Attributes\DataProvider;
21+
use PHPUnit\Framework\Attributes\Test;
22+
use Symfony\Component\Mailer\Envelope;
23+
use Symfony\Component\Mailer\SentMessage;
24+
use Symfony\Component\Mime\Address;
25+
use Symfony\Component\Mime\Header\HeaderInterface;
26+
use Symfony\Component\Mime\Header\Headers;
27+
use Symfony\Component\Mime\RawMessage;
28+
use TYPO3\CMS\Core\Serializer\Exception\PolymorphicDeserializerException;
29+
use TYPO3\CMS\Core\Serializer\PolymorphicDeserializer;
30+
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
31+
32+
final class PolymorphicDeserializerTest extends FunctionalTestCase
33+
{
34+
protected bool $initializeDatabase = false;
35+
36+
private PolymorphicDeserializer $subject;
37+
38+
protected function setUp(): void
39+
{
40+
parent::setUp();
41+
$this->subject = new PolymorphicDeserializer();
42+
}
43+
44+
#[Test]
45+
public function spooledMailMessageCanBeDeserialized(): void
46+
{
47+
$payload = file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message');
48+
$result = $this->subject->deserialize($payload, [
49+
SentMessage::class,
50+
RawMessage::class,
51+
Envelope::class,
52+
Address::class,
53+
Headers::class,
54+
HeaderInterface::class,
55+
]);
56+
self::assertInstanceOf(SentMessage::class, $result);
57+
}
58+
59+
public static function spooledMailMessageCannotBeDeserializedDataProvider(): \Generator
60+
{
61+
yield [
62+
file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'),
63+
'Invalid class name "TYPO3\CMS\Core\Mail\FluidEmail" found in payload',
64+
1767987405,
65+
];
66+
yield [
67+
's:foo:broken',
68+
'Syntax error in payload, unable to de-serialize: unserialize(): Error at offset 0 of 12 bytes',
69+
1768212616,
70+
];
71+
}
72+
73+
#[Test]
74+
#[DataProvider('spooledMailMessageCannotBeDeserializedDataProvider')]
75+
public function spooledMailMessageCannotBeDeserialized(string $payload, string $expectedExceptionMessage, int $expectedExceptionCode): void
76+
{
77+
$this->expectException(PolymorphicDeserializerException::class);
78+
$this->expectExceptionMessage($expectedExceptionMessage);
79+
$this->expectExceptionCode($expectedExceptionCode);
80+
81+
$result = $this->subject->deserialize($payload, [SentMessage::class]);
82+
self::assertInstanceOf(SentMessage::class, $result);
83+
}
84+
85+
public static function canParseClassNamesDataProvider(): iterable
86+
{
87+
yield 'simple example' => [
88+
'a:2:{i:0;O:10:"ValidClass":0:{}i:1;s:21:" O:12:"InvalidClass":0:{} ";i:2;O:333:"IncorrectLengthClass":0:{}}',
89+
['ValidClass'],
90+
];
91+
yield 'serialized mail message' => [
92+
file_get_contents(__DIR__ . '/Fixtures/BoE4HIpmXv.message'),
93+
[
94+
\Symfony\Component\Mailer\SentMessage::class,
95+
\TYPO3\CMS\Core\Mail\FluidEmail::class,
96+
\Symfony\Component\Mime\Header\Headers::class,
97+
\Symfony\Component\Mime\Header\MailboxListHeader::class,
98+
\Symfony\Component\Mime\Address::class,
99+
\Symfony\Component\Mime\Header\MailboxListHeader::class,
100+
\Symfony\Component\Mime\Address::class,
101+
\Symfony\Component\Mime\Header\UnstructuredHeader::class,
102+
\Symfony\Component\Mime\Header\UnstructuredHeader::class,
103+
\Symfony\Component\Mime\RawMessage::class,
104+
\Symfony\Component\Mailer\DelayedEnvelope::class,
105+
],
106+
];
107+
}
108+
109+
#[Test]
110+
#[DataProvider('canParseClassNamesDataProvider')]
111+
public function canParseClassNames(string $payload, array $expectedClassNames): void
112+
{
113+
self::assertEquals($expectedClassNames, $this->subject->parseClassNames($payload));
114+
}
115+
116+
#[Test]
117+
public function falseValueCanBeDeserialized(): void
118+
{
119+
$payload = 'b:0;';
120+
$result = $this->subject->deserialize($payload, []);
121+
122+
self::assertFalse($result);
123+
}
124+
}

0 commit comments

Comments
 (0)