Skip to content

Commit 2030617

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/+/94401 Tested-by: Oliver Hader <oliver.hader@typo3.org> Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
1 parent 504e724 commit 2030617

6 files changed

Lines changed: 205 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
@@ -296,7 +296,8 @@ public function getFolderInfoByIdentifier(string $folderIdentifier): array
296296
/**
297297
* Returns a string where any character not matching [.a-zA-Z0-9_-] is
298298
* substituted by '_'
299-
* Trailing dots are removed
299+
* Trailing dots are removed and characters are lowercased if using
300+
* a case insensitive file system.
300301
*
301302
* Previously in \TYPO3\CMS\Core\Utility\File\BasicFileUtility::cleanFileName()
302303
*
@@ -310,10 +311,16 @@ public function sanitizeFileName(string $fileName): string
310311
if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['UTF8filesystem']) {
311312
// Allow ".", "-", 0-9, a-z, A-Z and everything beyond U+C0 (latin capital letter a with grave)
312313
$cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . ']/u', '_', trim($fileName));
314+
if (!$this->isCaseSensitiveFileSystem()) {
315+
$cleanFileName = mb_strtolower($cleanFileName, 'utf-8');
316+
}
313317
} else {
314318
$fileName = GeneralUtility::makeInstance(CharsetConverter::class)->utf8_char_mapping($fileName);
315319
// Replace unwanted characters with underscores
316320
$cleanFileName = (string)preg_replace('/[' . self::UNSAFE_FILENAME_CHARACTER_EXPRESSION . '\\xC0-\\xFF]/', '_', trim($fileName));
321+
if (!$this->isCaseSensitiveFileSystem()) {
322+
$cleanFileName = strtolower($cleanFileName);
323+
}
317324
}
318325
// Strip trailing dots and return
319326
$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: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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\File;
26+
use TYPO3\CMS\Core\Utility\File\ExtendedFileUtility;
27+
use TYPO3\CMS\Core\Utility\GeneralUtility;
28+
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
29+
30+
final class ExtendedFileUtilityTest extends FunctionalTestCase
31+
{
32+
protected array $coreExtensionsToLoad = ['form'];
33+
34+
protected function setUp(): void
35+
{
36+
parent::setUp();
37+
$this->importCSVDataSet(__DIR__ . '/../Fixtures/be_users.csv');
38+
$this->setUpBackendUser(1);
39+
$GLOBALS['LANG'] = $this->get(LanguageServiceFactory::class)->create('en');
40+
41+
// ensure, temporary uploaded files are purged again
42+
// @todo move this to the testing framework (which only reinitialized files for the first run)
43+
$fileCommandsPath = $this->instancePath . '/fileadmin/file-commands';
44+
if (is_dir($fileCommandsPath)) {
45+
GeneralUtility::rmdir($fileCommandsPath, true);
46+
}
47+
GeneralUtility::mkdir($fileCommandsPath);
48+
}
49+
50+
public static function fileCommandsAreProcessedDataProvider(): iterable
51+
{
52+
yield 'protected file suffix (case-insensitive storage)' => [
53+
'caseSensitiveFileStorage' => false,
54+
'fileCommands' => [
55+
'upload' => [
56+
1 => ['target' => '1:/file-commands/', 'data' => 1],
57+
],
58+
],
59+
'uploadableFiles' => [
60+
'upload_1' => [
61+
__DIR__ . '/../Fixtures/Files/temp-lowercase.form.yaml',
62+
__DIR__ . '/../Fixtures/Files/temp-uppercase.FORM.YAML',
63+
],
64+
],
65+
'expectedResult' => [
66+
'upload' => [
67+
// none of the uploaded files is supposed to be accepted
68+
0 => [],
69+
],
70+
],
71+
];
72+
yield 'protected file suffix (case-sensitive storage)' => [
73+
'caseSensitiveFileStorage' => true,
74+
'fileCommands' => [
75+
'upload' => [
76+
1 => ['target' => '1:/file-commands/', 'data' => 1],
77+
],
78+
],
79+
'uploadableFiles' => [
80+
'upload_1' => [
81+
__DIR__ . '/../Fixtures/Files/temp-lowercase.form.yaml',
82+
__DIR__ . '/../Fixtures/Files/temp-uppercase.FORM.YAML',
83+
],
84+
],
85+
'expectedResult' => [
86+
'upload' => [
87+
// none of the uploaded files is supposed to be accepted
88+
0 => [],
89+
],
90+
],
91+
];
92+
yield 'regular-file (case-sensitive storage)' => [
93+
'caseSensitiveFileStorage' => true,
94+
'fileCommands' => [
95+
'upload' => [
96+
1 => ['target' => '1:/file-commands/', 'data' => 1],
97+
],
98+
],
99+
'uploadableFiles' => [
100+
'upload_1' => [
101+
__DIR__ . '/../Fixtures/Files/regular-file.txt',
102+
],
103+
],
104+
'expectedResult' => [
105+
'upload' => [
106+
// none of the uploaded files is supposed to be accepted
107+
0 => ['1:/file-commands/regular-file.txt'],
108+
],
109+
],
110+
];
111+
}
112+
113+
/**
114+
* Specific implementation for EXT:form of
115+
* \TYPO3\CMS\Core\Tests\Functional\Utility\File\ExtendedFileUtilityTest::fileCommandsAreProcessed
116+
*/
117+
#[Test]
118+
#[DataProvider('fileCommandsAreProcessedDataProvider')]
119+
public function fileCommandsAreProcessed(bool $caseSensitiveFileStorage, array $fileCommands, array $uploadableFiles, array $expectedResult): void
120+
{
121+
$this->createDefaultFileStorage($caseSensitiveFileStorage);
122+
$uploadedFiles = array_map(
123+
fn(array|string $data): array|UploadedFile => is_array($data)
124+
? array_map($this->createUploadedFile(...), $data)
125+
: $this->createUploadedFile($data),
126+
$uploadableFiles
127+
);
128+
129+
$extendedFileUtility = new ExtendedFileUtility();
130+
$extendedFileUtility->start($fileCommands, $uploadedFiles);
131+
$result = $extendedFileUtility->processData();
132+
133+
self::assertSame($expectedResult, $this->normalizeProcessedDataResult($result));
134+
}
135+
136+
private function createDefaultFileStorage(bool $caseSensitive): void
137+
{
138+
$caseSensitiveValue = $caseSensitive ? 1 : 0;
139+
$configuration = <<<XML
140+
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
141+
<T3FlexForms>
142+
<data>
143+
<sheet index="sDEF">
144+
<language index="lDEF">
145+
<field index="basePath"><value index="vDEF">fileadmin/</value></field>
146+
<field index="pathType"><value index="vDEF">relative</value></field>
147+
<field index="caseSensitive"><value index="vDEF">{$caseSensitiveValue}</value></field>
148+
</language>
149+
</sheet>
150+
</data>
151+
</T3FlexForms>
152+
XML;
153+
$this->get(ConnectionPool::class)
154+
->getConnectionForTable('sys_file_storage')
155+
->insert('sys_file_storage', [
156+
'uid' => 1,
157+
'pid' => 0,
158+
'name' => 'fileadmin/ (auto-created)',
159+
'processingfolder' => 'temp/assets/_processed_/',
160+
'driver' => 'Local',
161+
'is_browsable' => 1,
162+
'is_public' => 1,
163+
'is_writable' => 1,
164+
'is_online' => 1,
165+
'configuration' => $configuration,
166+
]);
167+
}
168+
169+
private function createUploadedFile(string $filePath): UploadedFile
170+
{
171+
$size = filesize($filePath);
172+
$tempPath = GeneralUtility::tempnam('extended-file-utility-test');
173+
GeneralUtility::writeFile($tempPath, file_get_contents($filePath));
174+
// @todo use resource streams of `UploadedFile`, once it's fully supported in FAL
175+
return new UploadedFile($tempPath, $size, UPLOAD_ERR_OK, basename($filePath));
176+
}
177+
178+
private function normalizeProcessedDataResult(array $result): array
179+
{
180+
return array_map(
181+
static fn(array $actionResult): array => array_map(
182+
static fn(array|File|null $fileResult): array|string|null => is_array($fileResult)
183+
? array_map(static fn(File $file): string => $file->getCombinedIdentifier(), $fileResult)
184+
: $fileResult?->getCombinedIdentifier(),
185+
$actionResult
186+
),
187+
$result
188+
);
189+
}
190+
}
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

0 commit comments

Comments
 (0)