VYPR
High severityNVD Advisory· Published Jun 9, 2026· Updated Jun 9, 2026

CVE-2026-49741

CVE-2026-49741

Description

Backend users with write access to the form_definition database table were able to directly create, update, or delete form definition records via DataHandler, bypassing the Form Framework's persistence validation and permission checks. This allowed injecting arbitrary form configurations, re-enabling attack vectors originally addressed in TYPO3-CORE-SA-2018-003, including SQL injection and privilege escalation. This issue affects TYPO3 CMS versions 14.0.0-14.3.3.

Affected products

3
  • TYPO3/Typo3references2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: 14.0.0-14.3.3
  • TYPO3/TYPO3 CMSllm-fuzzy
    Range: 14.0.0-14.3.3

Patches

1
c90493c13b63

[SECURITY] Guard form_definition DataHandler access

https://github.com/TYPO3/typo3Oliver HaderJun 9, 2026via github-commit-search
7 files changed · +547 29
  • typo3/sysext/form/Classes/Domain/Repository/FormDefinitionRepository.php+36 29 modified
    @@ -24,6 +24,8 @@
     use TYPO3\CMS\Core\Utility\GeneralUtility;
     use TYPO3\CMS\Form\Domain\DTO\FormData;
     use TYPO3\CMS\Form\Domain\DTO\SearchCriteria;
    +use TYPO3\CMS\Form\Storage\Security\FormDefinitionPersistenceCommand;
    +use TYPO3\CMS\Form\Storage\Security\FormDefinitionPersistenceGuard;
     
     /**
      * Repository class to fetch available form definitions.
    @@ -36,6 +38,7 @@
     
         public function __construct(
             private ConnectionPool $connectionPool,
    +        private FormDefinitionPersistenceGuard $persistenceGuard,
         ) {}
     
         /**
    @@ -180,19 +183,22 @@ private function applySearchCriteria(QueryBuilder $queryBuilder, SearchCriteria
         public function add(string $persistenceIdentifier, int $pid, FormData $formDefinition): ?int
         {
             $formDefinitionJson = json_encode($formDefinition->toArray(), JSON_THROW_ON_ERROR);
    +        $fields = [
    +            'pid' => $pid,
    +            'label' => $formDefinition->name,
    +            'identifier' => $formDefinition->identifier,
    +            'configuration' => $formDefinitionJson,
    +        ];
    +
    +        $this->persistenceGuard->allowInvocation(FormDefinitionPersistenceCommand::Create, $persistenceIdentifier, $fields);
             /** @var DataHandler $dataHandler */
             $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
    -        $dataHandler->start([
    -            self::TABLE_NAME => [
    -                $persistenceIdentifier => [
    -                    'pid' => $pid,
    -                    'label' => $formDefinition->name,
    -                    'identifier' => $formDefinition->identifier,
    -                    'configuration' => $formDefinitionJson,
    -                ],
    -            ],
    -        ], []);
    -        $dataHandler->process_datamap();
    +        $dataHandler->start([self::TABLE_NAME => [$persistenceIdentifier => $fields]], []);
    +        try {
    +            $dataHandler->process_datamap();
    +        } finally {
    +            $this->persistenceGuard->consumeInvocation(FormDefinitionPersistenceCommand::Create, $persistenceIdentifier, $fields);
    +        }
     
             if ($dataHandler->errorLog !== []) {
                 return null;
    @@ -235,16 +241,15 @@ public function addRaw(int $pid, FormData $formDefinition): ?int
          */
         public function remove(int $uid): bool
         {
    +        $this->persistenceGuard->allowInvocation(FormDefinitionPersistenceCommand::Delete, $uid);
             /** @var DataHandler $dataHandler */
             $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
    -        $dataHandler->start([], [
    -            self::TABLE_NAME => [
    -                $uid => [
    -                    'delete' => 1,
    -                ],
    -            ],
    -        ]);
    -        $dataHandler->process_cmdmap();
    +        $dataHandler->start([], [self::TABLE_NAME => [$uid => ['delete' => 1]]]);
    +        try {
    +            $dataHandler->process_cmdmap();
    +        } finally {
    +            $this->persistenceGuard->consumeInvocation(FormDefinitionPersistenceCommand::Delete, $uid);
    +        }
             return $dataHandler->errorLog === [];
         }
     
    @@ -263,19 +268,21 @@ public function update(int $uid, FormData $formDefinition): bool
             }
     
             $formDefinitionJson = json_encode($formDefinition->toArray(), JSON_THROW_ON_ERROR);
    +        $fields = [
    +            'label' => $formDefinition->name,
    +            'identifier' => $formDefinition->identifier,
    +            'configuration' => $formDefinitionJson,
    +        ];
     
    +        $this->persistenceGuard->allowInvocation(FormDefinitionPersistenceCommand::Update, $uid, $fields);
             /** @var DataHandler $dataHandler */
             $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
    -        $dataHandler->start([
    -            self::TABLE_NAME => [
    -                $uid => [
    -                    'label' => $formDefinition->name,
    -                    'identifier' => $formDefinition->identifier,
    -                    'configuration' => $formDefinitionJson,
    -                ],
    -            ],
    -        ], []);
    -        $dataHandler->process_datamap();
    +        $dataHandler->start([self::TABLE_NAME => [$uid => $fields]], []);
    +        try {
    +            $dataHandler->process_datamap();
    +        } finally {
    +            $this->persistenceGuard->consumeInvocation(FormDefinitionPersistenceCommand::Update, $uid, $fields);
    +        }
     
             return $dataHandler->errorLog === [];
         }
    
  • typo3/sysext/form/Classes/Hooks/FormDefinitionDataHandlerHook.php+127 0 added
    @@ -0,0 +1,127 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of the TYPO3 CMS project.
    + *
    + * It is free software; you can redistribute it and/or modify it under
    + * the terms of the GNU General Public License, either version 2
    + * of the License, or any later version.
    + *
    + * For the full copyright and license information, please read the
    + * LICENSE.txt file that was distributed with this source code.
    + *
    + * The TYPO3 project - inspiring people to share!
    + */
    +
    +namespace TYPO3\CMS\Form\Hooks;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
    +use TYPO3\CMS\Core\DataHandling\DataHandler;
    +use TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
    +use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
    +use TYPO3\CMS\Core\Utility\MathUtility;
    +use TYPO3\CMS\Form\Storage\Security\FormDefinitionPersistenceCommand;
    +use TYPO3\CMS\Form\Storage\Security\FormDefinitionPersistenceGuard;
    +
    +/**
    + * Denies direct DataHandler write access to the form_definition table unless
    + * FormDefinitionPersistenceGuard has granted a matching invocation. This
    + * ensures form definitions can only be persisted through DatabaseStorageAdapter,
    + * which applies the form persistence manager's validation and permission checks.
    + *
    + * Registered as processDatamapClass (create/update) and processCmdmapClass
    + * (delete) so each path carries command, identifier, and field-level checks.
    + *
    + * @internal
    + */
    +#[Autoconfigure(public: true)]
    +final readonly class FormDefinitionDataHandlerHook
    +{
    +    public function __construct(
    +        private FormDefinitionPersistenceGuard $guard,
    +    ) {}
    +
    +    /**
    +     * Verifies create/update operations against the pending invocation grant.
    +     *
    +     * The command is derived from $id: a NEW-prefixed string means create,
    +     * an integer means update. The full incoming field array is matched against
    +     * the stored field names and HMAC. Setting $incomingFieldArray to null
    +     * cancels the record in DataHandler.
    +     *
    +     * @param array<string, mixed>|null $incomingFieldArray
    +     * @param-out array<string, mixed>|null $incomingFieldArray
    +     */
    +    public function processDatamap_preProcessFieldArray(
    +        ?array &$incomingFieldArray,
    +        string $table,
    +        string|int $id,
    +        DataHandler $dataHandler,
    +    ): void {
    +        if ($table !== 'form_definition') {
    +            return;
    +        }
    +
    +        $isUpdate = MathUtility::canBeInterpretedAsInteger($id);
    +        $command = $isUpdate
    +            ? FormDefinitionPersistenceCommand::Update
    +            : FormDefinitionPersistenceCommand::Create;
    +        $recordIdentifier = $isUpdate ? (int)$id : (string)$id;
    +
    +        if (!$this->guard->isInvocationAllowed($command, $recordIdentifier, $incomingFieldArray)) {
    +            $incomingFieldArray = null;
    +            $dataHandler->log(
    +                $table,
    +                $isUpdate ? (int)$id : 0,
    +                $isUpdate ? SystemLogDatabaseAction::UPDATE : SystemLogDatabaseAction::INSERT,
    +                null,
    +                SystemLogErrorClassification::USER_ERROR,
    +                'Persisting form definition "%s" via DataHandler is denied',
    +                null,
    +                [$id],
    +            );
    +            return;
    +        }
    +
    +        $this->guard->consumeInvocation($command, $recordIdentifier, $incomingFieldArray);
    +    }
    +
    +    /**
    +     * Blocks unauthorised delete commands on form_definition records.
    +     *
    +     * Sets $commandIsProcessed to true (preventing DataHandler's built-in
    +     * delete) when no COMMAND_FORM_DELETE grant is pending. This mirrors the
    +     * FilePersistenceSlot pattern for FAL-based form definitions.
    +     */
    +    public function processCmdmap(
    +        string $command,
    +        string $table,
    +        int|string $id,
    +        mixed $value,
    +        bool &$commandIsProcessed,
    +        DataHandler $dataHandler,
    +    ): void {
    +        if ($table !== 'form_definition' || $command !== 'delete') {
    +            return;
    +        }
    +
    +        if (!$this->guard->isInvocationAllowed(FormDefinitionPersistenceCommand::Delete, (int)$id)) {
    +            $commandIsProcessed = true;
    +            $dataHandler->log(
    +                $table,
    +                (int)$id,
    +                SystemLogDatabaseAction::DELETE,
    +                null,
    +                SystemLogErrorClassification::USER_ERROR,
    +                'Deleting form definition "%s" via DataHandler is denied',
    +                null,
    +                [$id],
    +            );
    +            return;
    +        }
    +
    +        $this->guard->consumeInvocation(FormDefinitionPersistenceCommand::Delete, (int)$id);
    +    }
    +}
    
  • typo3/sysext/form/Classes/Storage/Security/FormDefinitionPersistenceCommand.php+31 0 added
    @@ -0,0 +1,31 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of the TYPO3 CMS project.
    + *
    + * It is free software; you can redistribute it and/or modify it under
    + * the terms of the GNU General Public License, either version 2
    + * of the License, or any later version.
    + *
    + * For the full copyright and license information, please read the
    + * LICENSE.txt file that was distributed with this source code.
    + *
    + * The TYPO3 project - inspiring people to share!
    + */
    +
    +namespace TYPO3\CMS\Form\Storage\Security;
    +
    +/**
    + * Represents the DataHandler operations that FormDefinitionPersistenceGuard
    + * can authorise for the form_definition table.
    + *
    + * @internal
    + */
    +enum FormDefinitionPersistenceCommand
    +{
    +    case Create;
    +    case Update;
    +    case Delete;
    +}
    
  • typo3/sysext/form/Classes/Storage/Security/FormDefinitionPersistenceGuard.php+146 0 added
    @@ -0,0 +1,146 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of the TYPO3 CMS project.
    + *
    + * It is free software; you can redistribute it and/or modify it under
    + * the terms of the GNU General Public License, either version 2
    + * of the License, or any later version.
    + *
    + * For the full copyright and license information, please read the
    + * LICENSE.txt file that was distributed with this source code.
    + *
    + * The TYPO3 project - inspiring people to share!
    + */
    +
    +namespace TYPO3\CMS\Form\Storage\Security;
    +
    +use TYPO3\CMS\Core\Crypto\HashAlgo;
    +use TYPO3\CMS\Core\Crypto\HashService;
    +
    +/**
    + * Guards direct DataHandler access to the form_definition table.
    + *
    + * FormDefinitionRepository grants a per-invocation token before each
    + * DataHandler call. FormDefinitionDataHandlerHook verifies and consumes that
    + * token; unauthorised DataHandler operations are rejected. This prevents
    + * backend users from bypassing form persistence validation by writing directly
    + * to the table (e.g. list module, impexp).
    + *
    + * Each token covers the command, the record identifier, and an HMAC of all
    + * field-pairs (ksort-ordered), so neither the operation nor any individual
    + * field value can be tampered with independently.
    + *
    + * @internal
    + */
    +final class FormDefinitionPersistenceGuard
    +{
    +    private array $allowedInvocations = [];
    +
    +    public function __construct(private readonly HashService $hashService) {}
    +
    +    /**
    +     * Allows a single DataHandler invocation for the given command and record.
    +     * Write commands (create, update) must supply the exact field-pairs that
    +     * will be passed to DataHandler; delete passes null.
    +     *
    +     * Returns false if an identical invocation is already pending (duplicate).
    +     */
    +    public function allowInvocation(
    +        FormDefinitionPersistenceCommand $command,
    +        string|int $identifier,
    +        ?array $fields = null,
    +    ): bool {
    +        if ($this->findInvocationIndex($command, $identifier) !== null) {
    +            return false;
    +        }
    +        $item = [
    +            'command' => $command,
    +            'identifier' => $identifier,
    +        ];
    +        if ($fields !== null) {
    +            $processed = $this->processFields($fields);
    +            $item['names'] = $processed['names'];
    +            $item['hmac'] = $processed['hmac'];
    +        }
    +        $this->allowedInvocations[] = $item;
    +        return true;
    +    }
    +
    +    /**
    +     * Returns true if a matching invocation has been granted and not yet consumed.
    +     * The provided fields must produce the same sorted key list and HMAC as
    +     * the fields that were registered via allowInvocation().
    +     */
    +    public function isInvocationAllowed(
    +        FormDefinitionPersistenceCommand $command,
    +        string|int $identifier,
    +        ?array $fields = null,
    +    ): bool {
    +        $index = $this->findInvocationIndex($command, $identifier);
    +        if ($index === null) {
    +            return false;
    +        }
    +        if ($fields === null) {
    +            return true;
    +        }
    +        $item = $this->allowedInvocations[$index];
    +        $processed = $this->processFields($fields);
    +        return $item['names'] === $processed['names'] && $item['hmac'] === $processed['hmac'];
    +    }
    +
    +    /**
    +     * Consumes a matching invocation (removes it from the pending list).
    +     * Called both by the hook after successful verification (single-use
    +     * enforcement) and by the repository's finally block (cleanup).
    +     */
    +    public function consumeInvocation(
    +        FormDefinitionPersistenceCommand $command,
    +        string|int $identifier,
    +        ?array $fields = null,
    +    ): void {
    +        $index = $this->findInvocationIndex($command, $identifier);
    +        if ($index === null) {
    +            return;
    +        }
    +        if ($fields === null) {
    +            unset($this->allowedInvocations[$index]);
    +            return;
    +        }
    +        $item = $this->allowedInvocations[$index];
    +        $processed = $this->processFields($fields);
    +        if ($item['names'] === $processed['names'] && $item['hmac'] === $processed['hmac']) {
    +            unset($this->allowedInvocations[$index]);
    +        }
    +    }
    +
    +    private function findInvocationIndex(FormDefinitionPersistenceCommand $command, string|int $identifier): ?int
    +    {
    +        foreach ($this->allowedInvocations as $index => $invocation) {
    +            if ($invocation['command'] === $command && $invocation['identifier'] === $identifier) {
    +                return $index;
    +            }
    +        }
    +        return null;
    +    }
    +
    +    /**
    +     * Sorts fields alphabetically and returns an array with keys 'names' and 'hmac'.
    +     *
    +     * @return array{names: list<string>, hmac: string}
    +     */
    +    private function processFields(array $fields): array
    +    {
    +        ksort($fields);
    +        return [
    +            'names' => array_keys($fields),
    +            'hmac' => $this->hashService->hmac(
    +                json_encode($fields, JSON_THROW_ON_ERROR),
    +                FormDefinitionPersistenceGuard::class,
    +                HashAlgo::SHA3_384
    +            ),
    +        ];
    +    }
    +}
    
  • typo3/sysext/form/ext_localconf.php+5 0 modified
    @@ -6,6 +6,7 @@
     use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
     use TYPO3\CMS\Form\Controller\FormFrontendController;
     use TYPO3\CMS\Form\Evaluation\EmailOrFormElementIdentifier;
    +use TYPO3\CMS\Form\Hooks\FormDefinitionDataHandlerHook;
     use TYPO3\CMS\Form\Hooks\ImportExportHook;
     use TYPO3\CMS\Form\Mvc\Property\PropertyMappingConfiguration;
     
    @@ -26,6 +27,10 @@
         $GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['form-content'] = 'EXT:form/Configuration/RTE/FormContent.yaml';
     }
     
    +// Deny direct DataHandler write access to form_definition: only DatabaseStorageAdapter may persist form definitions
    +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['form'] = FormDefinitionDataHandlerHook::class;
    +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass']['form'] = FormDefinitionDataHandlerHook::class;
    +
     // FE file upload processing
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterFormStateInitialized'][1613296803] = PropertyMappingConfiguration::class;
     
    
  • typo3/sysext/form/Tests/Functional/Storage/FormDefinitionDataHandlerHookTest.php+91 0 added
    @@ -0,0 +1,91 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of the TYPO3 CMS project.
    + *
    + * It is free software; you can redistribute it and/or modify it under
    + * the terms of the GNU General Public License, either version 2
    + * of the License, or any later version.
    + *
    + * For the full copyright and license information, please read the
    + * LICENSE.txt file that was distributed with this source code.
    + *
    + * The TYPO3 project - inspiring people to share!
    + */
    +
    +namespace TYPO3\CMS\Form\Tests\Functional\Storage;
    +
    +use PHPUnit\Framework\Attributes\Test;
    +use TYPO3\CMS\Core\DataHandling\DataHandler;
    +use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
    +use TYPO3\CMS\Core\Utility\GeneralUtility;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +/**
    + * Verifies that FormDefinitionDataHandlerHook denies direct DataHandler access
    + * to form_definition records while still allowing access through DatabaseStorageAdapter.
    + */
    +final class FormDefinitionDataHandlerHookTest extends FunctionalTestCase
    +{
    +    protected array $coreExtensionsToLoad = ['form'];
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/be_users.csv');
    +        $backendUser = $this->setUpBackendUser(1);
    +        $GLOBALS['LANG'] = $this->get(LanguageServiceFactory::class)->createFromUserPreferences($backendUser);
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/form_definition.csv');
    +    }
    +
    +    #[Test]
    +    public function directDataHandlerCreateIsDenied(): void
    +    {
    +        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
    +        $dataHandler->start([
    +            'form_definition' => [
    +                'NEW1' => [
    +                    'pid' => 0,
    +                    'label' => 'Injected Form',
    +                    'identifier' => 'injected-form',
    +                    'configuration' => '{}',
    +                ],
    +            ],
    +        ], []);
    +
    +        $dataHandler->process_datamap();
    +        self::assertSame(['[1.1]: Persisting form definition "NEW1" via DataHandler is denied'], $dataHandler->errorLog);
    +    }
    +
    +    #[Test]
    +    public function directDataHandlerUpdateIsDenied(): void
    +    {
    +        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
    +        $dataHandler->start([
    +            'form_definition' => [
    +                1 => [
    +                    'label' => 'Tampered Label',
    +                ],
    +            ],
    +        ], []);
    +
    +        $dataHandler->process_datamap();
    +        self::assertSame(['[1.2]: Persisting form definition "1" via DataHandler is denied'], $dataHandler->errorLog);
    +    }
    +
    +    #[Test]
    +    public function directDataHandlerDeleteIsDenied(): void
    +    {
    +        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
    +        $dataHandler->start([], [
    +            'form_definition' => [
    +                1 => ['delete' => 1],
    +            ],
    +        ]);
    +
    +        $dataHandler->process_cmdmap();
    +        self::assertSame(['[1.3]: Deleting form definition "1" via DataHandler is denied'], $dataHandler->errorLog);
    +    }
    +}
    
  • typo3/sysext/form/Tests/Functional/Storage/FormDefinitionRepositoryTest.php+111 0 added
    @@ -0,0 +1,111 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of the TYPO3 CMS project.
    + *
    + * It is free software; you can redistribute it and/or modify it under
    + * the terms of the GNU General Public License, either version 2
    + * of the License, or any later version.
    + *
    + * For the full copyright and license information, please read the
    + * LICENSE.txt file that was distributed with this source code.
    + *
    + * The TYPO3 project - inspiring people to share!
    + */
    +
    +namespace TYPO3\CMS\Form\Tests\Functional\Storage;
    +
    +use PHPUnit\Framework\Attributes\Test;
    +use TYPO3\CMS\Core\Database\ConnectionPool;
    +use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
    +use TYPO3\CMS\Form\Domain\DTO\FormData;
    +use TYPO3\CMS\Form\Domain\Repository\FormDefinitionRepository;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +/**
    + * Verifies that FormDefinitionRepository::add(), update(), and remove()
    + * actually persist their changes to the database through DataHandler.
    + */
    +final class FormDefinitionRepositoryTest extends FunctionalTestCase
    +{
    +    private FormDefinitionRepository $subject;
    +    protected array $coreExtensionsToLoad = ['form'];
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/be_users.csv');
    +        $backendUser = $this->setUpBackendUser(1);
    +        $GLOBALS['LANG'] = $this->get(LanguageServiceFactory::class)->createFromUserPreferences($backendUser);
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/form_definition.csv');
    +        $this->subject = $this->get(FormDefinitionRepository::class);
    +    }
    +
    +    #[Test]
    +    public function addPersistsRecordToDatabase(): void
    +    {
    +        $uid = $this->subject->add('NEW42', 0, $this->buildFormData('repo-add-form', 'Repository Add Form'));
    +
    +        self::assertNotNull($uid);
    +        self::assertGreaterThan(0, $uid);
    +
    +        $row = $this->subject->findByUid($uid);
    +        self::assertIsArray($row);
    +        self::assertSame('repo-add-form', $row['identifier']);
    +        self::assertSame('Repository Add Form', $row['label']);
    +        self::assertSame(0, (int)$row['pid']);
    +    }
    +
    +    #[Test]
    +    public function updatePersistsChangesToDatabase(): void
    +    {
    +        $result = $this->subject->update(1, $this->buildFormData('crud-test-form', 'Updated Repository Label'));
    +
    +        self::assertTrue($result);
    +
    +        $row = $this->subject->findByUid(1);
    +        self::assertIsArray($row);
    +        self::assertSame('Updated Repository Label', $row['label']);
    +        self::assertSame('crud-test-form', $row['identifier']);
    +    }
    +
    +    #[Test]
    +    public function removeSoftDeletesRecordInDatabase(): void
    +    {
    +        $result = $this->subject->remove(1);
    +
    +        self::assertTrue($result);
    +        // attempt to remove again, which should return null
    +        self::assertNull($this->subject->findByUid(1));
    +
    +        // verify the row is still present with deleted=1 (soft delete, not hard delete).
    +        // Remove all restrictions so the QueryBuilder can see the soft-deleted row.
    +        $queryBuilder = $this->get(ConnectionPool::class)
    +            ->getQueryBuilderForTable('form_definition');
    +        $queryBuilder->getRestrictions()->removeAll();
    +        $row = $queryBuilder
    +            ->select('uid', 'deleted')
    +            ->from('form_definition')
    +            ->where($queryBuilder->expr()->eq('uid', 1))
    +            ->executeQuery()
    +            ->fetchAssociative();
    +        self::assertIsArray($row);
    +        self::assertSame(1, (int)$row['deleted']);
    +    }
    +
    +    private function buildFormData(string $identifier, string $label): FormData
    +    {
    +        return FormData::fromArray([
    +            'identifier' => $identifier,
    +            'type' => 'Form',
    +            'label' => $label,
    +            'prototypeName' => 'standard',
    +            'renderingOptions' => [],
    +            'finishers' => [],
    +            'renderables' => [],
    +            'variants' => [],
    +        ]);
    +    }
    +}
    

Vulnerability mechanics

Root cause

"Backend users with write access to the form_definition database table could bypass persistence validation and permission checks when directly manipulating form definition records."

Attack vector

An attacker with write access to the form_definition database table could directly interact with the DataHandler to create, update, or delete form definition records. This bypasses the Form Framework's built-in validation and permission checks. By injecting arbitrary form configurations, the attacker could re-enable previously addressed attack vectors, such as SQL injection and privilege escalation [ref_id=1].

Affected code

The vulnerability lies within the `FormDefinitionRepository` class, specifically in the `add`, `remove`, and `update` methods. These methods directly interact with the `DataHandler` without sufficient checks. The patch modifies these methods to include calls to `FormDefinitionPersistenceGuard` before and after the `DataHandler` operations, ensuring that persistence commands are properly guarded [patch_id=5349005].

What the fix does

The patch introduces a `FormDefinitionPersistenceGuard` which is invoked before and after persistence operations like create, update, and delete. This guard enforces security checks, ensuring that only authorized operations with valid parameters are allowed to proceed. The `DataHandler` now calls `allowInvocation` and `consumeInvocation` methods on this guard, effectively re-introducing the necessary validation and permission checks that were previously bypassed [patch_id=5349005].

Preconditions

  • authThe attacker must have write access to the form_definition database table.

Generated on Jun 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

1