From 6cc11761b8e2434fa4ccc9f096c65ca82569cfdf Mon Sep 17 00:00:00 2001
From: Benjamin Franzke <ben@bnf.dev>
Date: Tue, 13 Feb 2024 10:05:36 +0100
Subject: [PATCH] [SECURITY] Prevent RCE via install tool settings

Resolves: #102799
Releases: main, 13.0, 12.4, 11.5
Change-Id: I673b6fbac853b0a977a5e5833a683c6952a55458
Security-Bulletin: TYPO3-CORE-SA-2024-002
Security-References: CVE-2024-22188
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/82946
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
---
 .../Classes/Imaging/GraphicalFunctions.php    |  2 +-
 .../core/Classes/Mail/TransportFactory.php    |  5 +++
 .../Processing/LocalCropScaleMaskHelper.php   | 24 ++++++-----
 .../core/Classes/Utility/CommandUtility.php   | 12 ++++--
 .../Configuration/DefaultConfiguration.php    |  2 +-
 .../DefaultConfigurationDescription.yaml      | 11 +++--
 ...stripColorProfileParametersOptionAdded.rst | 40 +++++++++++++++++++
 .../ContentObject/ContentObjectRenderer.php   |  2 +-
 .../Configuration/AbstractCustomPreset.php    | 23 +++++++++--
 .../Classes/Configuration/AbstractPreset.php  |  5 +++
 .../Configuration/Image/CustomPreset.php      |  4 ++
 .../Configuration/Mail/CustomPreset.php       |  4 ++
 .../PasswordHashing/CustomPreset.php          | 27 +++++++++----
 .../LocalConfigurationValueService.php        | 32 ++++++++++++++-
 .../LocalConfiguration/SubSection.html        | 15 ++++---
 .../Settings/Presets/Cache/Custom.html        |  7 ++--
 .../Settings/Presets/Context/Custom.html      |  7 ++--
 .../Settings/Presets/Image/Custom.html        |  7 ++--
 .../Settings/Presets/Mail/Custom.html         |  8 ++--
 .../Presets/PasswordHashing/Custom.html       |  8 ++--
 .../Partials/Settings/ReadonlyInfo.html       | 10 +++++
 21 files changed, 200 insertions(+), 55 deletions(-)
 create mode 100644 typo3/sysext/core/Documentation/Changelog/11.5.x/Important-102799-TYPO3_CONF_VARSGFXprocessor_stripColorProfileParametersOptionAdded.rst
 create mode 100644 typo3/sysext/install/Resources/Private/Partials/Settings/ReadonlyInfo.html

diff --git a/typo3/sysext/core/Classes/Imaging/GraphicalFunctions.php b/typo3/sysext/core/Classes/Imaging/GraphicalFunctions.php
index b62311f991e1..20d7df93ce0a 100644
--- a/typo3/sysext/core/Classes/Imaging/GraphicalFunctions.php
+++ b/typo3/sysext/core/Classes/Imaging/GraphicalFunctions.php
@@ -2160,7 +2160,7 @@ class GraphicalFunctions
         }
         $command .= ' ' . $info[0] . 'x' . $info[1] . '! ' . $params . ' ';
         // re-apply colorspace-setting for the resulting image so colors don't appear to dark (sRGB instead of RGB)
-        $command .= ' -colorspace ' . $this->colorspace;
+        $command .= ' -colorspace ' . CommandUtility::escapeShellArgument($this->colorspace);
         $cropscale = $data['crs'] ? 'crs-V' . $data['cropV'] . 'H' . $data['cropH'] : '';
         if ($this->alternativeOutputKey) {
             $theOutputName = md5($command . $cropscale . PathUtility::basename($imagefile) . $this->alternativeOutputKey . '[' . $frame . ']');
diff --git a/typo3/sysext/core/Classes/Mail/TransportFactory.php b/typo3/sysext/core/Classes/Mail/TransportFactory.php
index 32772ad3b368..ee0467322594 100644
--- a/typo3/sysext/core/Classes/Mail/TransportFactory.php
+++ b/typo3/sysext/core/Classes/Mail/TransportFactory.php
@@ -27,6 +27,7 @@ use Symfony\Component\Mailer\Transport\TransportInterface;
 use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
 use TYPO3\CMS\Core\Exception;
 use TYPO3\CMS\Core\Log\LogManagerInterface;
+use TYPO3\CMS\Core\Resource\Security\FileNameValidator;
 use TYPO3\CMS\Core\SingletonInterface;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
@@ -150,6 +151,10 @@ class TransportFactory implements SingletonInterface, LoggerAwareInterface
                 if ($mboxFile === '') {
                     throw new Exception('$GLOBALS[\'TYPO3_CONF_VARS\'][\'MAIL\'][\'transport_mbox_file\'] needs to be set when transport is set to "mbox".', 1294586645);
                 }
+                $fileNameValidator = GeneralUtility::makeInstance(FileNameValidator::class);
+                if (!$fileNameValidator->isValid($mboxFile)) {
+                    throw new Exception('$GLOBALS[\'TYPO3_CONF_VARS\'][\'MAIL\'][\'transport_mbox_file\'] failed against deny-pattern', 1705312431);
+                }
                 // Create our transport
                 $transport = GeneralUtility::makeInstance(
                     MboxTransport::class,
diff --git a/typo3/sysext/core/Classes/Resource/Processing/LocalCropScaleMaskHelper.php b/typo3/sysext/core/Classes/Resource/Processing/LocalCropScaleMaskHelper.php
index 88cbe253aebc..668de2d4919f 100644
--- a/typo3/sysext/core/Classes/Resource/Processing/LocalCropScaleMaskHelper.php
+++ b/typo3/sysext/core/Classes/Resource/Processing/LocalCropScaleMaskHelper.php
@@ -19,6 +19,7 @@ use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Imaging\GraphicalFunctions;
 use TYPO3\CMS\Core\Resource\FileInterface;
 use TYPO3\CMS\Core\Resource\ProcessedFile;
+use TYPO3\CMS\Core\Utility\CommandUtility;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Core\Utility\MathUtility;
 use TYPO3\CMS\Frontend\Imaging\GifBuilder;
@@ -286,18 +287,21 @@ class LocalCropScaleMaskHelper
      */
     protected function modifyImageMagickStripProfileParameters(string $parameters, array $configuration)
     {
+        if (!isset($configuration['stripProfile'])) {
+            return $parameters;
+        }
+
+        $gfxConf = $GLOBALS['TYPO3_CONF_VARS']['GFX'] ?? [];
+        // Use legacy processor_stripColorProfileCommand setting if defined, otherwise
+        // use the preferred configuration option processor_stripColorProfileParameters
+        $stripColorProfileCommand = $gfxConf['processor_stripColorProfileCommand'] ??
+            implode(' ', array_map(CommandUtility::escapeShellArgument(...), $gfxConf['processor_stripColorProfileParameters'] ?? []));
+
         // Strips profile information of image to save some space:
-        if (isset($configuration['stripProfile'])) {
-            if (
-                $configuration['stripProfile']
-                && $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileCommand'] !== ''
-            ) {
-                $parameters = $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileCommand'] . $parameters;
-            } else {
-                $parameters .= '###SkipStripProfile###';
-            }
+        if ($configuration['stripProfile'] && $stripColorProfileCommand !== '') {
+            return $stripColorProfileCommand . $parameters;
         }
-        return $parameters;
+        return $parameters . '###SkipStripProfile###';
     }
 
     protected function isTemporaryFile(string $filePath): bool
diff --git a/typo3/sysext/core/Classes/Utility/CommandUtility.php b/typo3/sysext/core/Classes/Utility/CommandUtility.php
index df74cd8a671e..e1ec81d241a4 100644
--- a/typo3/sysext/core/Classes/Utility/CommandUtility.php
+++ b/typo3/sysext/core/Classes/Utility/CommandUtility.php
@@ -119,14 +119,18 @@ class CommandUtility
         }
         // strip profile information for thumbnails and reduce their size
         if ($parameters && $command !== 'identify') {
+            // Use legacy processor_stripColorProfileCommand setting if defined, otherwise
+            // use the preferred configuration option processor_stripColorProfileParameters
+            $stripColorProfileCommand = $gfxConf['processor_stripColorProfileCommand'] ??
+                implode(' ', array_map(CommandUtility::escapeShellArgument(...), $gfxConf['processor_stripColorProfileParameters'] ?? []));
             // Determine whether the strip profile action has be disabled by TypoScript:
             if ($gfxConf['processor_stripColorProfileByDefault']
-                && $gfxConf['processor_stripColorProfileCommand'] !== ''
+                && $stripColorProfileCommand !== ''
                 && $parameters !== '-version'
-                && !str_contains($parameters, $gfxConf['processor_stripColorProfileCommand'])
+                && !str_contains($parameters, $stripColorProfileCommand)
                 && !str_contains($parameters, '###SkipStripProfile###')
             ) {
-                $parameters = $gfxConf['processor_stripColorProfileCommand'] . ' ' . $parameters;
+                $parameters = $stripColorProfileCommand . ' ' . $parameters;
             } else {
                 $parameters = str_replace('###SkipStripProfile###', '', $parameters);
             }
@@ -137,7 +141,7 @@ class CommandUtility
         }
         // set interlace parameter for convert command
         if ($command !== 'identify' && $gfxConf['processor_interlace']) {
-            $parameters = '-interlace ' . $gfxConf['processor_interlace'] . ' ' . $parameters;
+            $parameters = '-interlace ' . CommandUtility::escapeShellArgument($gfxConf['processor_interlace']) . ' ' . $parameters;
         }
         $cmdLine = $path . ' ' . $parameters;
         // It is needed to change the parameters order when a mask image has been specified
diff --git a/typo3/sysext/core/Configuration/DefaultConfiguration.php b/typo3/sysext/core/Configuration/DefaultConfiguration.php
index c3a50ee72fcf..a61c88d7485a 100644
--- a/typo3/sysext/core/Configuration/DefaultConfiguration.php
+++ b/typo3/sysext/core/Configuration/DefaultConfiguration.php
@@ -37,7 +37,7 @@ return [
         'processor_allowFrameSelection' => true,
         'processor_allowTemporaryMasksAsPng' => false,
         'processor_stripColorProfileByDefault' => true,
-        'processor_stripColorProfileCommand' => '+profile \'*\'',
+        'processor_stripColorProfileParameters' => ['+profile', '*'],
         'processor_colorspace' => 'RGB',
         'processor_interlace' => 'None',
         'jpg_quality' => 85,
diff --git a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml
index a6ae8acf1cc2..e2e5b10d16f5 100644
--- a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml
+++ b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml
@@ -25,6 +25,7 @@ GFX:
             description: 'Enables the use of Image- or GraphicsMagick.'
         processor_path:
             type: text
+            readonly: true
             description: 'Path to the IM tools ''convert'', ''combine'', ''identify''.'
         processor:
             type: dropdown
@@ -46,10 +47,10 @@ GFX:
             description: 'This should be set if your processor supports using PNGs as masks as this is usually faster.'
         processor_stripColorProfileByDefault:
             type: bool
-            description: 'If set, the processor_stripColorProfileCommand is used with all processor image operations by default. See tsRef for setting this parameter explicitly for IMAGE generation.'
-        processor_stripColorProfileCommand:
-            type: text
-            description: 'String: Specify the command to strip the profile information, which can reduce thumbnail size up to 60KB. Command can differ in IM/GM, IM also know the -strip command. See <a href="http://www.imagemagick.org/Usage/thumbnails/#profiles" target="_blank" rel="noreferrer">imagemagick.org</a> for details'
+            description: 'If set, the processor_stripColorProfileParameters is used with all processor image operations by default. See tsRef for setting this parameter explicitly for IMAGE generation.'
+        processor_stripColorProfileParameters:
+            type: array
+            description: 'Comma separated list of parameters: Specify the parameters to strip the profile information, which can reduce thumbnail size up to 60KB. Command can differ in IM/GM, IM also know the -strip command. See <a href="http://www.imagemagick.org/Usage/thumbnails/#profiles" target="_blank" rel="noreferrer">imagemagick.org</a> for details'
         processor_colorspace:
             type: text
             description: 'String: Specify the colorspace to use. Some ImageMagick versions (like 6.7.0 and above) use the sRGB colorspace, so all images are darker then the original. <br />Possible Values: CMY, CMYK, Gray, HCL, HSB, HSL, HWB, Lab, LCH, LMS, Log, Luv, OHTA, Rec601Luma, Rec601YCbCr, Rec709Luma, Rec709YCbCr, RGB, sRGB, Transparent, XYZ, YCbCr, YCC, YIQ, YCbCr, YUV'
@@ -377,6 +378,7 @@ BE:
             description: 'Content-Security-Policy reporting HTTP endpoint, if empty system default will be used'
         fileDenyPattern:
             type: text
+            readonly: true
             description: 'A perl-compatible and JavaScript-compatible regular expression (without delimiters "/"!) that - if it matches a filename - will deny the file upload/rename or whatever. For security reasons, files with multiple extensions have to be denied on an Apache environment with mod_alias, if the filename contains a valid php handler in an arbitrary position. Also, ".htaccess" files have to be denied. Matching is done case-insensitive. Default value is stored in PHP constant FILE_DENY_PATTERN_DEFAULT'
         flexformForceCDATA:
             type: bool
@@ -627,6 +629,7 @@ MAIL:
         transport_sendmail_command:
             type: text
             description: '<em>only with transport=sendmail</em>: The command to call to send a mail locally.'
+            readonly: true
         transport_mbox_file:
             type: text
             description: '<em>only with transport=mbox</em>: The file where to write the mails into. This file will be conforming the mbox format described in RFC 4155. It is a simple text file with a concatenation of all mails. Path must be absolute.'
diff --git a/typo3/sysext/core/Documentation/Changelog/11.5.x/Important-102799-TYPO3_CONF_VARSGFXprocessor_stripColorProfileParametersOptionAdded.rst b/typo3/sysext/core/Documentation/Changelog/11.5.x/Important-102799-TYPO3_CONF_VARSGFXprocessor_stripColorProfileParametersOptionAdded.rst
new file mode 100644
index 000000000000..47155119947a
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/11.5.x/Important-102799-TYPO3_CONF_VARSGFXprocessor_stripColorProfileParametersOptionAdded.rst
@@ -0,0 +1,40 @@
+.. include:: /Includes.rst.txt
+
+.. _important-102799-1707403491:
+
+===========================================================================================
+Important: #102799 - TYPO3_CONF_VARS.GFX.processor_stripColorProfileParameters option added
+===========================================================================================
+
+See :issue:`102799`
+
+Description
+===========
+
+The string-based configuration option
+:php:`$GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileCommand']`
+has been superseded by
+:php:`$GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileParameters']`
+for security reasons.
+
+The former option expected a string of command line parameters. The defined
+parameters had to be shell-escaped beforehand, while the new option expects an
+array of strings that will be shell-escaped by TYPO3 when used.
+
+The existing configuration will continue to be supported. Still, it is suggested
+to use the new configuration format, as the Install Tool is adapted to allow
+modification of the new configuration option only:
+
+..  code-block:: php
+
+    // Before
+    $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileCommand'] = '+profile \'*\'';
+
+    // After
+    $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileParameters'] = [
+        '+profile',
+        '*'
+    ];
+
+
+.. index:: LocalConfiguration, ext:core
diff --git a/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php b/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php
index e09640db3227..e3cd55a3cc35 100644
--- a/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php
+++ b/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php
@@ -3821,7 +3821,7 @@ class ContentObjectRenderer implements LoggerAwareInterface
                 }
 
                 // Possibility to cancel/force profile extraction
-                // see $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileCommand']
+                // see $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileParameters']
                 if (isset($fileArray['stripProfile'])) {
                     $processingConfiguration['stripProfile'] = $fileArray['stripProfile'];
                 }
diff --git a/typo3/sysext/install/Classes/Configuration/AbstractCustomPreset.php b/typo3/sysext/install/Classes/Configuration/AbstractCustomPreset.php
index 89d7caa38725..dbdcf060baf2 100644
--- a/typo3/sysext/install/Classes/Configuration/AbstractCustomPreset.php
+++ b/typo3/sysext/install/Classes/Configuration/AbstractCustomPreset.php
@@ -70,17 +70,29 @@ abstract class AbstractCustomPreset extends AbstractPreset
     }
 
     /**
-     * Get configuration values is used in fluid to show configuration options.
+     * Get configuration values is used to persist data and is merged with given $postValues.
+     *
+     * @return array Configuration values needed to activate prefix
+     */
+    public function getConfigurationValues()
+    {
+        return array_map(static fn($configuration) => $configuration['value'], $this->getConfigurationDescriptors());
+    }
+
+    /**
+     * Build configuration descriptors to be used in fluid to show configuration options.
      * They are fetched from LocalConfiguration / DefaultConfiguration and
      * merged with given $postValues.
      *
      * @return array Configuration values needed to activate prefix
      */
-    public function getConfigurationValues()
+    public function getConfigurationDescriptors()
     {
         $configurationValues = [];
         foreach ($this->configurationValues as $configurationKey => $configurationValue) {
-            if (isset($this->postValues['enable'])
+            $readonly = isset($this->readonlyConfigurationValues[$configurationKey]);
+            if (!$readonly
+                && isset($this->postValues['enable'])
                 && $this->postValues['enable'] === $this->name
                 && isset($this->postValues[$this->name][$configurationKey])
             ) {
@@ -88,7 +100,10 @@ abstract class AbstractCustomPreset extends AbstractPreset
             } else {
                 $currentValue = $this->configurationManager->getConfigurationValueByPath($configurationKey);
             }
-            $configurationValues[$configurationKey] = $currentValue;
+            $configurationValues[$configurationKey] = [
+                'value' => $currentValue,
+                'readonly' => $readonly,
+            ];
         }
         return $configurationValues;
     }
diff --git a/typo3/sysext/install/Classes/Configuration/AbstractPreset.php b/typo3/sysext/install/Classes/Configuration/AbstractPreset.php
index 57e4a182da0d..3efd0c95859f 100644
--- a/typo3/sysext/install/Classes/Configuration/AbstractPreset.php
+++ b/typo3/sysext/install/Classes/Configuration/AbstractPreset.php
@@ -45,6 +45,11 @@ abstract class AbstractPreset implements PresetInterface
      */
     protected $configurationValues = [];
 
+    /**
+     * @var array Configuration values that are visible but not editable via presets GUI
+     */
+    protected $readonlyConfigurationValues = [];
+
     /**
      * @var array List of $POST values
      */
diff --git a/typo3/sysext/install/Classes/Configuration/Image/CustomPreset.php b/typo3/sysext/install/Classes/Configuration/Image/CustomPreset.php
index 67f3eeddb10b..3336135d988e 100644
--- a/typo3/sysext/install/Classes/Configuration/Image/CustomPreset.php
+++ b/typo3/sysext/install/Classes/Configuration/Image/CustomPreset.php
@@ -35,4 +35,8 @@ class CustomPreset extends AbstractCustomPreset implements CustomPresetInterface
         'GFX/processor_allowTemporaryMasksAsPng' => true,
         'GFX/processor_colorspace' => '',
     ];
+
+    protected $readonlyConfigurationValues = [
+        'GFX/processor_path' => true,
+    ];
 }
diff --git a/typo3/sysext/install/Classes/Configuration/Mail/CustomPreset.php b/typo3/sysext/install/Classes/Configuration/Mail/CustomPreset.php
index a6866752a378..88e2bb22f628 100644
--- a/typo3/sysext/install/Classes/Configuration/Mail/CustomPreset.php
+++ b/typo3/sysext/install/Classes/Configuration/Mail/CustomPreset.php
@@ -35,4 +35,8 @@ class CustomPreset extends AbstractCustomPreset implements CustomPresetInterface
         'MAIL/transport_smtp_username' => '',
         'MAIL/transport_smtp_password' => '',
     ];
+
+    protected $readonlyConfigurationValues = [
+        'MAIL/transport_sendmail_command' => true,
+    ];
 }
diff --git a/typo3/sysext/install/Classes/Configuration/PasswordHashing/CustomPreset.php b/typo3/sysext/install/Classes/Configuration/PasswordHashing/CustomPreset.php
index cf1a0a433a31..8e01fb67b48a 100644
--- a/typo3/sysext/install/Classes/Configuration/PasswordHashing/CustomPreset.php
+++ b/typo3/sysext/install/Classes/Configuration/PasswordHashing/CustomPreset.php
@@ -32,22 +32,35 @@ class CustomPreset extends AbstractCustomPreset implements CustomPresetInterface
      * Get configuration values is used in fluid to show configuration options.
      * They are fetched from LocalConfiguration / DefaultConfiguration.
      *
+     * They are not merged with postValues for security reasons, as
+     * all options are readonly.
+     *
      * @return array Current custom configuration values
      */
-    public function getConfigurationValues(): array
+    public function getConfigurationDescriptors(): array
     {
         $configurationValues = [];
-        $configurationValues['BE/passwordHashing/className'] =
-            $this->configurationManager->getConfigurationValueByPath('BE/passwordHashing/className');
+        $configurationValues['BE/passwordHashing/className'] = [
+            'value' => $this->configurationManager->getConfigurationValueByPath('BE/passwordHashing/className'),
+            'readonly' => true,
+        ];
         $options = (array)$this->configurationManager->getConfigurationValueByPath('BE/passwordHashing/options');
         foreach ($options as $optionName => $optionValue) {
-            $configurationValues['BE/passwordHashing/options/' . $optionName] = $optionValue;
+            $configurationValues['BE/passwordHashing/options/' . $optionName] = [
+                'value' => $optionValue,
+                'readonly' => true,
+            ];
         }
-        $configurationValues['FE/passwordHashing/className'] =
-            $this->configurationManager->getConfigurationValueByPath('FE/passwordHashing/className');
+        $configurationValues['FE/passwordHashing/className'] = [
+            'value' => $this->configurationManager->getConfigurationValueByPath('FE/passwordHashing/className'),
+            'readonly' => true,
+        ];
         $options = (array)$this->configurationManager->getConfigurationValueByPath('FE/passwordHashing/options');
         foreach ($options as $optionName => $optionValue) {
-            $configurationValues['FE/passwordHashing/options/' . $optionName] = $optionValue;
+            $configurationValues['FE/passwordHashing/options/' . $optionName] = [
+                'value' => $optionValue,
+                'readonly' => true,
+            ];
         }
         return $configurationValues;
     }
diff --git a/typo3/sysext/install/Classes/Service/LocalConfigurationValueService.php b/typo3/sysext/install/Classes/Service/LocalConfigurationValueService.php
index d89195699e1e..4f8068fa570e 100644
--- a/typo3/sysext/install/Classes/Service/LocalConfigurationValueService.php
+++ b/typo3/sysext/install/Classes/Service/LocalConfigurationValueService.php
@@ -21,6 +21,8 @@ use TYPO3\CMS\Core\Configuration\ConfigurationManager;
 use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
 use TYPO3\CMS\Core\Messaging\FlashMessage;
 use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
+use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
+use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
 /**
@@ -90,6 +92,7 @@ class LocalConfigurationValueService
                 $itemData['path'] = '[' . implode('][', $newPath) . ']';
                 $itemData['fieldType'] = $descriptionInfo['type'];
                 $itemData['description'] = $descriptionInfo['description'] ?? '';
+                $itemData['readonly'] = $descriptionInfo['readonly'] ?? false;
                 $itemData['allowedValues'] = $descriptionInfo['allowedValues'] ?? [];
                 $itemData['differentValueInCurrentConfiguration'] = (!isset($descriptionInfo['compareValuesWithCurrentConfiguration']) ||
                     $descriptionInfo['compareValuesWithCurrentConfiguration']) &&
@@ -151,11 +154,28 @@ class LocalConfigurationValueService
         $commentArray = $this->getDefaultConfigArrayComments();
         $configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class);
         foreach ($valueList as $path => $value) {
-            $oldValue = $configurationManager->getConfigurationValueByPath($path);
+            try {
+                $oldValue = $configurationManager->getConfigurationValueByPath($path);
+            } catch (MissingArrayPathException) {
+                $messageQueue->enqueue(new FlashMessage(
+                    'Update rejected, the category of this setting does not exist',
+                    $path,
+                    ContextualFeedbackSeverity::ERROR
+                ));
+                continue;
+            }
             $pathParts = explode('/', $path);
             $descriptionData = $commentArray[$pathParts[0]];
 
             while ($part = next($pathParts)) {
+                if (!isset($descriptionData['items'][$part])) {
+                    $messageQueue->enqueue(new FlashMessage(
+                        'Update rejected, this setting is not writable',
+                        $path,
+                        ContextualFeedbackSeverity::ERROR
+                    ));
+                    continue 2;
+                }
                 $descriptionData = $descriptionData['items'][$part];
             }
 
@@ -183,6 +203,16 @@ class LocalConfigurationValueService
                 $valueHasChanged = (string)$oldValue !== (string)$value;
             }
 
+            $readonly = $descriptionData['readonly'] ?? false;
+            if ($readonly && $valueHasChanged) {
+                $messageQueue->enqueue(new FlashMessage(
+                    'Update rejected, this setting is readonly',
+                    $path,
+                    ContextualFeedbackSeverity::ERROR
+                ));
+                continue;
+            }
+
             // Save if value changed
             if ($valueHasChanged) {
                 $configurationPathValuePairs[$path] = $value;
diff --git a/typo3/sysext/install/Resources/Private/Partials/Settings/LocalConfiguration/SubSection.html b/typo3/sysext/install/Resources/Private/Partials/Settings/LocalConfiguration/SubSection.html
index 0128fba88c06..af67289832bb 100644
--- a/typo3/sysext/install/Resources/Private/Partials/Settings/LocalConfiguration/SubSection.html
+++ b/typo3/sysext/install/Resources/Private/Partials/Settings/LocalConfiguration/SubSection.html
@@ -32,6 +32,7 @@
                             </f:if>
                         </div>
                         <div class="localconf-item-body">
+                            <f:render partial="Settings/ReadonlyInfo" arguments="{configuration: item}" />
                             <f:if condition="{item.differentValueInCurrentConfiguration}">
                                 <div class="t3js-infobox callout callout-warning">
                                     <div class="callout-body">
@@ -44,7 +45,9 @@
                                 <f:then>
                                     <div class="form-group">
                                         <div class="form-description">{item.description -> f:sanitize.html()}</div>
-                                        <select data-path="{sectionName}/{item.key}" class="t3-install-form-input-text form-select t3js-localConfiguration-pathValue" {f:if(condition: '!{isWritable}', then: 'disabled')}>
+                                        <select data-path="{sectionName}/{item.key}" class="t3-install-form-input-text form-select t3js-localConfiguration-pathValue"
+                                            {f:if(condition: '!{isWritable} || {item.readonly}', then: 'disabled')}
+                                            >
                                             <f:for each="{item.allowedValues}" key="optionKey" as="optionLabel">
                                                 <option value="{optionKey}" {f:if(condition: '{item.value} == {optionKey}', then: 'selected="selected"')}>{optionLabel} ({optionKey})</option>
                                             </f:for>
@@ -62,7 +65,7 @@
                                                 id="{sectionName}_{item.key}"
                                                 data-path="{sectionName}/{item.key}"
                                                 {f:if(condition: item.checked, then:'checked="checked"')}
-                                                {f:if(condition: '!{isWritable}', then: 'disabled')}
+                                                {f:if(condition: '!{isWritable} || {item.readonly}', then: 'disabled')}
                                             />
                                             <label class="form-check-label" for="{sectionName}_{item.key}">
                                                 {item.description -> f:sanitize.html()}
@@ -81,7 +84,7 @@
                                                 data-path="{sectionName}/{item.key}"
                                                 class="t3-install-form-input-text form-control t3js-localConfiguration-pathValue"
                                                 autocomplete="off"
-                                                {f:if(condition: '!{isWritable}', then: 'disabled')}
+                                                {f:if(condition: '!{isWritable} || {item.readonly}', then: 'disabled')}
                                             />
                                         </div>
                                     </f:if>
@@ -97,7 +100,7 @@
                                                 data-path="{sectionName}/{item.key}"
                                                 class="t3-install-form-input-text form-control t3js-localConfiguration-pathValue"
                                                 autocomplete="new-password"
-                                                {f:if(condition: '!{isWritable}', then: 'disabled')}
+                                                {f:if(condition: '!{isWritable} || {item.readonly}', then: 'disabled')}
                                             />
                                         </div>
                                     </f:if>
@@ -114,7 +117,7 @@
                                                 class="t3-
                                                 install-form-input-text form-control t3js-localConfiguration-pathValue"
                                                 autocomplete="off"
-                                                {f:if(condition: '!{isWritable}', then: 'disabled')}
+                                                {f:if(condition: '!{isWritable} || {item.readonly}', then: 'disabled')}
                                             />
                                         </div>
                                     </f:if>
@@ -129,7 +132,7 @@
                                                 cols="60"
                                                 data-path="{sectionName}/{item.key}"
                                                 class="form-control t3js-localConfiguration-pathValue"
-                                                {f:if(condition: '!{isWritable}', then: 'disabled')}
+                                                {f:if(condition: '!{isWritable} || {item.readonly}', then: 'disabled')}
                                             >{item.value}</textarea>
                                         </div>
                                     </f:if>
diff --git a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Cache/Custom.html b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Cache/Custom.html
index 91e717f859a6..ee7f283b36e2 100644
--- a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Cache/Custom.html
+++ b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Cache/Custom.html
@@ -16,18 +16,19 @@
         </label>
     </div>
     <p>Custom cache settings:</p>
-    <f:for each="{preset.configurationValues}" as="configurationValue" key="configurationKey">
+    <f:for each="{preset.configurationDescriptors}" as="configuration" key="configurationKey">
         <div class="row mb-3">
             <label class="col-sm-6 col-form-label" for="{feature.name}{preset.name}{configurationKey}">{configurationKey}</label>
             <div class="col-sm-6">
+                <f:render partial="Settings/ReadonlyInfo" arguments="{configuration: configuration}" />
                 <input
                     id="{feature.name}{preset.name}{configurationKey}"
                     type="text"
                     name="install[values][{feature.name}][{preset.name}][{configurationKey}]"
-                    value="{configurationValue}"
+                    value="{configuration.value}"
                     class="form-control t3js-custom-preset"
                     data-radio="t3-install-tool-configuration-cache-custom"
-                    {f:if(condition: '!{isWritable}', then: 'disabled')}
+                    {f:if(condition: '!{isWritable} || {configuration.readonly}', then: 'disabled')}
                     />
             </div>
         </div>
diff --git a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Context/Custom.html b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Context/Custom.html
index 7638730b5908..a599f5f925a8 100644
--- a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Context/Custom.html
+++ b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Context/Custom.html
@@ -17,18 +17,19 @@
     </div>
     <p>Custom configuration mixture if no other preset fits.</p>
 
-    <f:for each="{preset.configurationValues}" as="configurationValue" key="configurationKey">
+    <f:for each="{preset.configurationDescriptors}" as="configuration" key="configurationKey">
         <div class="row mb-3">
             <label class="col-sm-4 col-form-label" for="{feature.name}{preset.name}{configurationKey}">{configurationKey}</label>
             <div class="col-sm-8">
+                <f:render partial="Settings/ReadonlyInfo" arguments="{configuration: configuration}" />
                 <input
                     id="{feature.name}{preset.name}{configurationKey}"
                     type="text"
                     name="install[values][{feature.name}][{preset.name}][{configurationKey}]"
-                    value="{configurationValue}"
+                    value="{configuration.value}"
                     class="form-control t3js-custom-preset"
                     data-radio="t3-install-tool-configuration-context-custom"
-                    {f:if(condition: '!{isWritable}', then: 'disabled')}
+                    {f:if(condition: '!{isWritable} || {configuration.readonly}', then: 'disabled')}
                 />
             </div>
         </div>
diff --git a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Image/Custom.html b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Image/Custom.html
index 07db6d9c57d6..9d9e4a3cd8a6 100644
--- a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Image/Custom.html
+++ b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Image/Custom.html
@@ -18,18 +18,19 @@
 
     <p>Custom configuration mixture if no other preset fits.</p>
 
-    <f:for each="{preset.configurationValues}" as="configurationValue" key="configurationKey">
+    <f:for each="{preset.configurationDescriptors}" as="configuration" key="configurationKey">
         <div class="row mb-3">
             <label class="col-sm-4 col-form-label" for="{feature.name}{preset.name}{configurationKey}">{configurationKey}</label>
             <div class="col-sm-8">
+                <f:render partial="Settings/ReadonlyInfo" arguments="{configuration: configuration}" />
                 <input
                     id="{feature.name}{preset.name}{configurationKey}"
                     type="text"
                     name="install[values][{feature.name}][{preset.name}][{configurationKey}]"
-                    value="{configurationValue}"
+                    value="{configuration.value}"
                     class="form-control t3js-custom-preset"
                     data-radio="t3-install-tool-configuration-image-custom"
-                    {f:if(condition: '!{isWritable}', then: 'disabled')}
+                    {f:if(condition: '!{isWritable} || {configuration.readonly}', then: 'disabled')}
                 />
             </div>
         </div>
diff --git a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Mail/Custom.html b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Mail/Custom.html
index ebfa27c7a46c..3e8d52027548 100644
--- a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Mail/Custom.html
+++ b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/Mail/Custom.html
@@ -16,19 +16,21 @@
         </label>
     </div>
     <p>Custom mail settings:</p>
-    <f:for each="{preset.configurationValues}" as="configurationValue" key="configurationKey">
+
+    <f:for each="{preset.configurationDescriptors}" as="configuration" key="configurationKey">
         <div class="row mb-3">
             <label class="col-sm-6 col-form-label" for="{feature.name}{preset.name}{configurationKey}">{configurationKey}</label>
             <div class="col-sm-6">
+                <f:render partial="Settings/ReadonlyInfo" arguments="{configuration: configuration}" />
                 <input
                     id="{feature.name}{preset.name}{configurationKey}"
                     type="{f:if(condition: '{configurationKey} == "MAIL/transport_smtp_password"', then: 'password', else: 'text')}"
                     autocomplete="{f:if(condition: '{configurationKey} == "MAIL/transport_smtp_password"', then: 'new-password', else: 'off')}"
                     name="install[values][{feature.name}][{preset.name}][{configurationKey}]"
-                    value="{configurationValue}"
+                    value="{configuration.value}"
                     class="form-control t3js-custom-preset"
                     data-radio="t3-install-tool-configuration-mail-custom"
-                    {f:if(condition: '!{isWritable}', then: 'disabled')}
+                    {f:if(condition: '!{isWritable} || {configuration.readonly}', then: 'disabled')}
                     />
             </div>
         </div>
diff --git a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/PasswordHashing/Custom.html b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/PasswordHashing/Custom.html
index 5c26c80931f1..59cf77451f44 100644
--- a/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/PasswordHashing/Custom.html
+++ b/typo3/sysext/install/Resources/Private/Partials/Settings/Presets/PasswordHashing/Custom.html
@@ -18,19 +18,19 @@
     <p>Custom password hash settings. This interface does not allow modification of the values, they are just shown.
         Configuring custom hash settings is for advanced users who know exactly what they are doing. Refer to the
         core documentation for details.</p>
-    <f:for each="{preset.configurationValues}" as="configurationValue" key="configurationKey">
+    <f:for each="{preset.configurationDescriptors}" as="configuration" key="configurationKey">
         <div class="row mb-3">
             <label class="col-sm-6 col-form-label" for="{feature.name}{preset.name}{configurationKey}">{configurationKey}</label>
             <div class="col-sm-6">
+                <f:render partial="Settings/ReadonlyInfo" arguments="{configuration: configuration}" />
                 <input
                     id="{feature.name}{preset.name}{configurationKey}"
                     type="text"
                     name="install[values][{feature.name}][{preset.name}][{configurationKey}]"
-                    value="{configurationValue}"
-                    disabled="disabled"
+                    value="{configuration.value}"
                     class="form-control t3js-custom-preset"
                     data-radio="t3-install-tool-configuration-passwordHashing-custom"
-                    {f:if(condition: '!{isWritable}', then: 'disabled')}
+                    {f:if(condition: '!{isWritable} || {configuration.readonly}', then: 'disabled')}
                     />
             </div>
         </div>
diff --git a/typo3/sysext/install/Resources/Private/Partials/Settings/ReadonlyInfo.html b/typo3/sysext/install/Resources/Private/Partials/Settings/ReadonlyInfo.html
new file mode 100644
index 000000000000..5cc452328353
--- /dev/null
+++ b/typo3/sysext/install/Resources/Private/Partials/Settings/ReadonlyInfo.html
@@ -0,0 +1,10 @@
+<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
+    <f:if condition="{configuration.readonly}">
+        <div class="t3js-infobox callout callout-info">
+            <div class="callout-body">
+                For security reasons, this option cannot be changed here.<br>
+                Please configure via <code>system/settings.php</code> or <code>system/additional.php</code>.
+            </div>
+        </div>
+    </f:if>
+</html>
-- 
GitLab