From 83874f2bcf32414048cc09df1571169ac645659b Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
Date: Fri, 1 Mar 2024 14:02:18 +0000
Subject: [PATCH] Issue #3412361 by Wim Leers, phenaproxima, catch,
 effulgentsia: Mark Editor config schema as fully validatable

---
 .../Core/Config/Schema/SchemaCheckTrait.php   |   7 +
 .../Controller/CKEditor5ImageController.php   |   4 +-
 .../src/Plugin/CKEditor5Plugin/Image.php      |  17 +-
 .../ckeditor5/src/Plugin/Editor/CKEditor5.php |  14 ++
 ...extEditorObjectDependentValidatorTrait.php |   7 +-
 .../src/Functional/ImageUploadAccessTest.php  |  19 +--
 .../CKEditor5UpdateImageToolbarItemTest.php   |   9 +-
 .../src/FunctionalJavascript/AdminUiTest.php  |  49 ++++++
 .../FunctionalJavascript/CKEditor5Test.php    |   5 +
 .../src/Kernel/CKEditor5PluginManagerTest.php |   5 +-
 .../tests/src/Kernel/ValidatorsTest.php       |  23 ++-
 .../editor/config/schema/editor.schema.yml    |  96 ++++++++---
 core/modules/editor/editor.module             |  48 ++++++
 core/modules/editor/editor.post_update.php    |  23 +++
 core/modules/editor/src/Entity/Editor.php     |  15 ++
 .../editor/src/Form/EditorImageDialog.php     |  21 +--
 .../tests/fixtures/update/editor-3412361.php  |  41 +++++
 .../update/editor.editor.umami_basic_html.yml |  64 +++++++
 .../update/filter.format.umami_basic_html.yml |  55 ++++++
 .../src/Functional/EditorDialogAccessTest.php |  19 ++-
 .../Rest/EditorResourceTestBase.php           |   6 +-
 ...rSanitizeImageUploadSettingsUpdateTest.php | 104 ++++++++++++
 .../src/Kernel/EditorImageDialogTest.php      |   4 +
 .../tests/src/Kernel/EditorValidationTest.php | 157 +++++++++++++++++-
 .../tests/src/Functional/EditorTest.php       |   8 +-
 .../install/editor.editor.basic_html.yml      |   6 -
 .../install/editor.editor.full_html.yml       |   2 +-
 .../install/editor.editor.basic_html.yml      |   6 +-
 .../install/editor.editor.full_html.yml       |   6 +-
 .../Tests/Component/Utility/BytesTest.php     |   1 +
 30 files changed, 751 insertions(+), 90 deletions(-)
 create mode 100644 core/modules/editor/tests/fixtures/update/editor-3412361.php
 create mode 100644 core/modules/editor/tests/fixtures/update/editor.editor.umami_basic_html.yml
 create mode 100644 core/modules/editor/tests/fixtures/update/filter.format.umami_basic_html.yml
 create mode 100644 core/modules/editor/tests/src/Functional/Update/EditorSanitizeImageUploadSettingsUpdateTest.php

diff --git a/core/lib/Drupal/Core/Config/Schema/SchemaCheckTrait.php b/core/lib/Drupal/Core/Config/Schema/SchemaCheckTrait.php
index 88682009ae22..fbc1edec5d85 100644
--- a/core/lib/Drupal/Core/Config/Schema/SchemaCheckTrait.php
+++ b/core/lib/Drupal/Core/Config/Schema/SchemaCheckTrait.php
@@ -52,6 +52,13 @@ trait SchemaCheckTrait {
         'This value should not be blank.',
       ],
     ],
+    'editor.editor.*' => [
+      // @todo Fix stream wrappers not being available early enough in
+      //   https://www.drupal.org/project/drupal/issues/3416735
+      'image_upload.scheme' => [
+        '^The file storage you selected is not a visible, readable and writable stream wrapper\. Possible choices: <em class="placeholder"><\/em>\.$',
+      ],
+    ],
   ];
 
   /**
diff --git a/core/modules/ckeditor5/src/Controller/CKEditor5ImageController.php b/core/modules/ckeditor5/src/Controller/CKEditor5ImageController.php
index 1a9cc15d1fb7..43f4d9b44cfc 100644
--- a/core/modules/ckeditor5/src/Controller/CKEditor5ImageController.php
+++ b/core/modules/ckeditor5/src/Controller/CKEditor5ImageController.php
@@ -175,7 +175,9 @@ public function upload(Request $request): Response {
    * Gets the image upload validators.
    */
   protected function getImageUploadValidators(array $settings): array {
-    $max_filesize = min(Bytes::toNumber($settings['max_size']), Environment::getUploadMaxSize());
+    $max_filesize = $settings['max_size']
+      ? Bytes::toNumber($settings['max_size'])
+      : Environment::getUploadMaxSize();
     $max_dimensions = 0;
     if (!empty($settings['max_dimensions']['width']) || !empty($settings['max_dimensions']['height'])) {
       $max_dimensions = $settings['max_dimensions']['width'] . 'x' . $settings['max_dimensions']['height'];
diff --git a/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Image.php b/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Image.php
index d897abd265f2..c243eaec6979 100644
--- a/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Image.php
+++ b/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Image.php
@@ -63,16 +63,27 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta
    */
   public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
     $form_state->setValue('status', (bool) $form_state->getValue('status'));
-    $form_state->setValue(['max_dimensions', 'width'], (int) $form_state->getValue(['max_dimensions', 'width']));
-    $form_state->setValue(['max_dimensions', 'height'], (int) $form_state->getValue(['max_dimensions', 'height']));
+    $directory = $form_state->getValue(['directory']);
+    $form_state->setValue(['directory'], trim($directory) === '' ? NULL : $directory);
+    $max_size = $form_state->getValue(['max_size']);
+    $form_state->setValue(['max_size'], trim($max_size) === '' ? NULL : $max_size);
+    $max_width = $form_state->getValue(['max_dimensions', 'width']);
+    $form_state->setValue(['max_dimensions', 'width'], trim($max_width) === '' ? NULL : (int) $max_width);
+    $max_height = $form_state->getValue(['max_dimensions', 'height']);
+    $form_state->setValue(['max_dimensions', 'height'], trim($max_height) === '' ? NULL : (int) $max_height);
   }
 
   /**
    * {@inheritdoc}
    */
   public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $settings = $form_state->getValues();
+    if (!$settings['status']) {
+      // Remove all other settings to comply with config schema.
+      $settings = ['status' => FALSE];
+    }
     // Store this configuration in its out-of-band location.
-    $form_state->get('editor')->setImageUploadSettings($form_state->getValues());
+    $form_state->get('editor')->setImageUploadSettings($settings);
   }
 
   /**
diff --git a/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php b/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php
index b12841651f81..87d46bcc5e32 100644
--- a/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php
+++ b/core/modules/ckeditor5/src/Plugin/Editor/CKEditor5.php
@@ -660,7 +660,13 @@ public function validateConfigurationForm(array &$form, FormStateInterface $form
     // All plugin settings have been collected, including defaults that depend
     // on visibility. Store the collected settings, throw away the interim state
     // that allowed determining which defaults to add.
+    // Create a new clone, because the plugins whose data is being stored
+    // out-of-band may have modified the Text Editor config entity in the form
+    // state.
+    // @see \Drupal\editor\EditorInterface::setImageUploadSettings()
+    // @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image::submitConfigurationForm()
     unset($eventual_editor_and_format_for_plugin_settings_visibility);
+    $submitted_editor = clone $form_state->get('editor');
     $submitted_editor->setSettings($settings);
 
     // Validate the text editor + text format pair.
@@ -903,6 +909,14 @@ protected static function mapPairViolationPropertyPathsToFormNames(string $prope
       return implode('][', array_merge(explode('.', $property_path), ['settings']));
     }
 
+    // Image upload settings are stored out-of-band and may also trigger
+    // validation errors.
+    // @see \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image
+    if (str_starts_with($property_path, 'image_upload.')) {
+      $image_upload_setting_property_path = str_replace('image_upload.', '', $property_path);
+      return 'editor][settings][plugins][ckeditor5_image][' . implode('][', explode('.', $image_upload_setting_property_path));
+    }
+
     // Everything else is in the subform.
     return 'editor][' . static::mapViolationPropertyPathsToFormNames($property_path, $form);
   }
diff --git a/core/modules/ckeditor5/src/Plugin/Validation/Constraint/TextEditorObjectDependentValidatorTrait.php b/core/modules/ckeditor5/src/Plugin/Validation/Constraint/TextEditorObjectDependentValidatorTrait.php
index 63bf2f0dad60..9b334f742baa 100644
--- a/core/modules/ckeditor5/src/Plugin/Validation/Constraint/TextEditorObjectDependentValidatorTrait.php
+++ b/core/modules/ckeditor5/src/Plugin/Validation/Constraint/TextEditorObjectDependentValidatorTrait.php
@@ -29,8 +29,13 @@ private function createTextEditorObjectFromContext(): EditorInterface {
       ]);
     }
     else {
-      assert($this->context->getRoot()->getDataDefinition()->getDataType() === 'editor.editor.*');
+      assert(in_array($this->context->getRoot()->getDataDefinition()->getDataType(), ['editor.editor.*', 'entity:editor'], TRUE));
       $text_format = FilterFormat::load($this->context->getRoot()->get('format')->getValue());
+      // This validator must not complain about a missing text format.
+      // @see \Drupal\Tests\editor\Kernel\EditorValidationTest::testInvalidFormat()
+      if ($text_format === NULL) {
+        $text_format = FilterFormat::create([]);
+      }
     }
     assert($text_format instanceof FilterFormatInterface);
 
diff --git a/core/modules/ckeditor5/tests/src/Functional/ImageUploadAccessTest.php b/core/modules/ckeditor5/tests/src/Functional/ImageUploadAccessTest.php
index de8bb0e6578f..5b224460e248 100644
--- a/core/modules/ckeditor5/tests/src/Functional/ImageUploadAccessTest.php
+++ b/core/modules/ckeditor5/tests/src/Functional/ImageUploadAccessTest.php
@@ -27,8 +27,14 @@ public function testCkeditor5ImageUploadRoute() {
     $response = $this->uploadRequest($url, $test_image, 'test.jpg');
     $this->assertSame(404, $response->getStatusCode());
 
-    $editor = $this->createEditorWithUpload([
-      'status' => FALSE,
+    $editor = $this->createEditorWithUpload(['status' => FALSE]);
+
+    // Ensure that images cannot be uploaded when image upload is disabled.
+    $response = $this->uploadRequest($url, $test_image, 'test.jpg');
+    $this->assertSame(403, $response->getStatusCode());
+
+    $editor->setImageUploadSettings([
+      'status' => TRUE,
       'scheme' => 'public',
       'directory' => 'inline-images',
       'max_size' => '',
@@ -36,14 +42,7 @@ public function testCkeditor5ImageUploadRoute() {
         'width' => 0,
         'height' => 0,
       ],
-    ]);
-
-    // Ensure that images cannot be uploaded when image upload is disabled.
-    $response = $this->uploadRequest($url, $test_image, 'test.jpg');
-    $this->assertSame(403, $response->getStatusCode());
-
-    $editor->setImageUploadSettings(['status' => TRUE] + $editor->getImageUploadSettings())
-      ->save();
+    ])->save();
     $response = $this->uploadRequest($url, $test_image, 'test.jpg');
     $this->assertSame(201, $response->getStatusCode());
 
diff --git a/core/modules/ckeditor5/tests/src/Functional/Update/CKEditor5UpdateImageToolbarItemTest.php b/core/modules/ckeditor5/tests/src/Functional/Update/CKEditor5UpdateImageToolbarItemTest.php
index 483ebd6bf2bb..82379a24d877 100644
--- a/core/modules/ckeditor5/tests/src/Functional/Update/CKEditor5UpdateImageToolbarItemTest.php
+++ b/core/modules/ckeditor5/tests/src/Functional/Update/CKEditor5UpdateImageToolbarItemTest.php
@@ -138,7 +138,14 @@ public function test(bool $filter_html_is_enabled, bool $image_uploads_are_enabl
       function (ConstraintViolation $v) {
         return (string) $v->getMessage();
       },
-      iterator_to_array(CKEditor5::validatePair($editor_after, $filter_format_after))
+      // @todo Fix stream wrappers not being available early enough in
+      //   https://www.drupal.org/project/drupal/issues/3416735. Then remove the
+      //   array_filter().
+      // @see \Drupal\Core\Config\Schema\SchemaCheckTrait::$ignoredPropertyPaths
+      array_filter(
+        iterator_to_array(CKEditor5::validatePair($editor_after, $filter_format_after)),
+        fn(ConstraintViolation $v) => $v->getMessage() != 'The file storage you selected is not a visible, readable and writable stream wrapper. Possible choices: <em class="placeholder"></em>.',
+      )
     ));
   }
 
diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/AdminUiTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/AdminUiTest.php
index c0b7a793194a..3bbf63a83090 100644
--- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/AdminUiTest.php
+++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/AdminUiTest.php
@@ -177,6 +177,55 @@ public function testFilterCheckboxesToggleSettings() {
     $this->assertFalse($media_tab->isVisible(), 'Media settings should be removed when media filter disabled');
   }
 
+  /**
+   * Tests that image upload settings (stored out of band) are validated too.
+   */
+  public function testImageUploadSettingsAreValidated(): void {
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+
+    $this->addNewTextFormat($page, $assert_session);
+    $this->drupalGet('admin/config/content/formats/manage/ckeditor5');
+
+    // Add the image plugin to the CKEditor 5 toolbar.
+    $this->assertNotEmpty($assert_session->waitForElement('css', '.ckeditor5-toolbar-item-drupalInsertImage'));
+    $this->triggerKeyUp('.ckeditor5-toolbar-item-drupalInsertImage', 'ArrowDown');
+    $assert_session->assertExpectedAjaxRequest(1);
+
+    // Open the vertical tab with its settings.
+    $page->find('css', '[href^="#edit-editor-settings-plugins-ckeditor5-image"]')->click();
+    $this->assertTrue($assert_session->waitForText('Enable image uploads'));
+
+    // Check the "Enable image uploads" checkbox.
+    $assert_session->checkboxNotChecked('editor[settings][plugins][ckeditor5_image][status]');
+    $page->checkField('editor[settings][plugins][ckeditor5_image][status]');
+    $assert_session->assertExpectedAjaxRequest(2);
+
+    // Enter a nonsensical maximum file size.
+    $page->fillField('editor[settings][plugins][ckeditor5_image][max_size]', 'foobar');
+    $this->assertNoRealtimeValidationErrors();
+
+    // Enable another toolbar item to trigger validation.
+    $this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowDown');
+    $assert_session->assertExpectedAjaxRequest(3);
+
+    // The expected validation error must be present.
+    $assert_session->elementExists('css', '[role=alert]:contains("This value must be a number of bytes, optionally with a unit such as "MB" or "megabytes".")');
+
+    // Enter no maximum file size because it is optional, this should result in
+    // no validation error and it being set to `null`.
+    $page->findField('editor[settings][plugins][ckeditor5_image][max_size]')->setValue('');
+
+    // Remove a toolbar item to trigger validation.
+    $this->triggerKeyUp('.ckeditor5-toolbar-item-sourceEditing', 'ArrowUp');
+    $assert_session->assertExpectedAjaxRequest(4);
+
+    // No more validation errors, let's save.
+    $this->assertNoRealtimeValidationErrors();
+    $page->pressButton('Save configuration');
+    $assert_session->pageTextContains('The text format ckeditor5 has been updated');
+  }
+
   /**
    * Ensure CKEditor 5 admin UI's real-time validation errors do not accumulate.
    */
diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php
index edc3b94576dc..3e17c31e00fa 100644
--- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php
+++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php
@@ -95,6 +95,10 @@ public function testAttributeEncoding() {
         'scheme' => 'public',
         'directory' => 'inline-images',
         'max_size' => '',
+        'max_dimensions' => [
+          'width' => NULL,
+          'height' => NULL,
+        ],
       ],
     ])->save();
     $this->assertSame([], array_map(
@@ -643,6 +647,7 @@ public function testListPlugin() {
               'reversed' => FALSE,
               'startIndex' => FALSE,
             ],
+            'multiBlock' => TRUE,
           ],
           'ckeditor5_sourceEditing' => [
             'allowed_tags' => [],
diff --git a/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php b/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php
index 1ae537c185b4..66a78c4f7cd5 100644
--- a/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php
+++ b/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php
@@ -1030,7 +1030,6 @@ public function testProvidedElementsInvalidElementSubset(array $configured_subse
           $sneaky_plugin_id => ['configured_subset' => $configured_subset],
         ],
       ],
-      'image_upload' => [],
     ]);
 
     // Invalid subsets are allowed on unsaved Text Editor config entities,
@@ -1257,7 +1256,9 @@ public function testProvidedElements(array $plugins, array $text_editor_settings
       'format' => 'dummy',
       'editor' => 'ckeditor5',
       'settings' => $text_editor_settings,
-      'image_upload' => [],
+      'image_upload' => [
+        'status' => FALSE,
+      ],
     ]);
     FilterFormat::create([
       'format' => 'dummy',
diff --git a/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php b/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php
index bb18c3cef653..dac6a4095107 100644
--- a/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php
+++ b/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php
@@ -86,7 +86,9 @@ public function test(array $ckeditor5_settings, array $expected_violations) {
       'format' => 'dummy',
       'editor' => 'ckeditor5',
       'settings' => $ckeditor5_settings,
-      'image_upload' => [],
+      'image_upload' => [
+        'status' => FALSE,
+      ],
     ]);
 
     $typed_config = $this->typedConfig->createFromNameAndData(
@@ -182,7 +184,10 @@ public static function provider(): array {
         ],
       ],
       'violations' => [
-        'settings.plugins.ckeditor5_language' => 'Configuration for the enabled plugin "<em class="placeholder">Language</em>" (<em class="placeholder">ckeditor5_language</em>) is missing.',
+        'settings.plugins.ckeditor5_language' => [
+          'Configuration for the enabled plugin "<em class="placeholder">Language</em>" (<em class="placeholder">ckeditor5_language</em>) is missing.',
+          "'language_list' is a required key because settings.plugins.%key is ckeditor5_language (see config schema type ckeditor5.plugin.ckeditor5_language).",
+        ],
       ],
     ];
     $data['valid language plugin configuration: un'] = [
@@ -1056,7 +1061,7 @@ public static function providerPair(): array {
         ],
       ],
       'image_upload' => [
-        'status' => TRUE,
+        'status' => FALSE,
       ],
       'filters' => [],
       'violations' => [
@@ -1102,7 +1107,7 @@ public static function providerPair(): array {
         ],
       ],
       'image_upload' => [
-        'status' => TRUE,
+        'status' => FALSE,
       ],
       'filters' => [],
       'violations' => [
@@ -1163,8 +1168,15 @@ public static function providerPair(): array {
           ],
         ],
       ],
-      'image' => [
+      'image_upload' => [
         'status' => TRUE,
+        'scheme' => 'public',
+        'directory' => 'inline-images',
+        'max_size' => NULL,
+        'max_dimensions' => [
+          'width' => NULL,
+          'height' => NULL,
+        ],
       ],
       'filters' => [],
       'violations' => [],
@@ -1621,7 +1633,6 @@ public function testMultipleHtmlRestrictingFilters(): void {
         ],
         'plugins' => [],
       ],
-      'image_upload' => [],
     ]);
 
     $this->assertSame([], $this->validatePairToViolationsArray($text_editor, $text_format, TRUE));
diff --git a/core/modules/editor/config/schema/editor.schema.yml b/core/modules/editor/config/schema/editor.schema.yml
index 0c0278b71551..ae035cc4cab0 100644
--- a/core/modules/editor/config/schema/editor.schema.yml
+++ b/core/modules/editor/config/schema/editor.schema.yml
@@ -7,6 +7,11 @@ editor.editor.*:
     format:
       type: string
       label: 'Name'
+      constraints:
+        # @see \Drupal\editor\Entity\Editor::getFilterFormat()
+        # @see \Drupal\editor\Entity\Editor::calculateDependencies()
+        ConfigExists:
+          prefix: 'filter.format.'
     editor:
       type: string
       label: 'Text editor'
@@ -17,30 +22,71 @@ editor.editor.*:
     settings:
       type: editor.settings.[%parent.editor]
     image_upload:
+      type: editor.image_upload_settings.[status]
+  constraints:
+    FullyValidatable: ~
+
+editor.image_upload_settings.*:
+  type: mapping
+  label: 'Image uploads'
+  constraints:
+    FullyValidatable: ~
+  mapping:
+    status:
+      type: boolean
+      label: 'Status'
+
+editor.image_upload_settings.1:
+  type: editor.image_upload_settings.*
+  label: 'Image upload settings'
+  constraints:
+    FullyValidatable: ~
+  mapping:
+    scheme:
+      type: string
+      label: 'File storage'
+      constraints:
+        Choice:
+          callback: \Drupal\editor\Entity\Editor::getValidStreamWrappers
+          message: 'The file storage you selected is not a visible, readable and writable stream wrapper. Possible choices: %choices.'
+    directory:
+      type: string
+      label: 'Upload directory'
+      nullable: true
+      constraints:
+        # `""` is not allowed, but `null` is.
+        NotBlank:
+          allowNull: true
+        Regex:
+          # Forbid any kind of control character.
+          # @see https://stackoverflow.com/a/66587087
+          pattern: '/([^\PC])/u'
+          match: false
+          message: 'The image upload directory is not allowed to span multiple lines or contain control characters.'
+    max_size:
+      # @see \Drupal\file\Plugin\Validation\Constraint\FileSizeLimitConstraintValidator
+      type: bytes
+      label: 'Maximum file size'
+      nullable: true
+    max_dimensions:
       type: mapping
-      label: 'Image upload settings'
+      label: 'Maximum dimensions'
       mapping:
-        status:
-          type: boolean
-          label: 'Status'
-        scheme:
-          type: string
-          label: 'File storage'
-        directory:
-          type: string
-          label: 'Upload directory'
-        max_size:
-          type: string
-          label: 'Maximum file size'
-        max_dimensions:
-          type: mapping
-          label: 'Maximum dimensions'
-          mapping:
-            width:
-              type: integer
-              nullable: true
-              label: 'Maximum width'
-            height:
-              type: integer
-              nullable: true
-              label: 'Maximum height'
+        width:
+          type: integer
+          nullable: true
+          label: 'Maximum width'
+          constraints:
+            Range:
+              # @see editor_image_upload_settings_form()
+              min: 1
+              max: 99999
+        height:
+          type: integer
+          nullable: true
+          label: 'Maximum height'
+          constraints:
+            Range:
+              # @see editor_image_upload_settings_form()
+              min: 1
+              max: 99999
diff --git a/core/modules/editor/editor.module b/core/modules/editor/editor.module
index d1f3c5c21a29..6925eea7b140 100644
--- a/core/modules/editor/editor.module
+++ b/core/modules/editor/editor.module
@@ -8,6 +8,7 @@
 use Drupal\Core\Url;
 use Drupal\Component\Utility\Html;
 use Drupal\Core\Form\SubformState;
+use Drupal\editor\EditorInterface;
 use Drupal\editor\Entity\Editor;
 use Drupal\Core\Entity\FieldableEntityInterface;
 use Drupal\Core\Field\FieldDefinitionInterface;
@@ -252,6 +253,15 @@ function editor_form_filter_admin_format_submit($form, FormStateInterface $form_
     if ($settings = $form_state->getValue(['editor', 'settings'])) {
       $editor->setSettings($settings);
     }
+    // When image uploads are disabled (status = FALSE), the schema for image
+    // upload settings does not allow other keys to be present.
+    // @see editor.image_upload_settings.*
+    // @see editor.image_upload_settings.1
+    // @see editor.schema.yml
+    $image_upload_settings = $editor->getImageUploadSettings();
+    if (!$image_upload_settings['status']) {
+      $editor->setImageUploadSettings(['status' => FALSE]);
+    }
     $editor->save();
   }
 }
@@ -641,3 +651,41 @@ function editor_filter_format_presave(FilterFormatInterface $format) {
     $editor->setStatus($status)->save();
   }
 }
+
+/**
+ * Implements hook_ENTITY_TYPE_presave().
+ */
+function editor_editor_presave(EditorInterface $editor) {
+  // @see editor_post_update_sanitize_image_upload_settings()
+  $image_upload_settings = $editor->getImageUploadSettings();
+  // When image uploads are disabled, then none of the other key-value pairs
+  // make sense.
+  // TRICKY: the configuration system has historically stored `type: boolean`
+  // not as `true` and `false`, but as `1` and `0`, so use `==`, not `===`.
+  // @see editor_post_update_sanitize_image_upload_settings()
+  if (!array_key_exists('status', $image_upload_settings) || $image_upload_settings['status'] == FALSE) {
+    $editor->setImageUploadSettings(['status' => FALSE]);
+  }
+  else {
+    // When image uploads are enabled, then some of the key-value pairs need
+    // some conversions to comply with the config schema. Note that all these
+    // keys SHOULD exist, but because validation has historically been absent,
+    // err on the side of caution.
+    // @see editor_post_update_sanitize_image_upload_settings()
+    if (array_key_exists('directory', $image_upload_settings) && $image_upload_settings['directory'] === '') {
+      $image_upload_settings['directory'] = NULL;
+    }
+    if (array_key_exists('max_size', $image_upload_settings) && $image_upload_settings['max_size'] === '') {
+      $image_upload_settings['max_size'] = NULL;
+    }
+    if (array_key_exists('max_dimensions', $image_upload_settings)) {
+      if (!array_key_exists('width', $image_upload_settings['max_dimensions']) || $image_upload_settings['max_dimensions']['width'] === 0) {
+        $image_upload_settings['max_dimensions']['width'] = NULL;
+      }
+      if (!array_key_exists('height', $image_upload_settings['max_dimensions']) || $image_upload_settings['max_dimensions']['height'] === 0) {
+        $image_upload_settings['max_dimensions']['height'] = NULL;
+      }
+    }
+    $editor->setImageUploadSettings($image_upload_settings);
+  }
+}
diff --git a/core/modules/editor/editor.post_update.php b/core/modules/editor/editor.post_update.php
index 112409ecb0e5..c62567af7c8e 100644
--- a/core/modules/editor/editor.post_update.php
+++ b/core/modules/editor/editor.post_update.php
@@ -5,6 +5,8 @@
  * Post update functions for Editor.
  */
 
+use Drupal\Core\Config\Entity\ConfigEntityUpdater;
+use Drupal\editor\EditorInterface;
 use Drupal\filter\Entity\FilterFormat;
 use Drupal\filter\FilterFormatInterface;
 use Drupal\filter\FilterPluginCollection;
@@ -44,3 +46,24 @@ function editor_post_update_image_lazy_load(): void {
     }
   }
 }
+
+/**
+ * Clean up image upload settings.
+ */
+function editor_post_update_sanitize_image_upload_settings(&$sandbox = []) {
+  $config_entity_updater = \Drupal::classResolver(ConfigEntityUpdater::class);
+
+  $callback = function (EditorInterface $editor) {
+    $image_upload_settings = $editor->getImageUploadSettings();
+    // Only update if the editor has image uploads:
+    // - empty image upload settings
+    // - disabled and >=1 other keys in its image upload settings
+    // - enabled (to tighten the key-value pairs in its settings).
+    // @see editor_editor_presave()
+    return !array_key_exists('status', $image_upload_settings)
+      || ($image_upload_settings['status'] == FALSE && count($image_upload_settings) >= 2)
+      || $image_upload_settings['status'] == TRUE;
+  };
+
+  $config_entity_updater->update($sandbox, 'editor', $callback);
+}
diff --git a/core/modules/editor/src/Entity/Editor.php b/core/modules/editor/src/Entity/Editor.php
index b8c041df85b8..25ff5f8e9922 100644
--- a/core/modules/editor/src/Entity/Editor.php
+++ b/core/modules/editor/src/Entity/Editor.php
@@ -4,6 +4,7 @@
 
 use Drupal\Component\Plugin\Exception\PluginNotFoundException;
 use Drupal\Core\Config\Entity\ConfigEntityBase;
+use Drupal\Core\StreamWrapper\StreamWrapperInterface;
 use Drupal\editor\EditorInterface;
 
 /**
@@ -207,4 +208,18 @@ public function setImageUploadSettings(array $image_upload_settings) {
     return $this;
   }
 
+  /**
+   * Computes all valid choices for the "image_upload.scheme" setting.
+   *
+   * @see editor.schema.yml
+   *
+   * @return string[]
+   *   All valid choices.
+   *
+   * @internal
+   */
+  public static function getValidStreamWrappers(): array {
+    return array_keys(\Drupal::service('stream_wrapper_manager')->getNames(StreamWrapperInterface::WRITE_VISIBLE));
+  }
+
 }
diff --git a/core/modules/editor/src/Form/EditorImageDialog.php b/core/modules/editor/src/Form/EditorImageDialog.php
index 6e4cf9db2705..323f24817ce5 100644
--- a/core/modules/editor/src/Form/EditorImageDialog.php
+++ b/core/modules/editor/src/Form/EditorImageDialog.php
@@ -96,25 +96,15 @@ public function buildForm(array $form, FormStateInterface $form_state, Editor $e
 
     // Construct strings to use in the upload validators.
     $image_upload = $editor->getImageUploadSettings();
-    if (!empty($image_upload['max_dimensions']['width']) || !empty($image_upload['max_dimensions']['height'])) {
-      $max_dimensions = $image_upload['max_dimensions']['width'] . 'x' . $image_upload['max_dimensions']['height'];
-    }
-    else {
-      $max_dimensions = 0;
-    }
-    $max_filesize = min(Bytes::toNumber($image_upload['max_size']), Environment::getUploadMaxSize());
     $existing_file = isset($image_element['data-entity-uuid']) ? \Drupal::service('entity.repository')->loadEntityByUuid('file', $image_element['data-entity-uuid']) : NULL;
     $fid = $existing_file ? $existing_file->id() : NULL;
 
     $form['fid'] = [
       '#title' => $this->t('Image'),
       '#type' => 'managed_file',
-      '#upload_location' => $image_upload['scheme'] . '://' . $image_upload['directory'],
       '#default_value' => $fid ? [$fid] : NULL,
       '#upload_validators' => [
         'FileExtension' => ['extensions' => 'gif png jpg jpeg'],
-        'FileSizeLimit' => ['fileLimit' => $max_filesize],
-        'FileImageDimensions' => ['maxDimensions' => $max_dimensions],
       ],
       '#required' => TRUE,
     ];
@@ -132,6 +122,17 @@ public function buildForm(array $form, FormStateInterface $form_state, Editor $e
     if ($image_upload['status']) {
       $form['attributes']['src']['#access'] = FALSE;
       $form['attributes']['src']['#required'] = FALSE;
+
+      if (!empty($image_upload['max_dimensions']['width']) || !empty($image_upload['max_dimensions']['height'])) {
+        $max_dimensions = $image_upload['max_dimensions']['width'] . 'x' . $image_upload['max_dimensions']['height'];
+      }
+      else {
+        $max_dimensions = 0;
+      }
+      $max_filesize = min(Bytes::toNumber($image_upload['max_size'] ?? 0), Environment::getUploadMaxSize());
+      $form['fid']['#upload_location'] = $image_upload['scheme'] . '://' . ($image_upload['directory'] ?? '');
+      $form['fid']['#upload_validators']['FileSizeLimit'] = ['fileLimit' => $max_filesize];
+      $form['fid']['#upload_validators']['FileImageDimensions'] = ['maxDimensions' => $max_dimensions];
     }
     else {
       $form['fid']['#access'] = FALSE;
diff --git a/core/modules/editor/tests/fixtures/update/editor-3412361.php b/core/modules/editor/tests/fixtures/update/editor-3412361.php
new file mode 100644
index 000000000000..390f07699e1c
--- /dev/null
+++ b/core/modules/editor/tests/fixtures/update/editor-3412361.php
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @file
+ * Test fixture.
+ */
+
+use Drupal\Core\Database\Database;
+use Drupal\Core\Serialization\Yaml;
+
+$connection = Database::getConnection();
+
+$umami_basic_html_format = Yaml::decode(file_get_contents(__DIR__ . '/filter.format.umami_basic_html.yml'));
+$umami_basic_html_format['format'] = 'umami_basic_html';
+$connection->insert('config')
+  ->fields([
+    'collection',
+    'name',
+    'data',
+  ])
+  ->values([
+    'collection' => '',
+    'name' => 'filter.format.umami_basic_html',
+    'data' => serialize($umami_basic_html_format),
+  ])
+  ->execute();
+
+$umami_basic_html_editor = Yaml::decode(file_get_contents(__DIR__ . '/editor.editor.umami_basic_html.yml'));
+$umami_basic_html_editor['format'] = 'umami_basic_html';
+$connection->insert('config')
+  ->fields([
+    'collection',
+    'name',
+    'data',
+  ])
+  ->values([
+    'collection' => '',
+    'name' => 'editor.editor.umami_basic_html',
+    'data' => serialize($umami_basic_html_editor),
+  ])
+  ->execute();
diff --git a/core/modules/editor/tests/fixtures/update/editor.editor.umami_basic_html.yml b/core/modules/editor/tests/fixtures/update/editor.editor.umami_basic_html.yml
new file mode 100644
index 000000000000..5a0f96f3a680
--- /dev/null
+++ b/core/modules/editor/tests/fixtures/update/editor.editor.umami_basic_html.yml
@@ -0,0 +1,64 @@
+uuid: c82794ef-c451-49c6-be67-39e2b0649a47
+langcode: en
+status: true
+dependencies:
+  config:
+    - filter.format.basic_html
+  module:
+    - ckeditor5
+format: basic_html
+editor: ckeditor5
+settings:
+  toolbar:
+    items:
+      - bold
+      - italic
+      - '|'
+      - link
+      - '|'
+      - bulletedList
+      - numberedList
+      - '|'
+      - blockQuote
+      - '|'
+      - heading
+      - '|'
+      - sourceEditing
+      - '|'
+  plugins:
+    ckeditor5_heading:
+      enabled_headings:
+        - heading2
+        - heading3
+        - heading4
+        - heading5
+        - heading6
+    ckeditor5_list:
+      properties:
+        reversed: false
+        startIndex: true
+      multiBlock: false
+    ckeditor5_sourceEditing:
+      allowed_tags:
+        - '<cite>'
+        - '<dl>'
+        - '<dt>'
+        - '<dd>'
+        - '<a hreflang>'
+        - '<blockquote cite>'
+        - '<ul type>'
+        - '<ol type>'
+        - '<h2 id>'
+        - '<h3 id>'
+        - '<h4 id>'
+        - '<h5 id>'
+        - '<h6 id>'
+        - '<img src alt data-entity-type data-entity-uuid data-align data-caption width height loading>'
+image_upload:
+  status: false
+  scheme: public
+  directory: inline-images
+  max_size: ''
+  max_dimensions:
+    width: null
+    height: null
diff --git a/core/modules/editor/tests/fixtures/update/filter.format.umami_basic_html.yml b/core/modules/editor/tests/fixtures/update/filter.format.umami_basic_html.yml
new file mode 100644
index 000000000000..db9282020ed5
--- /dev/null
+++ b/core/modules/editor/tests/fixtures/update/filter.format.umami_basic_html.yml
@@ -0,0 +1,55 @@
+uuid: 32fd1f3a-8ea1-44be-851f-64659c260bea
+langcode: en
+status: true
+dependencies:
+  module:
+    - editor
+name: 'Basic HTML'
+format: basic_html
+weight: 0
+filters:
+  editor_file_reference:
+    id: editor_file_reference
+    provider: editor
+    status: true
+    weight: 11
+    settings: {  }
+  filter_align:
+    id: filter_align
+    provider: filter
+    status: true
+    weight: 7
+    settings: {  }
+  filter_autop:
+    id: filter_autop
+    provider: filter
+    status: true
+    weight: 0
+    settings: {  }
+  filter_caption:
+    id: filter_caption
+    provider: filter
+    status: true
+    weight: 8
+    settings: {  }
+  filter_html:
+    id: filter_html
+    provider: filter
+    status: true
+    weight: -10
+    settings:
+      allowed_html: '<a href hreflang> <em> <strong> <cite> <blockquote cite> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id> <p> <br> <img src alt loading height width data-entity-type data-entity-uuid data-align data-caption> <drupal-media data-entity-type data-entity-uuid data-view-mode data-align data-caption alt title>'
+      filter_html_help: false
+      filter_html_nofollow: false
+  filter_html_image_secure:
+    id: filter_html_image_secure
+    provider: filter
+    status: true
+    weight: 9
+    settings: {  }
+  filter_image_lazy_load:
+    id: filter_image_lazy_load
+    provider: filter
+    status: true
+    weight: 15
+    settings: {  }
diff --git a/core/modules/editor/tests/src/Functional/EditorDialogAccessTest.php b/core/modules/editor/tests/src/Functional/EditorDialogAccessTest.php
index 7c0a689ae447..128ab2e7fdc8 100644
--- a/core/modules/editor/tests/src/Functional/EditorDialogAccessTest.php
+++ b/core/modules/editor/tests/src/Functional/EditorDialogAccessTest.php
@@ -45,13 +45,6 @@ public function testEditorImageDialogAccess() {
       'format' => 'plain_text',
       'image_upload' => [
         'status' => FALSE,
-        'scheme' => 'public',
-        'directory' => 'inline-images',
-        'max_size' => '',
-        'max_dimensions' => [
-          'width' => 0,
-          'height' => 0,
-        ],
       ],
     ]);
     $editor->save();
@@ -63,8 +56,16 @@ public function testEditorImageDialogAccess() {
 
     // With image upload settings, expect a 200, and now there should be an
     // input[type=file].
-    $editor->setImageUploadSettings(['status' => TRUE] + $editor->getImageUploadSettings())
-      ->save();
+    $editor->setImageUploadSettings([
+      'status' => TRUE,
+      'scheme' => 'public',
+      'directory' => 'inline-images',
+      'max_size' => NULL,
+      'max_dimensions' => [
+        'width' => NULL,
+        'height' => NULL,
+      ],
+    ])->save();
     $this->resetAll();
     $this->drupalGet($url);
     $this->assertEmpty($this->cssSelect('input[type=text][name="attributes[src]"]'), 'Image uploads enabled: input[type=text][name="attributes[src]"] is absent.');
diff --git a/core/modules/editor/tests/src/Functional/Rest/EditorResourceTestBase.php b/core/modules/editor/tests/src/Functional/Rest/EditorResourceTestBase.php
index 7b07ec45d07e..2eafeb051fed 100644
--- a/core/modules/editor/tests/src/Functional/Rest/EditorResourceTestBase.php
+++ b/core/modules/editor/tests/src/Functional/Rest/EditorResourceTestBase.php
@@ -66,7 +66,7 @@ protected function createEntity() {
     ]);
     $camelids
       ->setImageUploadSettings([
-        'status' => FALSE,
+        'status' => TRUE,
         'scheme' => 'public',
         'directory' => 'inline-images',
         'max_size' => '',
@@ -96,10 +96,10 @@ protected function getExpectedNormalizedEntity() {
       'editor' => 'ckeditor5',
       'format' => 'llama',
       'image_upload' => [
-        'status' => FALSE,
+        'status' => TRUE,
         'scheme' => 'public',
         'directory' => 'inline-images',
-        'max_size' => '',
+        'max_size' => NULL,
         'max_dimensions' => [
           'width' => NULL,
           'height' => NULL,
diff --git a/core/modules/editor/tests/src/Functional/Update/EditorSanitizeImageUploadSettingsUpdateTest.php b/core/modules/editor/tests/src/Functional/Update/EditorSanitizeImageUploadSettingsUpdateTest.php
new file mode 100644
index 000000000000..a742ff3645d6
--- /dev/null
+++ b/core/modules/editor/tests/src/Functional/Update/EditorSanitizeImageUploadSettingsUpdateTest.php
@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\editor\Functional\Update;
+
+use Drupal\FunctionalTests\Update\UpdatePathTestBase;
+
+/**
+ * @group Update
+ * @group editor
+ * @see editor_post_update_sanitize_image_upload_settings()
+ */
+class EditorSanitizeImageUploadSettingsUpdateTest extends UpdatePathTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setDatabaseDumpFiles(): void {
+    $this->databaseDumpFiles = [
+      __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.bare.standard.php.gz',
+      __DIR__ . '/../../../fixtures/update/editor-3412361.php',
+    ];
+  }
+
+  /**
+   * Ensure image upload settings for Text Editor config entities are corrected.
+   *
+   * @see editor_post_update_sanitize_image_upload_settings()
+   */
+  public function testUpdateRemoveMeaninglessImageUploadSettings(): void {
+    $basic_html_before = $this->config('editor.editor.basic_html');
+    $this->assertSame([
+      'status' => TRUE,
+      'scheme' => 'public',
+      'directory' => 'inline-images',
+      'max_size' => '',
+      'max_dimensions' => [
+        'width' => 0,
+        'height' => 0,
+      ],
+    ], $basic_html_before->get('image_upload'));
+    $full_html_before = $this->config('editor.editor.full_html');
+    $this->assertSame([
+      'status' => TRUE,
+      'scheme' => 'public',
+      'directory' => 'inline-images',
+      'max_size' => '',
+      'max_dimensions' => [
+        'width' => 0,
+        'height' => 0,
+      ],
+    ], $full_html_before->get('image_upload'));
+    $umami_basic_html_before = $this->config('editor.editor.umami_basic_html');
+    $this->assertSame([
+      'status' => FALSE,
+      'scheme' => 'public',
+      'directory' => 'inline-images',
+      'max_size' => '',
+      'max_dimensions' => [
+        'width' => NULL,
+        'height' => NULL,
+      ],
+    ], $umami_basic_html_before->get('image_upload'));
+
+    $this->runUpdates();
+
+    $basic_html_after = $this->config('editor.editor.basic_html');
+    $this->assertNotSame($basic_html_before->get('image_upload'), $basic_html_after->get('image_upload'));
+    $this->assertSame([
+      'status' => TRUE,
+      'scheme' => 'public',
+      'directory' => 'inline-images',
+      'max_size' => NULL,
+      'max_dimensions' => [
+        'width' => NULL,
+        'height' => NULL,
+      ],
+    ], $basic_html_after->get('image_upload'));
+    $full_html_after = $this->config('editor.editor.full_html');
+    $this->assertNotSame($full_html_before->get('image_upload'), $full_html_after->get('image_upload'));
+    $this->assertSame([
+      'status' => TRUE,
+      'scheme' => 'public',
+      'directory' => 'inline-images',
+      'max_size' => NULL,
+      'max_dimensions' => [
+        'width' => NULL,
+        'height' => NULL,
+      ],
+    ], $full_html_after->get('image_upload'));
+    $umami_basic_html_after = $this->config('editor.editor.umami_basic_html');
+    $this->assertNotSame($umami_basic_html_before->get('image_upload'), $umami_basic_html_after->get('image_upload'));
+    $this->assertSame([
+      'status' => FALSE,
+    ], $umami_basic_html_after->get('image_upload'));
+  }
+
+}
diff --git a/core/modules/editor/tests/src/Kernel/EditorImageDialogTest.php b/core/modules/editor/tests/src/Kernel/EditorImageDialogTest.php
index 21fd39820407..5ba77d067ebf 100644
--- a/core/modules/editor/tests/src/Kernel/EditorImageDialogTest.php
+++ b/core/modules/editor/tests/src/Kernel/EditorImageDialogTest.php
@@ -68,6 +68,10 @@ protected function setUp(): void {
         'max_size' => 100,
         'scheme' => 'public',
         'directory' => '',
+        'max_dimensions' => [
+          'width' => NULL,
+          'height' => NULL,
+        ],
         'status' => TRUE,
       ],
     ]);
diff --git a/core/modules/editor/tests/src/Kernel/EditorValidationTest.php b/core/modules/editor/tests/src/Kernel/EditorValidationTest.php
index 29006ec44088..88035dae514e 100644
--- a/core/modules/editor/tests/src/Kernel/EditorValidationTest.php
+++ b/core/modules/editor/tests/src/Kernel/EditorValidationTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\editor\Kernel;
 
+use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Heading;
 use Drupal\editor\Entity\Editor;
 use Drupal\filter\Entity\FilterFormat;
 use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase;
@@ -16,7 +17,18 @@ class EditorValidationTest extends ConfigEntityValidationTestBase {
   /**
    * {@inheritdoc}
    */
-  protected static $modules = ['editor', 'editor_test', 'filter'];
+  protected static $modules = ['ckeditor5', 'editor', 'filter'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static array $propertiesWithRequiredKeys = [
+    'settings' => [
+      "'toolbar' is a required key because editor is ckeditor5 (see config schema type editor.settings.ckeditor5).",
+      "'plugins' is a required key because editor is ckeditor5 (see config schema type editor.settings.ckeditor5).",
+    ],
+    'image_upload' => "'status' is a required key.",
+  ];
 
   /**
    * {@inheritdoc}
@@ -32,11 +44,34 @@ protected function setUp(): void {
 
     $this->entity = Editor::create([
       'format' => $format->id(),
-      'editor' => 'unicorn',
+      'editor' => 'ckeditor5',
+      'settings' => [
+        // @see \Drupal\ckeditor5\Plugin\Editor\CKEditor5::getDefaultSettings()
+        'toolbar' => [
+          'items' => ['heading', 'bold', 'italic'],
+        ],
+        'plugins' => [
+          'ckeditor5_heading' => Heading::DEFAULT_CONFIGURATION,
+        ],
+      ],
     ]);
     $this->entity->save();
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function testImmutableProperties(array $valid_values = [], ?array $additional_expected_validation_errors_when_missing = NULL): void {
+    // TRICKY: Every Text Editor is associated with a Text Format. It must exist
+    // to avoid triggering a validation error.
+    // @see \Drupal\editor\EditorInterface::hasAssociatedFilterFormat
+    FilterFormat::create([
+      'format' => 'another',
+      'name' => 'Another',
+    ])->save();
+    parent::testImmutableProperties(['format' => 'another']);
+  }
+
   /**
    * Tests that validation fails if config dependencies are invalid.
    */
@@ -70,6 +105,17 @@ public function testInvalidPluginId(): void {
     $this->assertValidationErrors(['editor' => "The 'non_existent' plugin does not exist."]);
   }
 
+  /**
+   * Tests validating an editor with a non-existent `format`.
+   */
+  public function testInvalidFormat(): void {
+    $this->entity->set('format', 'non_existent');
+    $this->assertValidationErrors([
+      '' => "The 'format' property cannot be changed.",
+      'format' => "The 'filter.format.non_existent' config does not exist.",
+    ]);
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -79,6 +125,107 @@ public function testLabelValidation(): void {
     $this->markTestSkipped();
   }
 
+  /**
+   * `image_upload.status = TRUE` must cause additional keys to be required.
+   */
+  public function testImageUploadSettingsAreDynamicallyRequired(): void {
+    // When image uploads are disabled, no other key-value pairs are needed.
+    $this->entity->setImageUploadSettings(['status' => FALSE]);
+    $this->assertValidationErrors([]);
+
+    // But when they are enabled, many others are needed.
+    $this->entity->setImageUploadSettings(['status' => TRUE]);
+    $this->assertValidationErrors([
+      'image_upload' => [
+        "'scheme' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1).",
+        "'directory' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1).",
+        "'max_size' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1).",
+        "'max_dimensions' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1).",
+      ],
+    ]);
+
+    // Specify all required keys, but forget one.
+    $this->entity->setImageUploadSettings([
+      'status' => TRUE,
+      'scheme' => 'public',
+      'directory' => 'uploaded-images',
+      'max_size' => '5 MB',
+    ]);
+    $this->assertValidationErrors(['image_upload' => "'max_dimensions' is a required key because image_upload.status is 1 (see config schema type editor.image_upload_settings.1)."]);
+
+    // Specify all required keys.
+    $this->entity->setImageUploadSettings([
+      'status' => TRUE,
+      'scheme' => 'public',
+      'directory' => 'uploaded-images',
+      'max_size' => '5 MB',
+      'max_dimensions' => [
+        'width' => 10000,
+        'height' => 10000,
+      ],
+    ]);
+    $this->assertValidationErrors([]);
+
+    // Specify all required keys … but now disable image uploads again. This
+    // should trigger a validation error from the ValidKeys constraint.
+    $this->entity->setImageUploadSettings([
+      'status' => FALSE,
+      'scheme' => 'public',
+      'directory' => 'uploaded-images',
+      'max_size' => '5 MB',
+      'max_dimensions' => [
+        'width' => 10000,
+        'height' => 10000,
+      ],
+    ]);
+    $this->assertValidationErrors([
+      'image_upload' => [
+        "'scheme' is an unknown key because image_upload.status is 0 (see config schema type editor.image_upload_settings.*).",
+        "'directory' is an unknown key because image_upload.status is 0 (see config schema type editor.image_upload_settings.*).",
+        "'max_size' is an unknown key because image_upload.status is 0 (see config schema type editor.image_upload_settings.*).",
+        "'max_dimensions' is an unknown key because image_upload.status is 0 (see config schema type editor.image_upload_settings.*).",
+      ],
+    ]);
+
+    // Remove the values that the messages said are unknown.
+    $this->entity->setImageUploadSettings(['status' => FALSE]);
+    $this->assertValidationErrors([]);
+
+    // Note how this is the same as the initial value. This proves that `status`
+    // being FALSE prevents any meaningless key-value pairs to be present, and
+    // `status` being TRUE requires those then meaningful pairs to be present.
+  }
+
+  /**
+   * @testWith [{"scheme": "public"}, {}]
+   *           [{"scheme": "private"}, {"image_upload.scheme": "The file storage you selected is not a visible, readable and writable stream wrapper. Possible choices: <em class=\"placeholder\">&quot;public&quot;</em>."}]
+   *           [{"directory": null}, {}]
+   *           [{"directory": ""}, {"image_upload.directory": "This value should not be blank."}]
+   *           [{"directory": "inline\nimages"}, {"image_upload.directory": "The image upload directory is not allowed to span multiple lines or contain control characters."}]
+   *           [{"directory": "foo\b\b\binline-images"}, {"image_upload.directory": "The image upload directory is not allowed to span multiple lines or contain control characters."}]
+   *           [{"max_size": null}, {}]
+   *           [{"max_size": "foo"}, {"image_upload.max_size": "This value must be a number of bytes, optionally with a unit such as \"MB\" or \"megabytes\". <em class=\"placeholder\">foo</em> does not represent a number of bytes."}]
+   *           [{"max_size": ""}, {"image_upload.max_size": "This value must be a number of bytes, optionally with a unit such as \"MB\" or \"megabytes\". <em class=\"placeholder\"></em> does not represent a number of bytes."}]
+   *           [{"max_size": "7 exabytes"}, {}]
+   *           [{"max_dimensions": {"width": null, "height": 15}}, {}]
+   *           [{"max_dimensions": {"width": null, "height": null}}, {}]
+   *           [{"max_dimensions": {"width": null, "height": 0}}, {"image_upload.max_dimensions.height": "This value should be between <em class=\"placeholder\">1</em> and <em class=\"placeholder\">99999</em>."}]
+   *           [{"max_dimensions": {"width": 100000, "height": 1}}, {"image_upload.max_dimensions.width": "This value should be between <em class=\"placeholder\">1</em> and <em class=\"placeholder\">99999</em>."}]
+   */
+  public function testImageUploadSettingsValidation(array $invalid_setting, array $expected_message): void {
+    $this->entity->setImageUploadSettings($invalid_setting + [
+      'status' => TRUE,
+      'scheme' => 'public',
+      'directory' => 'uploaded-images',
+      'max_size' => '5 MB',
+      'max_dimensions' => [
+        'width' => 10000,
+        'height' => 10000,
+      ],
+    ]);
+    $this->assertValidationErrors($expected_message);
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -89,6 +236,9 @@ public function testRequiredPropertyValuesMissing(?array $additional_expected_va
         // @see \Drupal\Core\Config\Plugin\Validation\Constraint\RequiredConfigDependenciesConstraintValidator
         '' => 'This text editor requires a text format.',
       ],
+      'settings' => [
+        'settings.plugins.ckeditor5_heading' => 'Configuration for the enabled plugin "<em class="placeholder">Headings</em>" (<em class="placeholder">ckeditor5_heading</em>) is missing.',
+      ],
     ]);
   }
 
@@ -102,6 +252,9 @@ public function testRequiredPropertyKeysMissing(?array $additional_expected_vali
         // @see \Drupal\Core\Config\Plugin\Validation\Constraint\RequiredConfigDependenciesConstraintValidator
         '' => 'This text editor requires a text format.',
       ],
+      'settings' => [
+        'settings.plugins.ckeditor5_heading' => 'Configuration for the enabled plugin "<em class="placeholder">Headings</em>" (<em class="placeholder">ckeditor5_heading</em>) is missing.',
+      ],
     ]);
   }
 
diff --git a/core/modules/jsonapi/tests/src/Functional/EditorTest.php b/core/modules/jsonapi/tests/src/Functional/EditorTest.php
index d04bbd6267d5..1ee003232693 100644
--- a/core/modules/jsonapi/tests/src/Functional/EditorTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/EditorTest.php
@@ -81,7 +81,7 @@ protected function createEntity() {
     ]);
     $camelids
       ->setImageUploadSettings([
-        'status' => FALSE,
+        'status' => TRUE,
         'scheme' => 'public',
         'directory' => 'inline-images',
         'max_size' => '',
@@ -129,10 +129,10 @@ protected function getExpectedDocument() {
           ],
           'editor' => 'ckeditor5',
           'image_upload' => [
-            'status' => FALSE,
+            'status' => TRUE,
             'scheme' => 'public',
             'directory' => 'inline-images',
-            'max_size' => '',
+            'max_size' => NULL,
             'max_dimensions' => [
               'width' => NULL,
               'height' => NULL,
@@ -193,7 +193,7 @@ protected function createAnotherEntity($key) {
     ]);
 
     $entity->setImageUploadSettings([
-      'status' => FALSE,
+      'status' => TRUE,
       'scheme' => 'public',
       'directory' => 'inline-images',
       'max_size' => '',
diff --git a/core/profiles/demo_umami/config/install/editor.editor.basic_html.yml b/core/profiles/demo_umami/config/install/editor.editor.basic_html.yml
index d87f1cc4a3a5..60cd331cc875 100644
--- a/core/profiles/demo_umami/config/install/editor.editor.basic_html.yml
+++ b/core/profiles/demo_umami/config/install/editor.editor.basic_html.yml
@@ -59,9 +59,3 @@ settings:
       allow_view_mode_override: true
 image_upload:
   status: false
-  scheme: public
-  directory: inline-images
-  max_size: ''
-  max_dimensions:
-    width: null
-    height: null
diff --git a/core/profiles/demo_umami/config/install/editor.editor.full_html.yml b/core/profiles/demo_umami/config/install/editor.editor.full_html.yml
index dd7cf0a06571..307861f05c8b 100644
--- a/core/profiles/demo_umami/config/install/editor.editor.full_html.yml
+++ b/core/profiles/demo_umami/config/install/editor.editor.full_html.yml
@@ -51,7 +51,7 @@ image_upload:
   status: true
   scheme: public
   directory: inline-images
-  max_size: ''
+  max_size: null
   max_dimensions:
     width: null
     height: null
diff --git a/core/profiles/standard/config/install/editor.editor.basic_html.yml b/core/profiles/standard/config/install/editor.editor.basic_html.yml
index 07b13d8e16bd..a31e41506fd5 100644
--- a/core/profiles/standard/config/install/editor.editor.basic_html.yml
+++ b/core/profiles/standard/config/install/editor.editor.basic_html.yml
@@ -59,7 +59,7 @@ image_upload:
   status: true
   scheme: public
   directory: inline-images
-  max_size: ''
+  max_size: null
   max_dimensions:
-    width: 0
-    height: 0
+    width: null
+    height: null
diff --git a/core/profiles/standard/config/install/editor.editor.full_html.yml b/core/profiles/standard/config/install/editor.editor.full_html.yml
index a27ed46a5e3d..e30fc15eaf3c 100644
--- a/core/profiles/standard/config/install/editor.editor.full_html.yml
+++ b/core/profiles/standard/config/install/editor.editor.full_html.yml
@@ -96,7 +96,7 @@ image_upload:
   status: true
   scheme: public
   directory: inline-images
-  max_size: ''
+  max_size: null
   max_dimensions:
-    width: 0
-    height: 0
+    width: null
+    height: null
diff --git a/core/tests/Drupal/Tests/Component/Utility/BytesTest.php b/core/tests/Drupal/Tests/Component/Utility/BytesTest.php
index d8c9c5ce2222..e28b912fb5e2 100644
--- a/core/tests/Drupal/Tests/Component/Utility/BytesTest.php
+++ b/core/tests/Drupal/Tests/Component/Utility/BytesTest.php
@@ -89,6 +89,7 @@ public static function providerTestToNumber(): array {
    *
    * @dataProvider providerTestValidate
    * @covers ::validate
+   * @covers ::validateConstraint
    */
   public function testValidate($string, bool $expected_result): void {
     $this->assertSame($expected_result, Bytes::validate($string));
-- 
GitLab