Commit 66b75cec authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[SECURITY] Mitigate directly accessible file upload in form framework

File handling implementation in `UploadedFileReferenceConverter` of
`ext:form` creates files in `/fileadmin/user_uploads/` whenever some
Extbase controller is (implicitly) dealing with `FileReference` models,
unless particular implementations assign specific type converters or
register type converters having a higher processing priority.

As a side-effect this could lead to by-passing mime-type validators,
allowing to plant cross-site scripting and other malicious binaries
to public accessible `/fileadmin/` storage. PHP files and similar are
blocked since `fileDenyPattern` rule is active in any case.

This change makes the usage of `UploadedFileReferenceConverter` more
specific in the scope of processing contact forms with `ext:form`

* use random folder names for files, `.../form_abcde12345/image.png`
* removes `UploadedFileReferenceConverter` from being used implicitly
  by other Extbase implementations dealing with `FileReference` models

`PseudoFileReference` has been introduced to limit properties being
serialized to `uid` (in case it's a real file reference) or `uidLocal`
(in case it's a transient reference, pointing to a file).

Direct URLs to uploaded files are substituted by `fileDump` eID script
now, enforcing corresponding FAL mime-type and denying the web server
from guessing/interpreting a different mime-type based on file suffix.

A unique form `__session` value has been introduce, serving as seed
to derive for instance mentioned folder names for uploaded files. In
addition to that, form `__state` is only parsed when having been
submitted via expected `FormFrontendController::performAction`.

Resolves: #92136
Releases: master, 11.1, 10.4, 9.5
Change-Id: I7c33803443a68d6b3c895ec74da802a70bd390c1
Security-Bulletin: TYPO3-CORE-SA-2021-002
Security-References: CVE-2021-21355
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68413

Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent a1b09aaa
#!/usr/bin/env php
<?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!
*/
$dbJson = file_get_contents(dirname(__DIR__) . '/node_modules/mime-db/db.json');
$dbJson = json_decode($dbJson, true);
$mimeTypeMapping = [];
$mimeTypeString = '';
foreach ($dbJson as $mimeType => $mimeTypeInfo) {
if (isset($mimeTypeInfo['extensions'])) {
$mimeTypeMapping[$mimeType] = $mimeTypeInfo['extensions'];
}
}
// @todo: add our own file extensions here
foreach ($mimeTypeMapping as $mimeType => $extensionInfo) {
$mimeTypeString .= " '" . $mimeType . "' => ['" . implode("', '", $extensionInfo) . "'],
";
}
$classTemplate = '<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Resource;
/*
* 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!
*/
/**
* This class contains a list of all available / known mimetypes and file extensions,
* and is automatically generated by TYPO3 via Core/Build/Scripts/generateMimeTypes.php
*/
final class MimeTypeCollection
{
private $map = [
' . rtrim($mimeTypeString, ',') .
' ];
/**
* @return array<string, List<string>>
*/
public function getMap(): array
{
return $this->map;
}
/**
* @return List<string>
*/
public function getMimeTypes(): array
{
return array_keys($this->map);
}
}
';
file_put_contents(dirname(dirname(__DIR__)) . '/typo3/sysext/core/Classes/Resource/MimeTypeCollection.php', $classTemplate);
......@@ -75,6 +75,7 @@
"intersection-observer": "^0.11.0",
"jquery": "~3.3.1",
"jquery-ui": "github:jquery/jquery-ui#1.11.4",
"mime-db": "^1.46.0",
"moment": "^2.29.0",
"moment-timezone": "^0.5.31",
"npm-font-source-sans-pro": "^1.0.2",
......
......@@ -5077,6 +5077,11 @@ mime-db@^1.28.0:
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea"
integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==
mime-db@^1.46.0:
version "1.46.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.46.0.tgz#6267748a7f799594de3cbc8cde91def349661cee"
integrity sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==
mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
version "2.1.27"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f"
......
......@@ -523,14 +523,16 @@ class FileReference implements FileInterface
public function __sleep(): array
{
$keys = get_object_vars($this);
unset($keys['originalFile']);
unset($keys['originalFile'], $keys['mergedProperties']);
return array_keys($keys);
}
public function __wakeup(): void
{
$factory = GeneralUtility::makeInstance(ResourceFactory::class);
$this->originalFile = $this->getFileObject(
(int)$this->propertiesOfFileReference['uid_local']
(int)$this->propertiesOfFileReference['uid_local'],
$factory
);
}
}
This diff is collapsed.
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Resource;
/*
* 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!
*/
/**
* This class contains a list of all available / known mimetypes and file extensions,
* and is automatically generated by TYPO3 via Core/Build/Scripts/generateMimeTypes.php
*/
final class MimeTypeDetector
{
/**
* @var MimeTypeCollection
*/
private $collection;
public function __construct()
{
$this->collection = new MimeTypeCollection();
}
/**
* @param string $fileExtension
* @return array<int, string>
*/
public function getMimeTypesForFileExtension(string $fileExtension): array
{
$mimeTypes = [];
$fileExtension = strtolower($fileExtension);
foreach ($this->collection->getMap() as $mimeType => $availableExtensions) {
if (in_array($fileExtension, $availableExtensions, true)) {
$mimeTypes[] = $mimeType;
}
}
return $mimeTypes;
}
/**
* @param string $mimeType
* @return array<int, string>
*/
public function getFileExtensionsForMimeType(string $mimeType): array
{
return $this->collection->getMap()[strtolower($mimeType)] ?? [];
}
}
......@@ -15,6 +15,7 @@ namespace TYPO3\CMS\Form\Domain\Finishers;
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Form\Domain\Model\FormElements\FileUpload;
......@@ -36,6 +37,7 @@ class DeleteUploadsFinisher extends AbstractFinisher
{
$formRuntime = $this->finisherContext->getFormRuntime();
$uploadFolders = [];
$elements = $formRuntime->getFormDefinition()->getRenderablesRecursively();
foreach ($elements as $element) {
if (!$element instanceof FileUpload) {
......@@ -49,7 +51,44 @@ class DeleteUploadsFinisher extends AbstractFinisher
if ($file instanceof FileReference) {
$file = $file->getOriginalResource();
}
$folder = $file->getParentFolder();
$uploadFolders[$folder->getCombinedIdentifier()] = $folder;
$file->getStorage()->deleteFile($file->getOriginalFile());
}
$this->deleteEmptyUploadFolders($uploadFolders);
}
/**
* note:
* TYPO3\CMS\Form\Mvc\Property\TypeConverter\UploadedFileReferenceConverter::importUploadedResource()
* creates a sub-folder for file uploads (e.g. .../form_<40-chars-hash>/actual.file)
* @param Folder[] $folders
*/
protected function deleteEmptyUploadFolders(array $folders): void
{
foreach ($folders as $folder) {
$parentFolder = $folder->getParentFolder();
if ($this->isEmptyFolder($folder)) {
$folder->delete();
}
if ($this->isEmptyFolder($parentFolder)) {
$parentFolder->delete();
}
}
}
/**
* @param Folder $folder
* @return bool
*/
protected function isEmptyFolder(Folder $folder): bool
{
return $folder->getFileCount() === 0
&& $folder->getStorage()->countFoldersInFolder($folder) === 0;
}
}
......@@ -27,6 +27,7 @@ use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
use TYPO3\CMS\Extbase\Error\Result;
use TYPO3\CMS\Extbase\Mvc\Controller\Arguments;
use TYPO3\CMS\Extbase\Mvc\Controller\ControllerContext;
......@@ -46,9 +47,12 @@ use TYPO3\CMS\Form\Domain\Model\Renderable\RootRenderableInterface;
use TYPO3\CMS\Form\Domain\Model\Renderable\VariableRenderableInterface;
use TYPO3\CMS\Form\Domain\Renderer\RendererInterface;
use TYPO3\CMS\Form\Domain\Runtime\Exception\PropertyMappingException;
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime\FormSession;
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime\Lifecycle\AfterFormStateInitializedInterface;
use TYPO3\CMS\Form\Exception as FormException;
use TYPO3\CMS\Form\Mvc\Validation\EmptyValidator;
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
/**
......@@ -118,6 +122,14 @@ class FormRuntime implements RootRenderableInterface, \ArrayAccess
*/
protected $formState;
/**
* Individual unique random form session identifier valid
* for current user session. This value is not persisted server-side.
*
* @var FormSession|null
*/
protected $formSession;
/**
* The current page is the page which will be displayed to the user
* during rendering.
......@@ -157,6 +169,11 @@ class FormRuntime implements RootRenderableInterface, \ArrayAccess
*/
protected $currentFinisher = null;
/**
* @var \TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface
*/
protected $configurationManager;
/**
* @param \TYPO3\CMS\Extbase\Security\Cryptography\HashService $hashService
* @internal
......@@ -175,6 +192,14 @@ class FormRuntime implements RootRenderableInterface, \ArrayAccess
$this->objectManager = $objectManager;
}
/**
* @param \TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface $configurationManager
*/
public function injectConfigurationManager(ConfigurationManagerInterface $configurationManager)
{
$this->configurationManager = $configurationManager;
}
/**
* @param FormDefinition $formDefinition
* @param Request $request
......@@ -199,43 +224,79 @@ class FormRuntime implements RootRenderableInterface, \ArrayAccess
public function initializeObject()
{
$this->initializeCurrentSiteLanguage();
$this->initializeFormSessionFromRequest();
$this->initializeFormStateFromRequest();
$this->triggerAfterFormStateInitialized();
$this->processVariants();
$this->initializeCurrentPageFromRequest();
$this->initializeHoneypotFromRequest();
if (!$this->isFirstRequest() && $this->getRequest()->getMethod() === 'POST') {
// Only validate and set form values within the form state
// if the current request is not the very first request
// and the current request can be processed (POST request and uncached).
if (!$this->isFirstRequest() && $this->canProcessFormSubmission()) {
$this->processSubmittedFormValues();
}
$this->renderHoneypot();
}
/**
* @todo `FormRuntime::$formSession` is still vulnerable to session fixation unless a real cookie-based process is used
*/
protected function initializeFormSessionFromRequest(): void
{
// Initialize the form session only if the current request can be processed
// (POST request and uncached) to ensure unique sessions for each form submitter.
if (!$this->canProcessFormSubmission()) {
return;
}
$sessionIdentifierFromRequest = $this->request->getInternalArgument('__session');
$this->formSession = GeneralUtility::makeInstance(FormSession::class, $sessionIdentifierFromRequest);
}
/**
* Initializes the current state of the form, based on the request
* @throws BadRequestException
*/
protected function initializeFormStateFromRequest()
{
// Only try to reconstitute the form state if the current request
// is not the very first request and if the current request can
// be processed (POST request and uncached).
$serializedFormStateWithHmac = $this->request->getInternalArgument('__state');
if ($serializedFormStateWithHmac === null) {
if ($serializedFormStateWithHmac === null || !$this->canProcessFormSubmission()) {
$this->formState = GeneralUtility::makeInstance(FormState::class);
} else {
try {
$serializedFormState = $this->hashService->validateAndStripHmac($serializedFormStateWithHmac);
} catch (InvalidHashException | InvalidArgumentForHashGenerationException $e) {
throw new BadRequestException('The HMAC of the form could not be validated.', 1581862823);
throw new BadRequestException('The HMAC of the form state could not be validated.', 1581862823);
}
$this->formState = unserialize(base64_decode($serializedFormState));
}
}
protected function triggerAfterFormStateInitialized(): void
{
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterFormStateInitialized'] ?? [] as $className) {
$hookObj = GeneralUtility::makeInstance($className);
if ($hookObj instanceof AfterFormStateInitializedInterface) {
$hookObj->afterFormStateInitialized($this);
}
}
}
/**
* Initializes the current page data based on the current request, also modifiable by a hook
*/
protected function initializeCurrentPageFromRequest()
{
if (!$this->formState->isFormSubmitted()) {
// If there was no previous form submissions or if the current request
// can't be processed (no POST request and/or cached) then display the first
// form step
if (!$this->formState->isFormSubmitted() || !$this->canProcessFormSubmission()) {
$this->currentPage = $this->formDefinition->getPageByIndex(0);
$renderingOptions = $this->currentPage->getRenderingOptions();
......@@ -467,6 +528,31 @@ class FormRuntime implements RootRenderableInterface, \ArrayAccess
return $this->lastDisplayedPage === null;
}
/**
* @return bool
*/
protected function isPostRequest(): bool
{
return $this->getRequest()->getMethod() === 'POST';
}
/**
* Determine whether the surrounding content object is cached.
* If no surrounding content object can be found (which would be strange)
* we assume a cached request for safety which means that an empty form
* will be rendered.
*
* @return bool
*/
protected function isRenderedCached(): bool
{
$contentObject = $this->configurationManager->getContentObject();
return $contentObject === null
? true
// @todo this does not work when rendering a cached `FLUIDTEMPLATE` (not nested in `COA_INT`)
: $contentObject->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER;
}
/**
* Runs throuh all validations
*/
......@@ -694,6 +780,29 @@ class FormRuntime implements RootRenderableInterface, \ArrayAccess
return $this->response;
}
/**
* Only process values if there is a post request and if the
* surrounding content object is uncached.
* Is this not the case, all possible submitted values will be discarded
* and the first form step will be shown with an empty form state.
*
* @return bool
* @internal
*/
public function canProcessFormSubmission(): bool
{
return $this->isPostRequest() && !$this->isRenderedCached();
}
/**
* @return FormSession|null
* @internal
*/
public function getFormSession(): ?FormSession
{
return $this->formSession;
}
/**
* Returns the currently selected page
*
......
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
/*
* 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!
*/
use TYPO3\CMS\Core\Crypto\Random;
use TYPO3\CMS\Core\Error\Http\BadRequestException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Security\Cryptography\HashService;
use TYPO3\CMS\Extbase\Security\Exception\InvalidArgumentForHashGenerationException;
use TYPO3\CMS\Extbase\Security\Exception\InvalidHashException;
/**
* @internal
*/
class FormSession
{
protected $identifier;
/**
* Factory to create the form session from the current state
*
* @param string|null $authenticatedIdentifier
* @throws BadRequestException
*/
public function __construct(string $authenticatedIdentifier = null)
{
if ($authenticatedIdentifier === null) {
$this->identifier = $this->generateIdentifier();
} else {
$this->identifier = $this->validateIdentifier($authenticatedIdentifier);
}
}
/**
* @return string
* @internal
*/
public function getIdentifier(): string
{
return $this->identifier;
}
/**
* Consumed by TYPO3\CMS\Form\ViewHelpers\FormViewHelper
*
* @return string
* @internal
*/
public function getAuthenticatedIdentifier(): string
{
return GeneralUtility::makeInstance(HashService::class)
// restrict string expansion by adding some char ('|')
->appendHmac($this->identifier . '|');
}
/**
* @return string
*/
protected function generateIdentifier(): string
{
return GeneralUtility::makeInstance(Random::class)->generateRandomHexString(40);
}
/**
* @param string $authenticatedIdentifier
* @return string
* @throws BadRequestException
*/
protected function validateIdentifier(string $authenticatedIdentifier): string
{
try {
$identifier = GeneralUtility::makeInstance(HashService::class)
->validateAndStripHmac($authenticatedIdentifier);
return rtrim($identifier, '|');
} catch (InvalidHashException | InvalidArgumentForHashGenerationException $e) {
throw new BadRequestException('The HMAC of the form session could not be validated.', 1613300274);
}
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Form\Domain\Runtime\FormRuntime\Lifecycle;
/*
* 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!
*/
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
/**
* Event is triggered with current form state and form session, which is
* not the case with e.g. `afterBuildingFinished`. Can be used to further
* enrich components with runtime state.
* @internal
*/
interface AfterFormStateInitializedInterface
{
/**
* @param FormRuntime $formRuntime holding current form state and static form definition
*/
public function afterFormStateInitialized(FormRuntime $formRuntime): void;
}
......@@ -23,6 +23,8 @@ use TYPO3\CMS\Extbase\Property\TypeConverter\DateTimeConverter;
use TYPO3\CMS\Extbase\Validation\Validator\NotEmptyValidator;
use TYPO3\CMS\Form\Domain\Model\FormElements\FileUpload;
use TYPO3\CMS\Form\Domain\Model\Renderable\RenderableInterface;
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime;
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime\Lifecycle\AfterFormStateInitializedInterface;
use TYPO3\CMS\Form\Mvc\Property\TypeConverter\UploadedFileReferenceConverter;
use TYPO3\CMS\Form\Mvc\Validation\MimeTypeValidator;
......@@ -30,13 +32,16 @@ use TYPO3\CMS\Form\Mvc\Validation\MimeTypeValidator;
* Scope: frontend
* @internal
*/
class PropertyMappingConfiguration
class PropertyMappingConfiguration implements AfterFormStateInitializedInterface
{
/**
* This hook is called for each form element after the class
* TYPO3\CMS\Form\Domain\Factory\ArrayFormFactory has built the entire form.
*
* It is invoked after the static form definition is ready, but without knowing
* about the individual state organized in `FormRuntime` and `FormState`.
*
* @param RenderableInterface $renderable
* @internal