CVE-2026-47349
Description
TYPO3 CMS backend users could restore records they were not authorized to modify via the Recycler module.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
TYPO3 CMS backend users could restore records they were not authorized to modify via the Recycler module.
Vulnerability
Backend users with access to the Recycler module could restore soft-deleted records on pages or for tables they were not authorized to modify. This vulnerability affects TYPO3 CMS versions before 10.4.57, 11.0.0-11.5.51, 12.0.0-12.4.46, 13.0.0-13.4.31, and 14.0.0-14.3.3 [3].
Exploitation
An attacker with backend user access and permissions to the Recycler module can exploit this vulnerability. The attacker needs to navigate to the Recycler module and use its functionality to restore soft-deleted records, even if they lack explicit authorization to modify the target pages or tables [3].
Impact
Successful exploitation allows an attacker to restore records that they are not authorized to modify. This could lead to unauthorized data recovery and potential manipulation of content or data within the TYPO3 CMS, depending on the nature of the restored records [3].
Mitigation
Update to TYPO3 versions 10.4.57 ELTS, 11.5.51 ELTS, 12.4.46 ELTS, 13.4.31 LTS, or 14.3.3 LTS. These versions contain fixes for the described vulnerability [3]. The commits related to the fix are available at [1] and [2].
AI Insight generated on Jun 9, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
29f17a307cf77[SECURITY] Validate permissions on record undelete
2 files changed · +196 −1
typo3/sysext/core/Classes/DataHandling/DataHandler.php+26 −1 modified@@ -5930,6 +5930,12 @@ protected function undeleteRecord(string $table, int $uid): void $deleteField = $schema->hasCapability(TcaSchemaCapability::SoftDelete) ? $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() : ''; $timestampField = $schema->hasCapability(TcaSchemaCapability::UpdatedAt) ? $schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName() : ''; + // Exit if the current user does not have permission to modify the table + if (!$this->checkModifyAccessList($table)) { + $this->log($table, 0, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Cannot restore "{table}:{uid}" without permission', null, ['table' => $table, 'uid' => $uid]); + return; + } + if ($record === null || $deleteField === '' || !isset($record[$deleteField]) @@ -5952,7 +5958,7 @@ protected function undeleteRecord(string $table, int $uid): void $recordPid = (int)($record['pid'] ?? 0); if ($recordPid > 0) { // Record is not on root level. Parent page record must exist and must not be deleted itself. - $page = BackendUtility::getRecord('pages', $recordPid, 'deleted', '', false); + $page = BackendUtility::getRecord('pages', $recordPid, '*', '', false); if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) { $this->log( $table, @@ -5971,6 +5977,25 @@ protected function undeleteRecord(string $table, int $uid): void ); return; } + + if (!$this->hasPermissionToInsert($table, $recordPid, $page)) { + $this->log( + 'pages', + $recordPid, + SystemLogDatabaseAction::DELETE, + null, + SystemLogErrorClassification::USER_ERROR, + 'Record "{table}:{uid}" can\'t be restored: Insufficient user permissions to target page {pid}', + null, + [ + 'table' => $table, + 'uid' => $uid, + 'pid' => $recordPid, + ], + $recordPid + ); + return; + } } // @todo: When restoring a not-default language record, it should be verified the default language
typo3/sysext/core/Tests/Functional/DataHandling/DataHandler/UndeleteRecordTest.php+170 −0 added@@ -0,0 +1,170 @@ +<?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\Core\Tests\Functional\DataHandling\DataHandler; + +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\DataHandling\DataHandler; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Type\Bitmask\Permission; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class UndeleteRecordTest extends FunctionalTestCase +{ + protected array $coreExtensionsToLoad = ['workspaces']; + private BackendUserAuthentication $backendUser; + private DataHandler $subject; + + protected function setUp(): void + { + parent::setUp(); + + $this->importCSVDataSet(dirname(__DIR__, 2) . '/Fixtures/be_groups.csv'); + $this->importCSVDataSet(dirname(__DIR__, 2) . '/Fixtures/be_users.csv'); + $this->importCSVDataSet(dirname(__DIR__, 2) . '/Fixtures/pages.csv'); + + $this->backendUser = $this->setUpBackendUser(9); + // allow modifying the live workspace + $this->backendUser->groupData['workspace_perms'] = 1; + $this->backendUser->setWorkspace(0); + $this->backendUser->setWebmounts([1]); + + $this->subject = $this->get(DataHandler::class); + } + + #[Test] + public function undeleteWorksAsAnEditor(): void + { + $this->subject->start([], []); + $this->subject->deleteAction('pages', 10); + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + + $cmd = [ + 'pages' => [ + 10 => [ + 'undelete' => 1, + ], + ], + ]; + + $this->subject->start([], $cmd); + $this->subject->process_cmdmap(); + self::assertTrue($this->databaseRecordExists('pages', 10, false)); + } + + #[Test] + public function undeleteIsProhibitedIfMissingWritePermissionToParentPageAsAnEditor(): void + { + $this->subject->start([], []); + $this->subject->deleteAction('pages', 10); + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + + $this->getConnectionPool() + ->getConnectionForTable('pages') + ->update( + 'pages', + // deny new page creation on page with uid 4 (page 10 has pid 4) + ['perms_everybody' => Permission::ALL & ~Permission::PAGE_NEW], + ['uid' => 4] + ); + + $cmd = [ + 'pages' => [ + 10 => [ + 'undelete' => 1, + ], + ], + ]; + + $this->subject->start([], $cmd); + $this->subject->process_cmdmap(); + + // Page must not have been deleted + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + $this->assertLogEntry('Record "pages:10" can\'t be restored: Insufficient user permissions to target page 4', 'pages', 10); + } + + #[Test] + public function undeleteIsProhibitedIfMissingTablePermissionsAsAnEditor(): void + { + $this->subject->start([], []); + $this->subject->deleteAction('pages', 10); + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + + $this->getConnectionPool() + ->getConnectionForTable('be_groups') + ->update( + 'be_groups', + // deny new page modification + ['tables_modify' => 'tt_content'], + ['uid' => 9] + ); + + // Reload backend user after changes to the user group + $this->backendUser = $this->setUpBackendUser(9); + + $cmd = [ + 'pages' => [ + 10 => [ + 'undelete' => 1, + ], + ], + ]; + + $this->subject->start([], $cmd); + $this->subject->process_cmdmap(); + + // Page must not have been deleted + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + $this->assertLogEntry('Attempt to modify table "pages" without permission'); + } + + private function databaseRecordExists(string $tableName, int $id, ?bool $expectDeleted): bool + { + $schema = $this->get(TcaSchemaFactory::class)->get($tableName); + $softDeleteFieldName = $schema->hasCapability(TcaSchemaCapability::SoftDelete) + ? $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() + : null; + + $identifiers = ['uid' => $id]; + if ($expectDeleted !== null && $softDeleteFieldName !== null) { + $identifiers[$softDeleteFieldName] = (int)$expectDeleted; + } + + $queryBuilder = $this->getConnectionPool() + ->getQueryBuilderForTable($tableName) + ->count('uid') + ->from($tableName); + $queryBuilder->getRestrictions()->removeAll(); + foreach ($identifiers as $identifier => $value) { + $queryBuilder->andWhere($queryBuilder->expr()->eq($identifier, $queryBuilder->createNamedParameter($value))); + } + return (int)$queryBuilder->executeQuery()->fetchOne() === 1; + } + + private function assertLogEntry(string $logTemplate, ?string $tableName = null, ?int $id = null): void + { + $text = sprintf($logTemplate, (string)$tableName, $id !== null ? (string)$id : ''); + $matches = array_filter( + $this->subject->errorLog, + static fn(string $entry): bool => str_ends_with($entry, $text) + ); + self::assertNotSame([], $matches, 'Unable to find log entry: ' . $text); + } +}
92f08d8944f1[SECURITY] Validate permissions on record undelete
2 files changed · +196 −1
typo3/sysext/core/Classes/DataHandling/DataHandler.php+26 −1 modified@@ -5969,6 +5969,12 @@ protected function undeleteRecord(string $table, int $uid): void $deleteField = $schema->hasCapability(TcaSchemaCapability::SoftDelete) ? $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() : ''; $timestampField = $schema->hasCapability(TcaSchemaCapability::UpdatedAt) ? $schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName() : ''; + // Exit if the current user does not have permission to modify the table + if (!$this->checkModifyAccessList($table)) { + $this->log($table, 0, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Cannot restore "{table}:{uid}" without permission', null, ['table' => $table, 'uid' => $uid]); + return; + } + if ($record === null || $deleteField === '' || !isset($record[$deleteField]) @@ -5991,7 +5997,7 @@ protected function undeleteRecord(string $table, int $uid): void $recordPid = (int)($record['pid'] ?? 0); if ($recordPid > 0) { // Record is not on root level. Parent page record must exist and must not be deleted itself. - $page = BackendUtility::getRecord('pages', $recordPid, 'deleted', '', false); + $page = BackendUtility::getRecord('pages', $recordPid, '*', '', false); if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) { $this->log( $table, @@ -6010,6 +6016,25 @@ protected function undeleteRecord(string $table, int $uid): void ); return; } + + if (!$this->hasPermissionToInsert($table, $recordPid, $page)) { + $this->log( + 'pages', + $recordPid, + SystemLogDatabaseAction::DELETE, + null, + SystemLogErrorClassification::USER_ERROR, + 'Record "{table}:{uid}" can\'t be restored: Insufficient user permissions to target page {pid}', + null, + [ + 'table' => $table, + 'uid' => $uid, + 'pid' => $recordPid, + ], + $recordPid + ); + return; + } } // @todo: When restoring a not-default language record, it should be verified the default language
typo3/sysext/core/Tests/Functional/DataHandling/DataHandler/UndeleteRecordTest.php+170 −0 added@@ -0,0 +1,170 @@ +<?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\Core\Tests\Functional\DataHandling\DataHandler; + +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\DataHandling\DataHandler; +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability; +use TYPO3\CMS\Core\Schema\TcaSchemaFactory; +use TYPO3\CMS\Core\Type\Bitmask\Permission; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class UndeleteRecordTest extends FunctionalTestCase +{ + protected array $coreExtensionsToLoad = ['workspaces']; + private BackendUserAuthentication $backendUser; + private DataHandler $subject; + + protected function setUp(): void + { + parent::setUp(); + + $this->importCSVDataSet(dirname(__DIR__, 2) . '/Fixtures/be_groups.csv'); + $this->importCSVDataSet(dirname(__DIR__, 2) . '/Fixtures/be_users.csv'); + $this->importCSVDataSet(dirname(__DIR__, 2) . '/Fixtures/pages.csv'); + + $this->backendUser = $this->setUpBackendUser(9); + // allow modifying the live workspace + $this->backendUser->groupData['workspace_perms'] = 1; + $this->backendUser->setWorkspace(0); + $this->backendUser->setWebmounts([1]); + + $this->subject = $this->get(DataHandler::class); + } + + #[Test] + public function undeleteWorksAsAnEditor(): void + { + $this->subject->start([], []); + $this->subject->deleteAction('pages', 10); + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + + $cmd = [ + 'pages' => [ + 10 => [ + 'undelete' => 1, + ], + ], + ]; + + $this->subject->start([], $cmd); + $this->subject->process_cmdmap(); + self::assertTrue($this->databaseRecordExists('pages', 10, false)); + } + + #[Test] + public function undeleteIsProhibitedIfMissingWritePermissionToParentPageAsAnEditor(): void + { + $this->subject->start([], []); + $this->subject->deleteAction('pages', 10); + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + + $this->getConnectionPool() + ->getConnectionForTable('pages') + ->update( + 'pages', + // deny new page creation on page with uid 4 (page 10 has pid 4) + ['perms_everybody' => Permission::ALL & ~Permission::PAGE_NEW], + ['uid' => 4] + ); + + $cmd = [ + 'pages' => [ + 10 => [ + 'undelete' => 1, + ], + ], + ]; + + $this->subject->start([], $cmd); + $this->subject->process_cmdmap(); + + // Page must not have been deleted + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + $this->assertLogEntry('Record "pages:10" can\'t be restored: Insufficient user permissions to target page 4', 'pages', 10); + } + + #[Test] + public function undeleteIsProhibitedIfMissingTablePermissionsAsAnEditor(): void + { + $this->subject->start([], []); + $this->subject->deleteAction('pages', 10); + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + + $this->getConnectionPool() + ->getConnectionForTable('be_groups') + ->update( + 'be_groups', + // deny new page modification + ['tables_modify' => 'tt_content'], + ['uid' => 9] + ); + + // Reload backend user after changes to the user group + $this->backendUser = $this->setUpBackendUser(9); + + $cmd = [ + 'pages' => [ + 10 => [ + 'undelete' => 1, + ], + ], + ]; + + $this->subject->start([], $cmd); + $this->subject->process_cmdmap(); + + // Page must not have been deleted + self::assertTrue($this->databaseRecordExists('pages', 10, true)); + $this->assertLogEntry('Attempt to modify table "pages" without permission'); + } + + private function databaseRecordExists(string $tableName, int $id, ?bool $expectDeleted): bool + { + $schema = $this->get(TcaSchemaFactory::class)->get($tableName); + $softDeleteFieldName = $schema->hasCapability(TcaSchemaCapability::SoftDelete) + ? $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() + : null; + + $identifiers = ['uid' => $id]; + if ($expectDeleted !== null && $softDeleteFieldName !== null) { + $identifiers[$softDeleteFieldName] = (int)$expectDeleted; + } + + $queryBuilder = $this->getConnectionPool() + ->getQueryBuilderForTable($tableName) + ->count('uid') + ->from($tableName); + $queryBuilder->getRestrictions()->removeAll(); + foreach ($identifiers as $identifier => $value) { + $queryBuilder->andWhere($queryBuilder->expr()->eq($identifier, $queryBuilder->createNamedParameter($value))); + } + return (int)$queryBuilder->executeQuery()->fetchOne() === 1; + } + + private function assertLogEntry(string $logTemplate, ?string $tableName = null, ?int $id = null): void + { + $text = sprintf($logTemplate, (string)$tableName, $id !== null ? (string)$id : ''); + $matches = array_filter( + $this->subject->errorLog, + static fn(string $entry): bool => str_ends_with($entry, $text) + ); + self::assertNotSame([], $matches, 'Unable to find log entry: ' . $text); + } +}
Vulnerability mechanics
No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.
References
3News mentions
1- TYPO3 CMS: Thirteen Backend Vulnerabilities Disclosed on June 9, 2026Vypr Intelligence · Jun 9, 2026