Skip to content

Commit 9f17a30

Browse files
eliashaeusslerohader
authored andcommitted
[SECURITY] Validate permissions on record undelete
When records are undeleted (=restored) using DataHandler, dedicated permission checks must be in place to verify that the current user is allowed to write to the requested table on the requested page. This is already in place for the delete action, but was incomplete for the undelete action. Resolves: #108772 Releases: main, 14.3, 13.4 Change-Id: Ic4200d4aa89cc5a9ceba1135357d9a98281d55f4 Security-Bulletin: TYPO3-CORE-SA-2026-011 Security-References: CVE-2026-47349 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/94410 Tested-by: Oliver Hader <oliver.hader@typo3.org> Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
1 parent 2e96dd0 commit 9f17a30

2 files changed

Lines changed: 196 additions & 1 deletion

File tree

typo3/sysext/core/Classes/DataHandling/DataHandler.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5930,6 +5930,12 @@ protected function undeleteRecord(string $table, int $uid): void
59305930
$deleteField = $schema->hasCapability(TcaSchemaCapability::SoftDelete) ? $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName() : '';
59315931
$timestampField = $schema->hasCapability(TcaSchemaCapability::UpdatedAt) ? $schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName() : '';
59325932

5933+
// Exit if the current user does not have permission to modify the table
5934+
if (!$this->checkModifyAccessList($table)) {
5935+
$this->log($table, 0, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Cannot restore "{table}:{uid}" without permission', null, ['table' => $table, 'uid' => $uid]);
5936+
return;
5937+
}
5938+
59335939
if ($record === null
59345940
|| $deleteField === ''
59355941
|| !isset($record[$deleteField])
@@ -5952,7 +5958,7 @@ protected function undeleteRecord(string $table, int $uid): void
59525958
$recordPid = (int)($record['pid'] ?? 0);
59535959
if ($recordPid > 0) {
59545960
// Record is not on root level. Parent page record must exist and must not be deleted itself.
5955-
$page = BackendUtility::getRecord('pages', $recordPid, 'deleted', '', false);
5961+
$page = BackendUtility::getRecord('pages', $recordPid, '*', '', false);
59565962
if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) {
59575963
$this->log(
59585964
$table,
@@ -5971,6 +5977,25 @@ protected function undeleteRecord(string $table, int $uid): void
59715977
);
59725978
return;
59735979
}
5980+
5981+
if (!$this->hasPermissionToInsert($table, $recordPid, $page)) {
5982+
$this->log(
5983+
'pages',
5984+
$recordPid,
5985+
SystemLogDatabaseAction::DELETE,
5986+
null,
5987+
SystemLogErrorClassification::USER_ERROR,
5988+
'Record "{table}:{uid}" can\'t be restored: Insufficient user permissions to target page {pid}',
5989+
null,
5990+
[
5991+
'table' => $table,
5992+
'uid' => $uid,
5993+
'pid' => $recordPid,
5994+
],
5995+
$recordPid
5996+
);
5997+
return;
5998+
}
59745999
}
59756000

59766001
// @todo: When restoring a not-default language record, it should be verified the default language
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 CMS project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*
12+
* For the full copyright and license information, please read the
13+
* LICENSE.txt file that was distributed with this source code.
14+
*
15+
* The TYPO3 project - inspiring people to share!
16+
*/
17+
18+
namespace TYPO3\CMS\Core\Tests\Functional\DataHandling\DataHandler;
19+
20+
use PHPUnit\Framework\Attributes\Test;
21+
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
22+
use TYPO3\CMS\Core\DataHandling\DataHandler;
23+
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
24+
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
25+
use TYPO3\CMS\Core\Type\Bitmask\Permission;
26+
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
27+
28+
final class UndeleteRecordTest extends FunctionalTestCase
29+
{
30+
protected array $coreExtensionsToLoad = ['workspaces'];
31+
private BackendUserAuthentication $backendUser;
32+
private DataHandler $subject;
33+
34+
protected function setUp(): void
35+
{
36+
parent::setUp();
37+
38+
$this->importCSVDataSet(dirname(__DIR__, 2) . '/Fixtures/be_groups.csv');
39+
$this->importCSVDataSet(dirname(__DIR__, 2) . '/Fixtures/be_users.csv');
40+
$this->importCSVDataSet(dirname(__DIR__, 2) . '/Fixtures/pages.csv');
41+
42+
$this->backendUser = $this->setUpBackendUser(9);
43+
// allow modifying the live workspace
44+
$this->backendUser->groupData['workspace_perms'] = 1;
45+
$this->backendUser->setWorkspace(0);
46+
$this->backendUser->setWebmounts([1]);
47+
48+
$this->subject = $this->get(DataHandler::class);
49+
}
50+
51+
#[Test]
52+
public function undeleteWorksAsAnEditor(): void
53+
{
54+
$this->subject->start([], []);
55+
$this->subject->deleteAction('pages', 10);
56+
self::assertTrue($this->databaseRecordExists('pages', 10, true));
57+
58+
$cmd = [
59+
'pages' => [
60+
10 => [
61+
'undelete' => 1,
62+
],
63+
],
64+
];
65+
66+
$this->subject->start([], $cmd);
67+
$this->subject->process_cmdmap();
68+
self::assertTrue($this->databaseRecordExists('pages', 10, false));
69+
}
70+
71+
#[Test]
72+
public function undeleteIsProhibitedIfMissingWritePermissionToParentPageAsAnEditor(): void
73+
{
74+
$this->subject->start([], []);
75+
$this->subject->deleteAction('pages', 10);
76+
self::assertTrue($this->databaseRecordExists('pages', 10, true));
77+
78+
$this->getConnectionPool()
79+
->getConnectionForTable('pages')
80+
->update(
81+
'pages',
82+
// deny new page creation on page with uid 4 (page 10 has pid 4)
83+
['perms_everybody' => Permission::ALL & ~Permission::PAGE_NEW],
84+
['uid' => 4]
85+
);
86+
87+
$cmd = [
88+
'pages' => [
89+
10 => [
90+
'undelete' => 1,
91+
],
92+
],
93+
];
94+
95+
$this->subject->start([], $cmd);
96+
$this->subject->process_cmdmap();
97+
98+
// Page must not have been deleted
99+
self::assertTrue($this->databaseRecordExists('pages', 10, true));
100+
$this->assertLogEntry('Record "pages:10" can\'t be restored: Insufficient user permissions to target page 4', 'pages', 10);
101+
}
102+
103+
#[Test]
104+
public function undeleteIsProhibitedIfMissingTablePermissionsAsAnEditor(): void
105+
{
106+
$this->subject->start([], []);
107+
$this->subject->deleteAction('pages', 10);
108+
self::assertTrue($this->databaseRecordExists('pages', 10, true));
109+
110+
$this->getConnectionPool()
111+
->getConnectionForTable('be_groups')
112+
->update(
113+
'be_groups',
114+
// deny new page modification
115+
['tables_modify' => 'tt_content'],
116+
['uid' => 9]
117+
);
118+
119+
// Reload backend user after changes to the user group
120+
$this->backendUser = $this->setUpBackendUser(9);
121+
122+
$cmd = [
123+
'pages' => [
124+
10 => [
125+
'undelete' => 1,
126+
],
127+
],
128+
];
129+
130+
$this->subject->start([], $cmd);
131+
$this->subject->process_cmdmap();
132+
133+
// Page must not have been deleted
134+
self::assertTrue($this->databaseRecordExists('pages', 10, true));
135+
$this->assertLogEntry('Attempt to modify table "pages" without permission');
136+
}
137+
138+
private function databaseRecordExists(string $tableName, int $id, ?bool $expectDeleted): bool
139+
{
140+
$schema = $this->get(TcaSchemaFactory::class)->get($tableName);
141+
$softDeleteFieldName = $schema->hasCapability(TcaSchemaCapability::SoftDelete)
142+
? $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName()
143+
: null;
144+
145+
$identifiers = ['uid' => $id];
146+
if ($expectDeleted !== null && $softDeleteFieldName !== null) {
147+
$identifiers[$softDeleteFieldName] = (int)$expectDeleted;
148+
}
149+
150+
$queryBuilder = $this->getConnectionPool()
151+
->getQueryBuilderForTable($tableName)
152+
->count('uid')
153+
->from($tableName);
154+
$queryBuilder->getRestrictions()->removeAll();
155+
foreach ($identifiers as $identifier => $value) {
156+
$queryBuilder->andWhere($queryBuilder->expr()->eq($identifier, $queryBuilder->createNamedParameter($value)));
157+
}
158+
return (int)$queryBuilder->executeQuery()->fetchOne() === 1;
159+
}
160+
161+
private function assertLogEntry(string $logTemplate, ?string $tableName = null, ?int $id = null): void
162+
{
163+
$text = sprintf($logTemplate, (string)$tableName, $id !== null ? (string)$id : '');
164+
$matches = array_filter(
165+
$this->subject->errorLog,
166+
static fn(string $entry): bool => str_ends_with($entry, $text)
167+
);
168+
self::assertNotSame([], $matches, 'Unable to find log entry: ' . $text);
169+
}
170+
}

0 commit comments

Comments
 (0)