Commit 46266e60 authored by Ralf Zimmermann's avatar Ralf Zimmermann Committed by Oliver Hader
Browse files

[SECURITY] Validate allowed values for form element editors

Form editors which provide only a limited set of allowed values
(like single-select or multi-select form editors) now validate the
submitted values against the set of allowed values (configured within
the form setup).

Resolves: #93581
Releases: master, 11.1, 10.4, 9.5
Change-Id: Iae0a34c20cacdbcfc4eff9c4b1add966c1657010
Security-Bulletin: TYPO3-CORE-SA-2021-003
Security-References: CVE-2021-21357
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68414

Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent 66b75cec
......@@ -72,6 +72,10 @@ class FormEditorController extends AbstractBackendController
$this->view->getModuleTemplate()->setModuleName($this->request->getPluginName() . '_' . $this->request->getControllerName());
$this->view->getModuleTemplate()->setFlashMessageQueue($this->controllerContext->getFlashMessageQueue());
if (!$this->formPersistenceManager->isAllowedPersistencePath($formPersistenceIdentifier)) {
throw new PersistenceManagerException(sprintf('Read "%s" is not allowed', $formPersistenceIdentifier), 1614500662);
}
if (
strpos($formPersistenceIdentifier, 'EXT:') === 0
&& !$this->formSettings['persistenceManager']['allowSaveToExtensionPaths']
......@@ -182,6 +186,9 @@ class FormEditorController extends AbstractBackendController
];
try {
if (!$this->formPersistenceManager->isAllowedPersistencePath($formPersistenceIdentifier)) {
throw new PersistenceManagerException(sprintf('Save "%s" is not allowed', $formPersistenceIdentifier), 1614500663);
}
$this->formPersistenceManager->save($formPersistenceIdentifier, $formDefinition);
$configurationService = $this->objectManager->get(ConfigurationService::class);
$this->prototypeConfiguration = $configurationService->getPrototypeConfiguration($formDefinition['prototypeName']);
......@@ -500,6 +507,7 @@ class FormEditorController extends AbstractBackendController
$formDefinitionConversionService = $this->getFormDefinitionConversionService();
$formDefinition = $formDefinitionConversionService->addHmacData($formDefinition);
$formDefinition = $formDefinitionConversionService->migrateFinisherConfiguration($formDefinition);
return $formDefinition;
}
......
......@@ -103,10 +103,15 @@ class FormManagerController extends AbstractBackendController
* @param string $prototypeName
* @param string $savePath
* @throws FormException
* @throws PersistenceManagerException
* @internal
*/
public function createAction(string $formName, string $templatePath, string $prototypeName, string $savePath)
{
if (!$this->formPersistenceManager->isAllowedPersistencePath($savePath)) {
throw new PersistenceManagerException(sprintf('Save to path "%s" is not allowed', $savePath), 1614500657);
}
if (!$this->isValidTemplatePath($prototypeName, $templatePath)) {
throw new FormException(sprintf('The template path "%s" is not allowed', $templatePath), 1329233410);
}
......@@ -172,10 +177,18 @@ class FormManagerController extends AbstractBackendController
* @param string $formName
* @param string $formPersistenceIdentifier persistence identifier of the form to duplicate
* @param string $savePath
* @throws PersistenceManagerException
* @internal
*/
public function duplicateAction(string $formName, string $formPersistenceIdentifier, string $savePath)
{
if (!$this->formPersistenceManager->isAllowedPersistencePath($savePath)) {
throw new PersistenceManagerException(sprintf('Save to path "%s" is not allowed', $savePath), 1614500658);
}
if (!$this->formPersistenceManager->isAllowedPersistencePath($formPersistenceIdentifier)) {
throw new PersistenceManagerException(sprintf('Read of "%s" is not allowed', $formPersistenceIdentifier), 1614500659);
}
$formToDuplicate = $this->formPersistenceManager->load($formPersistenceIdentifier);
$formToDuplicate['label'] = $formName;
$formToDuplicate['identifier'] = $this->formPersistenceManager->getUniqueIdentifier($this->convertFormNameToIdentifier($formName));
......@@ -230,10 +243,15 @@ class FormManagerController extends AbstractBackendController
* Show references to this persistence identifier
*
* @param string $formPersistenceIdentifier persistence identifier of the form to duplicate
* @throws PersistenceManagerException
* @internal
*/
public function referencesAction(string $formPersistenceIdentifier)
{
if (!$this->formPersistenceManager->isAllowedPersistencePath($formPersistenceIdentifier)) {
throw new PersistenceManagerException(sprintf('Read from "%s" is not allowed', $formPersistenceIdentifier), 1614500660);
}
$this->view->assign('references', $this->getProcessedReferencesRows($formPersistenceIdentifier));
$this->view->assign('formPersistenceIdentifier', $formPersistenceIdentifier);
// referencesAction uses the extbase JsonView::class.
......@@ -248,10 +266,15 @@ class FormManagerController extends AbstractBackendController
* Delete a formDefinition identified by the $formPersistenceIdentifier.
*
* @param string $formPersistenceIdentifier persistence identifier to delete
* @throws PersistenceManagerException
* @internal
*/
public function deleteAction(string $formPersistenceIdentifier)
{
if (!$this->formPersistenceManager->isAllowedPersistencePath($formPersistenceIdentifier)) {
throw new PersistenceManagerException(sprintf('Delete "%s" is not allowed', $formPersistenceIdentifier), 1614500661);
}
if (empty($this->databaseService->getReferencesByPersistenceIdentifier($formPersistenceIdentifier))) {
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['beforeFormDelete'] ?? [] as $className) {
$hookObj = GeneralUtility::makeInstance($className);
......
......@@ -32,10 +32,12 @@ use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\FormEl
use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\FormElement\MultiValuePropertiesExtractor;
use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\FormElement\PredefinedDefaultsExtractor;
use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\FormElement\PropertyPathsExtractor;
use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\FormElement\SelectOptionsExtractor;
use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\PropertyCollectionElement\IsCreatablePropertyCollectionElementExtractor;
use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\PropertyCollectionElement\MultiValuePropertiesExtractor as CollectionMultiValuePropertiesExtractor;
use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\PropertyCollectionElement\PredefinedDefaultsExtractor as CollectionPredefinedDefaultsExtractor;
use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\PropertyCollectionElement\PropertyPathsExtractor as CollectionPropertyPathsExtractor;
use TYPO3\CMS\Form\Domain\Configuration\FrameworkConfiguration\Extractors\PropertyCollectionElement\SelectOptionsExtractor as CollectionSelectOptionsExtractor;
use TYPO3\CMS\Form\Mvc\Configuration\ConfigurationManagerInterface;
use TYPO3\CMS\Form\Service\TranslationService;
......@@ -186,6 +188,165 @@ class ConfigurationService implements SingletonInterface
return $this->isPropertyDefinedInFormEditorSetup($dto->getPropertyPath(), $subConfig);
}
/**
* If a form element editor has a property called "selectOptions"
* (e.g. editors with templateName "Inspector-SingleSelectEditor" or "Inspector-MultiSelectEditor")
* then only the defined values within the selectOptions are allowed to be written
* by the form editor.
*
* @param ValidationDto $dto
* @return bool
* @internal
*/
public function formElementPropertyHasLimitedAllowedValuesDefinedWithinFormEditorSetup(
ValidationDto $dto
): bool {
$formDefinitionValidationConfiguration = $this->buildFormDefinitionValidationConfigurationFromFormEditorSetup(
$dto->getPrototypeName()
);
$propertyPath = $this->getBasePropertyPathFromMultiValueFormElementProperty($dto);
return isset(
$formDefinitionValidationConfiguration['formElements'][$dto->getFormElementType()]['selectOptions'][$propertyPath]
);
}
/**
* Get the "selectOptions" value for a form element property from the form setup.
*
* @param ValidationDto $dto
* @return array
* @param bool $translated
* @throws PropertyException
* @internal
*/
public function getAllowedValuesForFormElementPropertyFromFormEditorSetup(
ValidationDto $dto,
bool $translated = true
): array {
if (!$this->formElementPropertyHasLimitedAllowedValuesDefinedWithinFormEditorSetup($dto)) {
throw new PropertyException(
sprintf(
'No selectOptions found for form element type "%s" and property path "%s"',
$dto->getFormElementType(),
$dto->getPropertyPath()
),
1614264312
);
}
$formDefinitionValidationConfiguration = $this->buildFormDefinitionValidationConfigurationFromFormEditorSetup(
$dto->getPrototypeName()
);
$property = $translated ? 'selectOptions' : 'untranslatedSelectOptions';
$propertyPath = $this->getBasePropertyPathFromMultiValueFormElementProperty($dto);
return $formDefinitionValidationConfiguration['formElements'][$dto->getFormElementType()][$property][$propertyPath];
}
/**
* If a form elements finisher|validator editor has a property called "selectOptions"
* (e.g. editors with templateName "Inspector-SingleSelectEditor" or "Inspector-MultiSelectEditor")
* then only the defined values within the selectOptions are allowed to be written
* by the form editor.
*
* @param ValidationDto $dto
* @return bool
* @internal
*/
public function propertyCollectionPropertyHasLimitedAllowedValuesDefinedWithinFormEditorSetup(
ValidationDto $dto
): bool {
$formDefinitionValidationConfiguration = $this->buildFormDefinitionValidationConfigurationFromFormEditorSetup(
$dto->getPrototypeName()
);
$propertyPath = $this->getBasePropertyPathFromMultiValuePropertyCollectionElement($dto);
return isset(
$formDefinitionValidationConfiguration['collections'][$dto->getPropertyCollectionName()][$dto->getPropertyCollectionElementIdentifier()]['selectOptions'][$propertyPath]
);
}
/**
* Get the "selectOptions" value for a form elements finisher|validator property from the form setup.
*
* @param ValidationDto $dto
* @param bool $translated
* @return array
* @throws PropertyException
* @internal
*/
public function getAllowedValuesForPropertyCollectionPropertyFromFormEditorSetup(
ValidationDto $dto,
bool $translated = true
): array {
if (!$this->propertyCollectionPropertyHasLimitedAllowedValuesDefinedWithinFormEditorSetup($dto)) {
throw new PropertyException(
sprintf(
'No selectOptions found for property collection "%s" and identifier "%s" and property path "%s"',
$dto->getPropertyCollectionName(),
$dto->getPropertyCollectionElementIdentifier(),
$dto->getPropertyPath()
),
1614264313
);
}
$formDefinitionValidationConfiguration = $this->buildFormDefinitionValidationConfigurationFromFormEditorSetup(
$dto->getPrototypeName()
);
$property = $translated ? 'selectOptions' : 'untranslatedSelectOptions';
$propertyPath = $this->getBasePropertyPathFromMultiValuePropertyCollectionElement($dto);
return $formDefinitionValidationConfiguration['collections'][$dto->getPropertyCollectionName()][$dto->getPropertyCollectionElementIdentifier()][$property][$propertyPath];
}
/**
* @param ValidationDto $dto
* @return string
*/
protected function getBasePropertyPathFromMultiValueFormElementProperty(
ValidationDto $dto
): string {
$formDefinitionValidationConfiguration = $this->buildFormDefinitionValidationConfigurationFromFormEditorSetup(
$dto->getPrototypeName()
);
$propertyPath = $dto->getPropertyPath();
$multiValueProperties = $formDefinitionValidationConfiguration['formElements'][$dto->getFormElementType()]['multiValueProperties'] ?? [];
foreach ($multiValueProperties as $multiValueProperty) {
if (strpos($propertyPath, $multiValueProperty) === 0) {
$propertyPath = $multiValueProperty;
continue;
}
}
return $propertyPath;
}
/**
* @param ValidationDto $dto
* @return string
*/
protected function getBasePropertyPathFromMultiValuePropertyCollectionElement(
ValidationDto $dto
): string {
$formDefinitionValidationConfiguration = $this->buildFormDefinitionValidationConfigurationFromFormEditorSetup(
$dto->getPrototypeName()
);
$propertyPath = $dto->getPropertyPath();
$multiValueProperties = $formDefinitionValidationConfiguration['collections'][$dto->getPropertyCollectionName()][$dto->getPropertyCollectionElementIdentifier()]['multiValueProperties'] ?? [];
foreach ($multiValueProperties as $multiValueProperty) {
if (strpos($propertyPath, $multiValueProperty) === 0) {
$propertyPath = $multiValueProperty;
continue;
}
}
return $propertyPath;
}
/**
* Check if a form element property is defined in "predefinedDefaults" in the form setup.
* If a form element property is defined in the "predefinedDefaults" in the form setup then it
......@@ -356,6 +517,26 @@ class ConfigurationService implements SingletonInterface
);
}
/**
* @param array $keys
* @param string $prototypeName
* @return array
* @internal
*/
public function getAllBackendTranslationsForTranslationKeys(array $keys, string $prototypeName): array
{
$translations = [];
foreach ($keys as $key) {
if (!is_string($key)) {
continue;
}
$translations[$key] = $this->getAllBackendTranslationsForTranslationKey($key, $prototypeName);
}
return $translations;
}
/**
* @param string $key
* @param string $prototypeName
......@@ -412,6 +593,12 @@ class ConfigurationService implements SingletonInterface
'^formElementsDefinition\.(.*)\.formEditor\.editors\.([\d]+)\.templateName$',
GeneralUtility::makeInstance(MultiValuePropertiesExtractor::class, $extractorDto)
),
GeneralUtility::makeInstance(
ArrayProcessing::class,
'formElementSelectOptions',
'^formElementsDefinition\.(.*)\.formEditor\.editors\.([\d]+)\.selectOptions\.([\d]+)\.value$',
GeneralUtility::makeInstance(SelectOptionsExtractor::class, $extractorDto)
),
GeneralUtility::makeInstance(
ArrayProcessing::class,
'formElementPredefinedDefaults',
......@@ -448,6 +635,12 @@ class ConfigurationService implements SingletonInterface
'^formElementsDefinition\.(.*)\.formEditor\.propertyCollections\.(finishers|validators)\.([\d]+)\.editors\.([\d]+)\.templateName$',
GeneralUtility::makeInstance(CollectionMultiValuePropertiesExtractor::class, $extractorDto)
),
GeneralUtility::makeInstance(
ArrayProcessing::class,
'propertyCollectionSelectOptions',
'^formElementsDefinition\.(.*)\.formEditor\.propertyCollections\.(finishers|validators)\.([\d]+)\.editors\.([\d]+)\.selectOptions\.([\d]+)\.value$',
GeneralUtility::makeInstance(CollectionSelectOptionsExtractor::class, $extractorDto)
),
GeneralUtility::makeInstance(
ArrayProcessing::class,
'propertyCollectionPredefinedDefaults',
......@@ -634,10 +827,16 @@ class ConfigurationService implements SingletonInterface
$prototypeConfiguration,
$configuration['formElements']
);
$configuration['formElements'] = $this->translateSelectOptions(
$prototypeConfiguration,
$configuration['formElements']
);
}
foreach ($configuration['collections'] ?? [] as $name => $collections) {
$configuration['collections'][$name] = $this->translatePredefinedDefaults($prototypeConfiguration, $collections);
$configuration['collections'][$name] = $this->translateSelectOptions($prototypeConfiguration, $configuration['collections'][$name]);
}
return $configuration;
}
......@@ -663,6 +862,28 @@ class ConfigurationService implements SingletonInterface
return $formElements;
}
/**
* @param array $prototypeConfiguration
* @param array $formElements
* @return array
*/
protected function translateSelectOptions(array $prototypeConfiguration, array $formElements): array
{
foreach ($formElements ?? [] as $name => $formElement) {
if (empty($formElement['selectOptions']) || !is_array($formElement['selectOptions'])) {
continue;
}
$formElement['untranslatedSelectOptions'] = $formElement['selectOptions'];
$formElement['selectOptions'] = $this->getTranslationService()->translateValuesRecursive(
$formElement['selectOptions'],
$prototypeConfiguration['formEditor']['translationFiles'] ?? []
);
$formElements[$name] = $formElement;
}
return $formElements;
}
/**
* @param string $cacheKey
* @return mixed
......
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Form\Domain\Configuration\FormDefinition\Converters;
/*
* 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\Utility\ArrayUtility;
/**
* @internal
*/
class FinisherTranslationLanguageConverter extends AbstractConverter
{
/**
* If "finishers.x.options.translation.language" is empty then set the value to "default" and remove
* the hmac.
*
* @param string $key
* @param mixed $value
*/
public function __invoke(string $key, $value): void
{
if (!empty($value)) {
return;
}
$formDefinition = $this->converterDto->getFormDefinition();
$formDefinition = ArrayUtility::setValueByPath($formDefinition, $key, 'default', '.');
$hmacPropertyPathParts = explode('.', $key);
$lastKeySegment = array_pop($hmacPropertyPathParts);
$hmacPropertyPathParts[] = '_orig_' . $lastKeySegment;
$hmacValuePath = implode('.', $hmacPropertyPathParts);
if (ArrayUtility::isValidPath($formDefinition, $hmacValuePath, '.')) {
$formDefinition = ArrayUtility::removeByPath($formDefinition, $hmacValuePath, '.');
}
$this->converterDto->setFormDefinition($formDefinition);
}
}
......@@ -29,6 +29,10 @@ class CreatableFormElementPropertiesValidator extends ElementBasedValidator
* or if the property is definied within the "predefinedDefaults" in the form editor setup
* and the property value matches the predefined value
* or if there is a valid hmac hash for the value.
* If the form element property is defined within the form editor setup
* and there is no valid hmac hash for the value
* and is the form element property configured to only allow a limited set of values,
* check the current (submitted) value against the allowed set of values (defined within the form setup).
*
* @param string $key
* @param mixed $value
......@@ -37,63 +41,132 @@ class CreatableFormElementPropertiesValidator extends ElementBasedValidator
{
$dto = $this->validationDto->withPropertyPath($key);
if (!$this->getConfigurationService()->isFormElementPropertyDefinedInFormEditorSetup($dto)) {
if (
$this->getConfigurationService()->isFormElementPropertyDefinedInPredefinedDefaultsInFormEditorSetup($dto)
&& !ArrayUtility::isValidPath($this->currentElement, $this->buildHmacDataPath($dto->getPropertyPath()), '.')
) {
// If the form element is newely created, we have to compare the $value (form definition) with $predefinedDefaultValue (form setup)
// to check the integrity (at this time we don't have a hmac for the $value to check the integrity)
$predefinedDefaultValue = $this->getConfigurationService()->getFormElementPredefinedDefaultValueFromFormEditorSetup($dto);
if ($value !== $predefinedDefaultValue) {
$throwException = true;
if ($this->getConfigurationService()->isFormElementPropertyDefinedInFormEditorSetup($dto)) {
if ($this->getConfigurationService()->formElementPropertyHasLimitedAllowedValuesDefinedWithinFormEditorSetup($dto)) {
$this->validateFormElementValue($value, $dto);
}
} elseif (
$this->getConfigurationService()->isFormElementPropertyDefinedInPredefinedDefaultsInFormEditorSetup($dto)
&& !ArrayUtility::isValidPath($this->currentElement, $this->buildHmacDataPath($dto->getPropertyPath()), '.')
) {
$this->validateFormElementPredefinedDefaultValue($value, $dto);
} else {
$this->validateFormElementPropertyValueByHmacData(
$this->currentElement,
$value,
$this->sessionToken,
$dto
);
}
}
if (is_string($predefinedDefaultValue)) {
// Last chance:
// Get all translations (from all backend languages) for the untranslated! $predefinedDefaultValue and
// compare the (already translated) $value (from the form definition) against the possible
// translations from $predefinedDefaultValue.
// Usecase:
// * backend language is EN
// * open the form edtior and add a ContentElement form element
// * switch to another browser tab and change the backend language to DE
// * clear the cache
// * go back to the form editor and click the save button
// Out of scope:
// * the same scenario as above + delete the previous chosen backend language within the maintenance tool
$untranslatedPredefinedDefaultValue = $this->getConfigurationService()->getFormElementPredefinedDefaultValueFromFormEditorSetup($dto, false);
$translations = $this->getConfigurationService()->getAllBackendTranslationsForTranslationKey(
$untranslatedPredefinedDefaultValue,
$dto->getPrototypeName()
);
/**
* Throws an exception if the value from a form element property
* does not match the default value from the form editor setup.
*
* @param mixed $value
* @param ValidationDto $dto
* @throws PropertyException
*/
protected function validateFormElementPredefinedDefaultValue(
$value,
ValidationDto $dto
): void {
// If the form element is newely created, we have to compare the $value (form definition) with $predefinedDefaultValue (form setup)
// to check the integrity (at this time we don't have a hmac for the $value to check the integrity)
$predefinedDefaultValue = $this->getConfigurationService()->getFormElementPredefinedDefaultValueFromFormEditorSetup($dto);
if ($value !== $predefinedDefaultValue) {
$throwException = true;
if (in_array($value, $translations, true)) {
$throwException = false;
}
}
if (is_string($predefinedDefaultValue)) {
// Last chance:
// Get all translations (from all backend languages) for the untranslated! $predefinedDefaultValue and
// compare the (already translated) $value (from the form definition) against the possible
// translations from $predefinedDefaultValue.
// Usecase:
// * backend language is EN
// * open the form edtior and add a ContentElement form element
// * switch to another browser tab and change the backend language to DE
// * clear the cache
// * go back to the form editor and click the save button
// Out of scope:
// * the same scenario as above + delete the previous chosen backend language within the maintenance tool
$untranslatedPredefinedDefaultValue = $this->getConfigurationService()->getFormElementPredefinedDefaultValueFromFormEditorSetup($dto, false);
$translations = $this->getConfigurationService()->getAllBackendTranslationsForTranslationKey(
$untranslatedPredefinedDefaultValue,
$dto->getPrototypeName()
);
if ($throwException) {
$message = 'The value "%s" of property "%s" (form element "%s") is not equal to the default value "%s" #1528588035';
throw new PropertyException(
sprintf(
$message,
$value,
$dto->getPropertyPath(),
$dto->getFormElementIdentifier(),
$predefinedDefaultValue
),
1528588035
);
}
if (in_array($value, $translations, true)) {
$throwException = false;
}
} else {
$this->validateFormElementPropertyValueByHmacData(
$this->currentElement,
$value,
$this->sessionToken,
$dto
}
if ($throwException) {
$message = 'The value "%s" of property "%s" (form element "%s") is not equal to the default value "%s" #1528588035';
throw new PropertyException(
sprintf(
$message,
$value,
$dto->getPropertyPath(),
$dto->getFormElementIdentifier(),
$predefinedDefaultValue
),
1528588035
);
}
}
}
/**
* Throws an exception if the value from a form element property
* does not match the allowed set of values (defined within the form setup).
*
* @param mixed $value