Skip to content

Commit eb2b225

Browse files
committed
[SECURITY] Properly detect .form.yaml suffixes in resource layer
The `TYPO3\CMS\Form\Slot\FilePersistenceSlot` is meant to deny direct invocations with files that have a .form.yaml suffix. However, due to the lack of properly passing the target file name to the mentioned slot, it was possible to bypass this check using .FORM.YAML. Resolves: #109056 Releases: main, 14.3, 13.4 Change-Id: If8d79d93ce921edd0877a4d19222a9cdb9b29bed Security-Bulletin: TYPO3-CORE-SA-2026-008 Security-References: CVE-2026-47346 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/94399 Tested-by: Oliver Hader <oliver.hader@typo3.org> Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
1 parent ac4125a commit eb2b225

7 files changed

Lines changed: 292 additions & 2 deletions

File tree

typo3/sysext/core/Classes/Resource/Driver/LocalDriver.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@ public function getFolderInfoByIdentifier(string $folderIdentifier): array
301301
/**
302302
* Returns a string where any character not matching [.a-zA-Z0-9_-] is
303303
* substituted by '_'
304-
* Trailing dots are removed
304+
* Trailing dots are removed and characters are lowercased if using
305+
* a case insensitive file system.
305306
*
306307
* Previously in \TYPO3\CMS\Core\Utility\File\BasicFileUtility::cleanFileName()
307308
*
@@ -320,10 +321,16 @@ public function sanitizeFileName(string $fileName, string $charset = 'utf-8'): s
320321
if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']) {
321322
// Allow ".", "-", 0-9, a-z, A-Z and everything beyond U+C0 (latin capital letter a with grave)
322323
$cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . ']/u', '_', trim($fileName));
324+
if (!$this->isCaseSensitiveFileSystem()) {
325+
$cleanFileName = mb_strtolower($cleanFileName, 'utf-8');
326+
}
323327
} else {
324328
$fileName = GeneralUtility::makeInstance(CharsetConverter::class)->specCharsToASCII($charset, $fileName);
325329
// Replace unwanted characters with underscores
326330
$cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . '\\xC0-\\xFF]/', '_', trim($fileName));
331+
if (!$this->isCaseSensitiveFileSystem()) {
332+
$cleanFileName = strtolower($cleanFileName);
333+
}
327334
}
328335
// Strip trailing dots and return
329336
$cleanFileName = rtrim($cleanFileName, '.');

typo3/sysext/form/Classes/Slot/FilePersistenceSlot.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,10 @@ private function buildCombinedIdentifier(FolderInterface $folder, string $fileNa
172172

173173
private function isFormDefinition(string $identifier): bool
174174
{
175-
return str_ends_with($identifier, FormPersistenceManagerInterface::FORM_DEFINITION_FILE_EXTENSION);
175+
return str_ends_with(
176+
mb_strtolower($identifier),
177+
FormPersistenceManagerInterface::FORM_DEFINITION_FILE_EXTENSION
178+
);
176179
}
177180

178181
private function isRecycleFolder(FolderInterface $folder): bool
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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\Form\Tests\Functional\Core;
19+
20+
use PHPUnit\Framework\Attributes\DataProvider;
21+
use PHPUnit\Framework\Attributes\Test;
22+
use TYPO3\CMS\Core\Database\ConnectionPool;
23+
use TYPO3\CMS\Core\Http\UploadedFile;
24+
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
25+
use TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior;
26+
use TYPO3\CMS\Core\Resource\File;
27+
use TYPO3\CMS\Core\Resource\ResourceStorage;
28+
use TYPO3\CMS\Core\Utility\File\ExtendedFileUtility;
29+
use TYPO3\CMS\Core\Utility\GeneralUtility;
30+
use TYPO3\CMS\Form\Tests\Functional\Fixtures\ResourceStorageUploadMock;
31+
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
32+
33+
final class ExtendedFileUtilityTest extends FunctionalTestCase
34+
{
35+
protected array $coreExtensionsToLoad = ['form'];
36+
37+
protected function setUp(): void
38+
{
39+
parent::setUp();
40+
$this->importCSVDataSet(__DIR__ . '/../Fixtures/be_users.csv');
41+
$this->setUpBackendUser(1);
42+
$GLOBALS['LANG'] = $this->get(LanguageServiceFactory::class)->create('default');
43+
44+
// ensure, temporary uploaded files are purged again
45+
// @todo move this to the testing framework (which only reinitialized files for the first run)
46+
$fileCommandsPath = $this->instancePath . '/fileadmin/file-commands';
47+
if (is_dir($fileCommandsPath)) {
48+
GeneralUtility::rmdir($fileCommandsPath, true);
49+
}
50+
GeneralUtility::mkdir($fileCommandsPath);
51+
52+
// Configure ResourceStorage mock to overwrite the `is_uploaded_file`
53+
// check which can not be mocked
54+
$GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][ResourceStorage::class] = [
55+
'className' => ResourceStorageUploadMock::class,
56+
];
57+
GeneralUtility::flushInternalRuntimeCaches();
58+
}
59+
60+
protected function tearDown(): void
61+
{
62+
unset($GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][ResourceStorage::class]);
63+
GeneralUtility::flushInternalRuntimeCaches();
64+
parent::tearDown();
65+
}
66+
67+
public static function fileCommandsAreProcessedDataProvider(): iterable
68+
{
69+
yield 'protected file suffix (case-insensitive storage)' => [
70+
'caseSensitiveFileStorage' => false,
71+
'fileCommands' => [
72+
'upload' => [
73+
1 => ['target' => '1:/file-commands/', 'data' => 1],
74+
],
75+
],
76+
'uploadableFiles' => [
77+
'upload_1' => [
78+
__DIR__ . '/../Fixtures/Files/temp-lowercase.form.yaml',
79+
__DIR__ . '/../Fixtures/Files/temp-uppercase.FORM.YAML',
80+
],
81+
],
82+
'expectedResult' => [
83+
'upload' => [
84+
// none of the uploaded files is supposed to be accepted
85+
0 => [],
86+
],
87+
],
88+
];
89+
yield 'protected file suffix (case-sensitive storage)' => [
90+
'caseSensitiveFileStorage' => true,
91+
'fileCommands' => [
92+
'upload' => [
93+
1 => ['target' => '1:/file-commands/', 'data' => 1],
94+
],
95+
],
96+
'uploadableFiles' => [
97+
'upload_1' => [
98+
__DIR__ . '/../Fixtures/Files/temp-lowercase.form.yaml',
99+
__DIR__ . '/../Fixtures/Files/temp-uppercase.FORM.YAML',
100+
],
101+
],
102+
'expectedResult' => [
103+
'upload' => [
104+
// none of the uploaded files is supposed to be accepted
105+
0 => [],
106+
],
107+
],
108+
];
109+
yield 'regular-file (case-sensitive storage)' => [
110+
'caseSensitiveFileStorage' => true,
111+
'fileCommands' => [
112+
'upload' => [
113+
1 => ['target' => '1:/file-commands/', 'data' => 1],
114+
],
115+
],
116+
'uploadableFiles' => [
117+
'upload_1' => [
118+
__DIR__ . '/../Fixtures/Files/regular-file.txt',
119+
],
120+
],
121+
'expectedResult' => [
122+
'upload' => [
123+
// none of the uploaded files is supposed to be accepted
124+
0 => ['1:/file-commands/regular-file.txt'],
125+
],
126+
],
127+
];
128+
}
129+
130+
/**
131+
* Specific implementation for EXT:form of
132+
* \TYPO3\CMS\Core\Tests\Functional\Utility\File\ExtendedFileUtilityTest::fileCommandsAreProcessed
133+
*/
134+
#[Test]
135+
#[DataProvider('fileCommandsAreProcessedDataProvider')]
136+
public function fileCommandsAreProcessed(bool $caseSensitiveFileStorage, array $fileCommands, array $uploadableFiles, array $expectedResult): void
137+
{
138+
$this->createDefaultFileStorage($caseSensitiveFileStorage);
139+
$uploadedFiles = array_map(
140+
fn(array|string $data): array|UploadedFile => is_array($data)
141+
? array_map($this->createUploadedFile(...), $data)
142+
: $this->createUploadedFile($data),
143+
$uploadableFiles
144+
);
145+
146+
$this->mockFilesArrayFromUploadedFiles($uploadedFiles);
147+
148+
$extendedFileUtility = new ExtendedFileUtility();
149+
$extendedFileUtility->setExistingFilesConflictMode(DuplicationBehavior::getDefaultDuplicationBehaviour());
150+
$extendedFileUtility->start($fileCommands);
151+
$result = $extendedFileUtility->processData();
152+
153+
self::assertSame($expectedResult, $this->normalizeProcessedDataResult($result));
154+
}
155+
156+
/**
157+
* @param array<string, UploadedFile|list<UploadedFile>> $uploadedFiles
158+
*/
159+
private function mockFilesArrayFromUploadedFiles(array $uploadedFiles): void
160+
{
161+
$_FILES = [];
162+
foreach ($uploadedFiles as $name => $files) {
163+
if ($files instanceof UploadedFile) {
164+
$file = $files;
165+
$_FILES[$name]['name'] = $file->getClientFilename();
166+
$_FILES[$name]['tmp_name'] = $file->getTemporaryFileName();
167+
$_FILES[$name]['error'] = $file->getError();
168+
$_FILES[$name]['size'] = $file->getSize();
169+
$_FILES[$name]['type'] = '';
170+
} else {
171+
foreach ($files as $index => $file) {
172+
$_FILES[$name]['name'][$index] = $file->getClientFilename();
173+
$_FILES[$name]['tmp_name'][$index] = $file->getTemporaryFileName();
174+
$_FILES[$name]['error'][$index] = $file->getError();
175+
$_FILES[$name]['size'][$index] = $file->getSize();
176+
$_FILES[$name]['type'][$index] = '';
177+
}
178+
}
179+
}
180+
}
181+
182+
private function createDefaultFileStorage(bool $caseSensitive): void
183+
{
184+
$caseSensitiveValue = $caseSensitive ? 1 : 0;
185+
$configuration = <<<XML
186+
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
187+
<T3FlexForms>
188+
<data>
189+
<sheet index="sDEF">
190+
<language index="lDEF">
191+
<field index="basePath"><value index="vDEF">fileadmin/</value></field>
192+
<field index="pathType"><value index="vDEF">relative</value></field>
193+
<field index="caseSensitive"><value index="vDEF">{$caseSensitiveValue}</value></field>
194+
</language>
195+
</sheet>
196+
</data>
197+
</T3FlexForms>
198+
XML;
199+
$this->get(ConnectionPool::class)
200+
->getConnectionForTable('sys_file_storage')
201+
->insert('sys_file_storage', [
202+
'uid' => 1,
203+
'pid' => 0,
204+
'name' => 'fileadmin/ (auto-created)',
205+
'processingfolder' => 'temp/assets/_processed_/',
206+
'driver' => 'Local',
207+
'is_browsable' => 1,
208+
'is_public' => 1,
209+
'is_writable' => 1,
210+
'is_online' => 1,
211+
'configuration' => $configuration,
212+
]);
213+
}
214+
215+
private function createUploadedFile(string $filePath): UploadedFile
216+
{
217+
$size = filesize($filePath);
218+
$tempPath = GeneralUtility::tempnam('extended-file-utility-test');
219+
GeneralUtility::writeFile($tempPath, file_get_contents($filePath));
220+
// @todo use resource streams of `UploadedFile`, once it's fully supported in FAL
221+
return new UploadedFile($tempPath, $size, UPLOAD_ERR_OK, basename($filePath));
222+
}
223+
224+
private function normalizeProcessedDataResult(array $result): array
225+
{
226+
return array_map(
227+
static fn(array $actionResult): array => array_map(
228+
static fn(array|File|null $fileResult): array|string|null => is_array($fileResult)
229+
? array_map(static fn(File $file): string => $file->getCombinedIdentifier(), $fileResult)
230+
: $fileResult?->getCombinedIdentifier(),
231+
$actionResult
232+
),
233+
$result
234+
);
235+
}
236+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
foo
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# temp.form.yaml
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# temp.FORM.YAML
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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\Form\Tests\Functional\Fixtures;
19+
20+
use TYPO3\CMS\Core\Resource\Exception\UploadSizeException;
21+
use TYPO3\CMS\Core\Resource\ResourceStorage;
22+
use TYPO3\CMS\Core\Utility\GeneralUtility;
23+
24+
class ResourceStorageUploadMock extends ResourceStorage
25+
{
26+
protected function assureFileUploadPermissions($localFilePath, $targetFolder, $targetFileName, $uploadedFileSize)
27+
{
28+
// HEADS UP: This condition is disabled to allow mocked $_FILES
29+
//if (!is_uploaded_file($localFilePath)) {
30+
// throw new UploadException('The upload has failed, no uploaded file found!', 1322110455);
31+
//}
32+
33+
// Max upload size (kb) for files.
34+
$maxUploadFileSize = GeneralUtility::getMaxUploadFileSize() * 1024;
35+
if ($maxUploadFileSize > 0 && $uploadedFileSize >= $maxUploadFileSize) {
36+
unlink($localFilePath);
37+
throw new UploadSizeException('The uploaded file exceeds the size-limit of ' . $maxUploadFileSize . ' bytes', 1322110042);
38+
}
39+
$this->assureFileAddPermissions($targetFolder, $targetFileName);
40+
}
41+
}

0 commit comments

Comments
 (0)