VYPR
High severityOSV Advisory· Published Jan 13, 2026· Updated Jan 13, 2026

TYPO3 CMS Allows Broken Access Control in Recycler Module

CVE-2025-59022

Description

Backend users who had access to the recycler module could delete arbitrary data from any database table defined in the TCA - regardless of whether they had permission to that particular table. This allowed attackers to purge and destroy critical site data, effectively rendering the website unavailable. This issue affects TYPO3 CMS versions 10.0.0-10.4.54, 11.0.0-11.5.48, 12.0.0-12.4.40, 13.0.0-13.4.22 and 14.0.0-14.0.1.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
typo3/cms-recyclerPackagist
>= 14.0.0, < 14.0.214.0.2
typo3/cms-recyclerPackagist
>= 13.0.0, < 13.4.2313.4.23
typo3/cms-recyclerPackagist
>= 12.0.0, < 12.4.4112.4.41
typo3/cms-recyclerPackagist
>= 11.0.0, < 11.5.4911.5.49
typo3/cms-recyclerPackagist
>= 10.0.0, < 10.4.5510.4.55

Affected products

1

Patches

3
336d6f165458

[SECURITY] Avoid record deletion without permissions in recycler module

https://github.com/TYPO3/typo3Elias HäußlerJan 13, 2026via ghsa
8 files changed · +422 17
  • typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php+3 2 modified
    @@ -299,11 +299,12 @@ protected function isMemberOfGroup(int $groupId): bool
          *
          * @param array $row Is the pagerow for which the permissions is checked
          * @param int $perms Is the binary representation of the permission we are going to check. Every bit in this number represents a permission that must be set. See function explanation.
    +     * @param bool $useDeleteClause Use the delete clause to check if a record is deleted
          * @return bool
          */
    -    public function doesUserHaveAccess($row, $perms)
    +    public function doesUserHaveAccess($row, $perms, bool $useDeleteClause = true)
         {
    -        $userPerms = $this->calcPerms($row);
    +        $userPerms = $this->calcPerms($row, $useDeleteClause);
             return ($userPerms & $perms) == $perms;
         }
     
    
  • typo3/sysext/core/Classes/DataHandling/DataHandler.php+14 6 modified
    @@ -5111,6 +5111,12 @@ public function deleteAction(string $table, int|array $uidOrRow, bool $noRecordC
             }
             unset($uidOrRow);
     
    +        // Exit if the current user does not have permission to modify the table and $noRecordCheck is set to false
    +        if (!$noRecordCheck && !$this->checkModifyAccessList($table)) {
    +            $this->log($table, 0, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Cannot delete "{table}:{uid}" without permission', null, ['table' => $table, 'uid' => $uid]);
    +            return;
    +        }
    +
             if ((int)($recordToDelete['t3ver_wsid'] ?? null) !== 0) {
                 // When uid to a workspace record is given, then discard always. This is coming from workspace BE
                 // module "waste bin" icon, which sends the workspace uid of the record with the intention to
    @@ -5217,7 +5223,7 @@ protected function deleteEl(string $table, array $recordToDelete, bool $noRecord
                         return;
                     }
                 }
    -            if (!$noRecordCheck && is_string($pagesDeletePermissionError = $this->canDeletePage($recordToDelete, $defaultLanguagePageRecord, $subPages))) {
    +            if (!$noRecordCheck && is_string($pagesDeletePermissionError = $this->canDeletePage($recordToDelete, $defaultLanguagePageRecord, $subPages, !$forceHardDelete))) {
                     $this->log('pages', $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::SYSTEM_ERROR, $pagesDeletePermissionError);
                     return;
                 }
    @@ -5574,8 +5580,9 @@ protected function deleteSpecificPage(array $recordToDelete, bool $forceHardDele
          * @param array $defaultLanguagePageRecord The default language page record if $pageRecord is a localization. Identical to
          *                                       $pageRecord if $pageRecord *is* a default language record
          * @param array $subPages List of subpages. Only set if a default language page record should be deleted.
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          */
    -    protected function canDeletePage(array $pageRecord, array $defaultLanguagePageRecord, array $subPages): ?string
    +    protected function canDeletePage(array $pageRecord, array $defaultLanguagePageRecord, array $subPages, bool $useDeleteClause = true): ?string
         {
             $languageCapability = $this->tcaSchemaFactory->get('pages')->getCapability(TcaSchemaCapability::Language);
             $localizationParentFieldName = $languageCapability->getTranslationOriginPointerField()->getName();
    @@ -5584,14 +5591,14 @@ protected function canDeletePage(array $pageRecord, array $defaultLanguagePageRe
             if ($localizationParent > 0) {
                 $pageRecordToCheck = $defaultLanguagePageRecord;
             }
    -        if (!$this->hasPagePermission(Permission::PAGE_DELETE, $pageRecordToCheck)) {
    +        if (!$this->hasPagePermission(Permission::PAGE_DELETE, $pageRecordToCheck, $useDeleteClause)) {
                 return 'Attempt to delete page without permissions';
             }
             if (!$this->BE_USER->recordEditAccessInternals('pages', $pageRecord, false, null, $localizationParent === $pageRecord['uid'])) {
                 return 'Attempt to delete page which has prohibited localizations';
             }
             foreach ($subPages as $subPage) {
    -            if (!$this->BE_USER->isAdmin() && !$this->BE_USER->doesUserHaveAccess($subPage, Permission::PAGE_DELETE)) {
    +            if (!$this->BE_USER->isAdmin() && !$this->BE_USER->doesUserHaveAccess($subPage, Permission::PAGE_DELETE, $useDeleteClause)) {
                     return 'Attempt to delete pages in branch without permissions';
                 }
                 if (!$this->BE_USER->recordEditAccessInternals('pages', $subPage, false, null, true)) {
    @@ -7478,9 +7485,10 @@ protected function doesPageHaveUnallowedTables(int $page_uid, int $doktype): arr
          *
          * @param int $perms Permission restrictions to observe. An integer bitmask of Permission constants
          * @param array $page Full page record
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          * @internal Strictly internal. May change or vanish any time.
          */
    -    public function hasPagePermission(int $perms, array $page): bool
    +    public function hasPagePermission(int $perms, array $page, bool $useDeleteClause = true): bool
         {
             if (!$perms) {
                 throw new \RuntimeException('Invalid $perms bitset: "' . $perms . '"', 1270853920);
    @@ -7490,7 +7498,7 @@ public function hasPagePermission(int $perms, array $page): bool
             }
     
             $pagesSchema = $this->tcaSchemaFactory->get('pages');
    -        if (!$pagesSchema->hasCapability(TcaSchemaCapability::RestrictionWebMount) && !$this->BE_USER->isInWebMount($page)) {
    +        if (!$pagesSchema->hasCapability(TcaSchemaCapability::RestrictionWebMount) && !$this->BE_USER->isInWebMount($page, '', $useDeleteClause)) {
                 return false;
             }
             $beUserUid = $this->BE_USER->getUserId();
    
  • typo3/sysext/core/Tests/Functional/DataHandling/DataHandler/DeleteActionTest.php+161 0 added
    @@ -0,0 +1,161 @@
    +<?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\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +final class DeleteActionTest extends FunctionalTestCase
    +{
    +    private const LOG_TEMPLATE_TABLE = 'Cannot delete "%s:%d" without permission';
    +    private const LOG_TEMPLATE_WEBMOUNT = 'Attempt to delete page without permissions';
    +
    +    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);
    +        $this->subject->start([], []);
    +    }
    +
    +    #[Test]
    +    public function softDeletingPageInWebMountIsAllowed(): void
    +    {
    +        $this->subject->deleteAction('pages', 10);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertTrue($this->databaseRecordExists('pages', 10, true));
    +    }
    +
    +    #[Test]
    +    public function softDeletingNestedPageInWebMountIsDenied(): void
    +    {
    +        $this->subject->deleteAction('pages', 4);
    +        // @todo due to https://forge.typo3.org/issues/101635, the `runtime` cache cannot be a `NullCache`
    +        // (besides the fact, that `DataHandler` fails to clear `runtime` caches when generating the root-line)
    +        $this->get('cache.runtime')->flush();
    +        $this->subject->deleteAction('pages', 10);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_WEBMOUNT);
    +        self::assertTrue($this->databaseRecordExists('pages', 4, true));
    +        self::assertTrue($this->databaseRecordExists('pages', 10, true));
    +    }
    +
    +    #[Test]
    +    public function softDeletingPageWithoutTablePermissionIsDenied(): void
    +    {
    +        $this->backendUser->groupData['tables_modify'] = '';
    +
    +        $this->subject->deleteAction('pages', 10);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_TABLE, 'pages', 10);
    +        self::assertTrue($this->databaseRecordExists('pages', 10, false));
    +    }
    +
    +    #[Test]
    +    public function softDeletingPageNotInWebMountIsDenied(): void
    +    {
    +        $this->backendUser->setWebmounts([9]);
    +
    +        $this->subject->deleteAction('pages', 10);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_WEBMOUNT);
    +        self::assertTrue($this->databaseRecordExists('pages', 10, false));
    +    }
    +
    +    #[Test]
    +    public function softDeletingDanglingPageVersionIsDenied(): void
    +    {
    +        $this->subject->deleteAction('pages', 11);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertTrue($this->databaseRecordExists('pages', 11, false));
    +    }
    +
    +    #[Test]
    +    public function hardDeletingPageInWebMountIsAllowed(): void
    +    {
    +        // first soft-delete
    +        $this->subject->deleteAction('pages', 10);
    +        // second hard-delete
    +        $this->subject->deleteAction('pages', 10, false, true);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertFalse($this->databaseRecordExists('pages', 10, null));
    +    }
    +
    +    #[Test]
    +    public function hardDeletingNestedPageInWebMountIsDenied(): void
    +    {
    +        // first soft-delete the parent page
    +        $this->subject->deleteAction('pages', 4);
    +        // @todo due to https://forge.typo3.org/issues/101635, the `runtime` cache cannot be a `NullCache`
    +        // (besides the fact, that `DataHandler` fails to clear `runtime` caches when generating the root-line)
    +        $this->get('cache.runtime')->flush();
    +        // second hard-delete the nested page
    +        $this->subject->deleteAction('pages', 10, false, true);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertTrue($this->databaseRecordExists('pages', 4, true));
    +        self::assertFalse($this->databaseRecordExists('pages', 10, null));
    +    }
    +
    +    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);
    +    }
    +}
    
  • typo3/sysext/recycler/Classes/Controller/RecyclerAjaxController.php+21 5 modified
    @@ -106,13 +106,10 @@ public function dispatch(ServerRequestInterface $request): ResponseInterface
                     $model = GeneralUtility::makeInstance(DeletedRecords::class);
                     $totalDeleted = $model->getTotalCount($this->conf['startUid'], $this->conf['table'], $this->conf['depth'], $this->conf['filterTxt']);
     
    -                $allowDelete = $this->getBackendUser()->isAdmin()
    -                    ?: (bool)($this->getBackendUser()->getTSConfig()['mod.']['recycler.']['allowDelete'] ?? false);
    -
                     $view = $this->backendViewFactory->create($request);
                     $view->assign('showTableHeader', empty($this->conf['table']));
                     $view->assign('showTableName', $this->getBackendUser()->shallDisplayDebugInformation());
    -                $view->assign('allowDelete', $allowDelete);
    +                $view->assign('allowDelete', $this->isDeleteAllowed());
                     $view->assign('groupedRecords', $this->transform($deletedRowsArray));
                     $content = [
                         'rows' => $view->render('Ajax/RecordsTable'),
    @@ -137,6 +134,14 @@ public function dispatch(ServerRequestInterface $request): ResponseInterface
                     ];
                     break;
                 case 'deleteRecords':
    +                if (!$this->isDeleteAllowed()) {
    +                    $content = [
    +                        'success' => false,
    +                        'message' => LocalizationUtility::translate('flashmessage.delete.unauthorized', 'recycler'),
    +                    ];
    +                    break;
    +                }
    +
                     if (empty($this->conf['records']) || !is_array($this->conf['records'])) {
                         $content = [
                             'success' => false,
    @@ -150,14 +155,25 @@ public function dispatch(ServerRequestInterface $request): ResponseInterface
                     $affectedRecords = count($this->conf['records']);
                     $messageKey = 'flashmessage.delete.' . ($success ? 'success' : 'failure') . '.' . ($affectedRecords === 1 ? 'singular' : 'plural');
                     $content = [
    -                    'success' => true,
    +                    'success' => $success,
                         'message' => sprintf((string)LocalizationUtility::translate($messageKey, 'recycler'), $affectedRecords),
                     ];
                     break;
             }
             return new JsonResponse($content);
         }
     
    +    protected function isDeleteAllowed(): bool
    +    {
    +        $backendUser = $this->getBackendUser();
    +
    +        if ($backendUser->isAdmin()) {
    +            return true;
    +        }
    +
    +        return (bool)($backendUser->getTSConfig()['mod.']['recycler.']['allowDelete'] ?? false);
    +    }
    +
         /**
          * Transforms the rows for the deleted records by grouping them
          * by their corresponding table and processing the raw record data.
    
  • typo3/sysext/recycler/Classes/Domain/Model/DeletedRecords.php+1 1 modified
    @@ -318,7 +318,7 @@ public function deleteData(?array $recordsArray): bool
                 $tce->start([], []);
                 foreach ($recordsArray as $record) {
                     [$table, $uid] = explode(':', $record);
    -                $tce->deleteAction($table, (int)$uid, true, true);
    +                $tce->deleteAction($table, (int)$uid, false, true);
                 }
                 return true;
             }
    
  • typo3/sysext/recycler/Resources/Private/Language/locallang.xlf+3 0 modified
    @@ -75,6 +75,9 @@
           <trans-unit id="modal.massundo.text">
             <source>Restore all selected records and any subpages? All records are restored to their original position and may take effect immediately, including the pages and their content that may become visible online. Undoing this requires deleting each record individually.</source>
           </trans-unit>
    +      <trans-unit id="flashmessage.delete.unauthorized">
    +        <source>Insufficient permissions to delete records.</source>
    +      </trans-unit>
           <trans-unit id="flashmessage.delete.norecordsselected">
             <source>No records set to delete.</source>
           </trans-unit>
    
  • typo3/sysext/recycler/Tests/Functional/Controller/RecyclerAjaxControllerTest.php+215 0 added
    @@ -0,0 +1,215 @@
    +<?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\Recycler\Tests\Functional\Controller;
    +
    +use PHPUnit\Framework\Attributes\DataProvider;
    +use PHPUnit\Framework\Attributes\Test;
    +use TYPO3\CMS\Core\Http\JsonResponse;
    +use TYPO3\CMS\Core\Http\ServerRequest;
    +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
    +use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
    +use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
    +use TYPO3\CMS\Recycler\Controller\RecyclerAjaxController;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +/**
    + * @phpstan-type DatabaseState array{regular: list<int>, softDeleted: list<int>}
    + */
    +final class RecyclerAjaxControllerTest extends FunctionalTestCase
    +{
    +    protected array $coreExtensionsToLoad = [
    +        'recycler',
    +    ];
    +
    +    private RecyclerAjaxController $subject;
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/Database/be_groups.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/Database/be_users.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/Database/pages.csv');
    +
    +        $this->subject = $this->get(RecyclerAjaxController::class);
    +    }
    +
    +    #[Test]
    +    public function dispatchWithDeleteRecordsActionDoesNothingIfUserLacksDeletionPermissions(): void
    +    {
    +        $this->setUpBackendUser(2);
    +
    +        $request = new ServerRequest('https://typo3-testing.local', 'POST');
    +        $request = $request->withParsedBody(['action' => 'deleteRecords']);
    +
    +        $expected = [
    +            'success' => false,
    +            'message' => LocalizationUtility::translate('flashmessage.delete.unauthorized', 'recycler'),
    +        ];
    +
    +        $actual = $this->subject->dispatch($request);
    +
    +        self::assertInstanceOf(JsonResponse::class, $actual);
    +        self::assertJsonStringEqualsJsonString(
    +            \json_encode($expected, JSON_THROW_ON_ERROR),
    +            (string)$actual->getBody(),
    +        );
    +    }
    +
    +    /**
    +     * @return \Generator<string, array{int}>
    +     */
    +    public static function dispatchWithDeleteRecordsActionDoesNothingIfNoRecordsAreProvidedInRequestDataProvider(): \Generator
    +    {
    +        yield 'admin' => [1];
    +        yield 'permitted editor with TSconfig' => [3];
    +    }
    +
    +    #[DataProvider('dispatchWithDeleteRecordsActionDoesNothingIfNoRecordsAreProvidedInRequestDataProvider')]
    +    #[Test]
    +    public function dispatchWithDeleteRecordsActionDoesNothingIfNoRecordsAreProvidedInRequest(int $backendUser): void
    +    {
    +        $this->setUpBackendUser($backendUser);
    +
    +        $request = new ServerRequest('https://typo3-testing.local', 'POST');
    +        $request = $request->withParsedBody(['action' => 'deleteRecords']);
    +
    +        $expected = [
    +            'success' => false,
    +            'message' => LocalizationUtility::translate('flashmessage.delete.norecordsselected', 'recycler'),
    +        ];
    +
    +        $actual = $this->subject->dispatch($request);
    +
    +        self::assertInstanceOf(JsonResponse::class, $actual);
    +        self::assertJsonStringEqualsJsonString(
    +            \json_encode($expected, JSON_THROW_ON_ERROR),
    +            (string)$actual->getBody(),
    +        );
    +    }
    +
    +    public static function dispatchWithDeleteRecordsActionDeletesGivenRecordsWherePermissionsAreGivenDataProvider(): \Generator
    +    {
    +        yield 'admin' => [
    +            'backendUser' => 1,
    +            'records' => ['pages:4', 'pages:6', 'pages:7'],
    +            // @todo response is misleading, actually 4, 5 (subpage of 4), 6, 6 were delete (= 4 records)
    +            'expectedResponse' => ['success' => true, 'message' => '3 records were deleted.'],
    +            'expectedDatabaseState' => [
    +                'pages' => ['regular' => [1, 2], 'softDeleted' => [3]],
    +            ],
    +        ];
    +
    +        yield 'editor with at least one record without permissions' => [
    +            'backendUser' => 3,
    +            'records' => [
    +                'pages:3',
    +                'pages:4',
    +                'pages:5', // subpage of 4 => already deleted when 4 is deleted
    +                'pages:6', // outside of configured mount points => no permission to delete
    +                'pages:7', // perms_everybody = 0 => no permission to delete
    +            ],
    +            'expectedResponse' => null,
    +            // @todo recycler does not return reliable counts
    +            // ['success' => false, 'message' => 'Could not delete 3 records.'],
    +            'expectedDatabaseState' => [
    +                'pages' => ['regular' => [1, 2], 'softDeleted' => [6, 7]],
    +            ],
    +        ];
    +
    +        yield 'editor with permissions on all records' => [
    +            'backendUser' => 3,
    +            'records' => ['pages:3', 'pages:4'],
    +            // @todo response is misleading, actually 3, 4, 5 (subpage of 4) were delete (= 3 records)
    +            'expectedResponse' => ['success' => true, 'message' => '2 records were deleted.'],
    +            'expectedDatabaseState' => [
    +                'pages' => ['regular' => [1, 2], 'softDeleted' => [6, 7]],
    +            ],
    +        ];
    +    }
    +
    +    /**
    +     * @param list<string> $records
    +     * @param array{success: bool, message: string} $expectedResponse
    +     * @param array<string, DatabaseState> $expectedDatabaseState
    +     */
    +    #[DataProvider('dispatchWithDeleteRecordsActionDeletesGivenRecordsWherePermissionsAreGivenDataProvider')]
    +    #[Test]
    +    public function dispatchWithDeleteRecordsActionDeletesGivenRecordsWherePermissionsAreGiven(
    +        int $backendUser,
    +        array $records,
    +        ?array $expectedResponse,
    +        array $expectedDatabaseState = [],
    +    ): void {
    +        $this->setUpBackendUser($backendUser);
    +
    +        $request = new ServerRequest('https://typo3-testing.local', 'POST');
    +        $request = $request->withParsedBody([
    +            'action' => 'deleteRecords',
    +            'records' => $records,
    +        ]);
    +
    +        $actual = $this->subject->dispatch($request);
    +
    +        self::assertInstanceOf(JsonResponse::class, $actual);
    +        if ($expectedResponse !== null) {
    +            self::assertJsonStringEqualsJsonString(
    +                \json_encode($expectedResponse, JSON_THROW_ON_ERROR),
    +                (string)$actual->getBody(),
    +            );
    +        }
    +        foreach ($expectedDatabaseState as $tableName => $expectedDatabaseStateForTable) {
    +            $actualDatabaseState = $this->fetchDatabaseState($tableName);
    +            self::assertSame($expectedDatabaseStateForTable, $actualDatabaseState);
    +        }
    +    }
    +
    +    /**
    +     * @return DatabaseState
    +     */
    +    private function fetchDatabaseState(string $tableName): array
    +    {
    +        $result = ['regular' => []];
    +
    +        $schema = $this->get(TcaSchemaFactory::class)->get($tableName);
    +        $softDeleteFieldName = $schema->hasCapability(TcaSchemaCapability::SoftDelete)
    +            ? $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName()
    +            : null;
    +
    +        $queryBuilder = $this->getConnectionPool()
    +            ->getQueryBuilderForTable($tableName);
    +        $queryBuilder->getRestrictions()->removeAll();
    +
    +        $selectFields = ['uid'];
    +        if ($softDeleteFieldName) {
    +            $selectFields[] = $softDeleteFieldName;
    +            $result['softDeleted'] = [];
    +        }
    +        $rows = $queryBuilder
    +            ->select(...$selectFields)
    +            ->from($tableName)
    +            ->fetchAllAssociative();
    +
    +        foreach ($rows as $row) {
    +            $id = $row['uid'];
    +            $key = empty($row[$softDeleteFieldName]) ? 'regular' : 'softDeleted';
    +            $result[$key][] = $id;
    +        }
    +        return $result;
    +    }
    +}
    
  • typo3/sysext/recycler/Tests/Functional/Fixtures/Database/be_users.csv+4 3 modified
    @@ -1,4 +1,5 @@
     "be_users"
    -,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","lastlogin","workspace_id","db_mountpoints","usergroup"
    -,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,1371033743,0,"",""
    -,2,0,1452944912,"editor","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1452944912,1,0,1452944915,0,"1","1"
    +,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","lastlogin","workspace_id","db_mountpoints","usergroup","TSconfig"
    +,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,1371033743,0,"","",
    +,2,0,1452944912,"editor","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1452944912,1,0,1452944915,0,"1","1",
    +,3,0,1751001736,"editor1","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1751001736,1,0,1452944915,0,"1","1","mod.recycler.allowDelete = 1"
    
efb9528f9882

[SECURITY] Avoid record deletion without permissions in recycler module

https://github.com/TYPO3/typo3Elias HäußlerJan 13, 2026via ghsa
8 files changed · +422 17
  • typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php+3 2 modified
    @@ -294,11 +294,12 @@ protected function isMemberOfGroup(int $groupId): bool
          *
          * @param array $row Is the pagerow for which the permissions is checked
          * @param int $perms Is the binary representation of the permission we are going to check. Every bit in this number represents a permission that must be set. See function explanation.
    +     * @param bool $useDeleteClause Use the delete clause to check if a record is deleted
          * @return bool
          */
    -    public function doesUserHaveAccess($row, $perms)
    +    public function doesUserHaveAccess($row, $perms, bool $useDeleteClause = true)
         {
    -        $userPerms = $this->calcPerms($row);
    +        $userPerms = $this->calcPerms($row, $useDeleteClause);
             return ($userPerms & $perms) == $perms;
         }
     
    
  • typo3/sysext/core/Classes/DataHandling/DataHandler.php+14 6 modified
    @@ -5219,6 +5219,12 @@ public function deleteAction(string $table, int|array $uidOrRow, bool $noRecordC
             }
             unset($uidOrRow);
     
    +        // Exit if the current user does not have permission to modify the table and $noRecordCheck is set to false
    +        if (!$noRecordCheck && !$this->checkModifyAccessList($table)) {
    +            $this->log($table, 0, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::USER_ERROR, 'Cannot delete "{table}:{uid}" without permission', null, ['table' => $table, 'uid' => $uid]);
    +            return;
    +        }
    +
             if ((int)($recordToDelete['t3ver_wsid'] ?? null) !== 0) {
                 // When uid to a workspace record is given, then discard always. This is coming from workspace BE
                 // module "waste bin" icon, which sends the workspace uid of the record with the intention to
    @@ -5339,7 +5345,7 @@ public function deleteEl(string $table, int|array $uidOrRow, bool $noRecordCheck
                         return;
                     }
                 }
    -            if (!$noRecordCheck && is_string($pagesDeletePermissionError = $this->canDeletePage($recordToDelete, $defaultLanguagePageRecord, $subPages))) {
    +            if (!$noRecordCheck && is_string($pagesDeletePermissionError = $this->canDeletePage($recordToDelete, $defaultLanguagePageRecord, $subPages, !$forceHardDelete))) {
                     $this->log('pages', $uid, SystemLogDatabaseAction::DELETE, null, SystemLogErrorClassification::SYSTEM_ERROR, $pagesDeletePermissionError);
                     return;
                 }
    @@ -5714,8 +5720,9 @@ protected function deleteSpecificPage(array $recordToDelete, bool $forceHardDele
          * @param array $defaultLanguagePageRecord The default language page record if $pageRecord is a localization. Identical to
          *                                       $pageRecord if $pageRecord *is* a default language record
          * @param array $subPages List of subpages. Only set if a default language page record should be deleted.
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          */
    -    protected function canDeletePage(array $pageRecord, array $defaultLanguagePageRecord, array $subPages): ?string
    +    protected function canDeletePage(array $pageRecord, array $defaultLanguagePageRecord, array $subPages, bool $useDeleteClause = true): ?string
         {
             $languageCapability = $this->tcaSchemaFactory->get('pages')->getCapability(TcaSchemaCapability::Language);
             $localizationParentFieldName = $languageCapability->getTranslationOriginPointerField()->getName();
    @@ -5724,14 +5731,14 @@ protected function canDeletePage(array $pageRecord, array $defaultLanguagePageRe
             if ($localizationParent > 0) {
                 $pageRecordToCheck = $defaultLanguagePageRecord;
             }
    -        if (!$this->hasPagePermission(Permission::PAGE_DELETE, $pageRecordToCheck)) {
    +        if (!$this->hasPagePermission(Permission::PAGE_DELETE, $pageRecordToCheck, $useDeleteClause)) {
                 return 'Attempt to delete page without permissions';
             }
             if (!$this->BE_USER->recordEditAccessInternals('pages', $pageRecord, false, null, $localizationParent === $pageRecord['uid'])) {
                 return 'Attempt to delete page which has prohibited localizations';
             }
             foreach ($subPages as $subPage) {
    -            if (!$this->admin && !$this->BE_USER->doesUserHaveAccess($subPage, Permission::PAGE_DELETE)) {
    +            if (!$this->admin && !$this->BE_USER->doesUserHaveAccess($subPage, Permission::PAGE_DELETE, $useDeleteClause)) {
                     return 'Attempt to delete pages in branch without permissions';
                 }
                 if (!$this->BE_USER->recordEditAccessInternals('pages', $subPage, false, null, true)) {
    @@ -7609,9 +7616,10 @@ public function doesPageHaveUnallowedTables($page_uid, int $doktype): array
          *
          * @param int $perms Permission restrictions to observe. An integer bitmask of Permission constants
          * @param array $page Full page record
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          * @internal Strictly internal. May change or vanish any time.
          */
    -    public function hasPagePermission(int $perms, array $page): bool
    +    public function hasPagePermission(int $perms, array $page, bool $useDeleteClause = true): bool
         {
             if (!$perms) {
                 throw new \RuntimeException('Invalid $perms bitset: "' . $perms . '"', 1270853920);
    @@ -7621,7 +7629,7 @@ public function hasPagePermission(int $perms, array $page): bool
             }
     
             $pagesSchema = $this->tcaSchemaFactory->get('pages');
    -        if (!$pagesSchema->hasCapability(TcaSchemaCapability::RestrictionWebMount) && !$this->BE_USER->isInWebMount($page)) {
    +        if (!$pagesSchema->hasCapability(TcaSchemaCapability::RestrictionWebMount) && !$this->BE_USER->isInWebMount($page, '', $useDeleteClause)) {
                 return false;
             }
             $beUserUid = $this->BE_USER->getUserId();
    
  • typo3/sysext/core/Tests/Functional/DataHandling/DataHandler/DeleteActionTest.php+161 0 added
    @@ -0,0 +1,161 @@
    +<?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\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +final class DeleteActionTest extends FunctionalTestCase
    +{
    +    private const LOG_TEMPLATE_TABLE = 'Cannot delete "%s:%d" without permission';
    +    private const LOG_TEMPLATE_WEBMOUNT = 'Attempt to delete page without permissions';
    +
    +    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);
    +        $this->subject->start([], []);
    +    }
    +
    +    #[Test]
    +    public function softDeletingPageInWebMountIsAllowed(): void
    +    {
    +        $this->subject->deleteAction('pages', 10);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertTrue($this->databaseRecordExists('pages', 10, true));
    +    }
    +
    +    #[Test]
    +    public function softDeletingNestedPageInWebMountIsDenied(): void
    +    {
    +        $this->subject->deleteAction('pages', 4);
    +        // @todo due to https://forge.typo3.org/issues/101635, the `runtime` cache cannot be a `NullCache`
    +        // (besides the fact, that `DataHandler` fails to clear `runtime` caches when generating the root-line)
    +        $this->get('cache.runtime')->flush();
    +        $this->subject->deleteAction('pages', 10);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_WEBMOUNT);
    +        self::assertTrue($this->databaseRecordExists('pages', 4, true));
    +        self::assertTrue($this->databaseRecordExists('pages', 10, true));
    +    }
    +
    +    #[Test]
    +    public function softDeletingPageWithoutTablePermissionIsDenied(): void
    +    {
    +        $this->backendUser->groupData['tables_modify'] = '';
    +
    +        $this->subject->deleteAction('pages', 10);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_TABLE, 'pages', 10);
    +        self::assertTrue($this->databaseRecordExists('pages', 10, false));
    +    }
    +
    +    #[Test]
    +    public function softDeletingPageNotInWebMountIsDenied(): void
    +    {
    +        $this->backendUser->setWebmounts([9]);
    +
    +        $this->subject->deleteAction('pages', 10);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_WEBMOUNT);
    +        self::assertTrue($this->databaseRecordExists('pages', 10, false));
    +    }
    +
    +    #[Test]
    +    public function softDeletingDanglingPageVersionIsDenied(): void
    +    {
    +        $this->subject->deleteAction('pages', 11);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertTrue($this->databaseRecordExists('pages', 11, false));
    +    }
    +
    +    #[Test]
    +    public function hardDeletingPageInWebMountIsAllowed(): void
    +    {
    +        // first soft-delete
    +        $this->subject->deleteAction('pages', 10);
    +        // second hard-delete
    +        $this->subject->deleteAction('pages', 10, false, true);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertFalse($this->databaseRecordExists('pages', 10, null));
    +    }
    +
    +    #[Test]
    +    public function hardDeletingNestedPageInWebMountIsDenied(): void
    +    {
    +        // first soft-delete the parent page
    +        $this->subject->deleteAction('pages', 4);
    +        // @todo due to https://forge.typo3.org/issues/101635, the `runtime` cache cannot be a `NullCache`
    +        // (besides the fact, that `DataHandler` fails to clear `runtime` caches when generating the root-line)
    +        $this->get('cache.runtime')->flush();
    +        // second hard-delete the nested page
    +        $this->subject->deleteAction('pages', 10, false, true);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertTrue($this->databaseRecordExists('pages', 4, true));
    +        self::assertFalse($this->databaseRecordExists('pages', 10, null));
    +    }
    +
    +    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);
    +    }
    +}
    
  • typo3/sysext/recycler/Classes/Controller/RecyclerAjaxController.php+21 5 modified
    @@ -106,13 +106,10 @@ public function dispatch(ServerRequestInterface $request): ResponseInterface
                     $model = GeneralUtility::makeInstance(DeletedRecords::class);
                     $totalDeleted = $model->getTotalCount($this->conf['startUid'], $this->conf['table'], $this->conf['depth'], $this->conf['filterTxt']);
     
    -                $allowDelete = $this->getBackendUser()->isAdmin()
    -                    ?: (bool)($this->getBackendUser()->getTSConfig()['mod.']['recycler.']['allowDelete'] ?? false);
    -
                     $view = $this->backendViewFactory->create($request);
                     $view->assign('showTableHeader', empty($this->conf['table']));
                     $view->assign('showTableName', $this->getBackendUser()->shallDisplayDebugInformation());
    -                $view->assign('allowDelete', $allowDelete);
    +                $view->assign('allowDelete', $this->isDeleteAllowed());
                     $view->assign('groupedRecords', $this->transform($deletedRowsArray));
                     $content = [
                         'rows' => $view->render('Ajax/RecordsTable'),
    @@ -137,6 +134,14 @@ public function dispatch(ServerRequestInterface $request): ResponseInterface
                     ];
                     break;
                 case 'deleteRecords':
    +                if (!$this->isDeleteAllowed()) {
    +                    $content = [
    +                        'success' => false,
    +                        'message' => LocalizationUtility::translate('flashmessage.delete.unauthorized', 'recycler'),
    +                    ];
    +                    break;
    +                }
    +
                     if (empty($this->conf['records']) || !is_array($this->conf['records'])) {
                         $content = [
                             'success' => false,
    @@ -150,14 +155,25 @@ public function dispatch(ServerRequestInterface $request): ResponseInterface
                     $affectedRecords = count($this->conf['records']);
                     $messageKey = 'flashmessage.delete.' . ($success ? 'success' : 'failure') . '.' . ($affectedRecords === 1 ? 'singular' : 'plural');
                     $content = [
    -                    'success' => true,
    +                    'success' => $success,
                         'message' => sprintf((string)LocalizationUtility::translate($messageKey, 'recycler'), $affectedRecords),
                     ];
                     break;
             }
             return new JsonResponse($content);
         }
     
    +    protected function isDeleteAllowed(): bool
    +    {
    +        $backendUser = $this->getBackendUser();
    +
    +        if ($backendUser->isAdmin()) {
    +            return true;
    +        }
    +
    +        return (bool)($backendUser->getTSConfig()['mod.']['recycler.']['allowDelete'] ?? false);
    +    }
    +
         /**
          * Transforms the rows for the deleted records by grouping them
          * by their corresponding table and processing the raw record data.
    
  • typo3/sysext/recycler/Classes/Domain/Model/DeletedRecords.php+1 1 modified
    @@ -318,7 +318,7 @@ public function deleteData(?array $recordsArray): bool
                 $tce->start([], []);
                 foreach ($recordsArray as $record) {
                     [$table, $uid] = explode(':', $record);
    -                $tce->deleteAction($table, (int)$uid, true, true);
    +                $tce->deleteAction($table, (int)$uid, false, true);
                 }
                 return true;
             }
    
  • typo3/sysext/recycler/Resources/Private/Language/locallang.xlf+3 0 modified
    @@ -75,6 +75,9 @@
     			<trans-unit id="modal.massundo.text">
     				<source>Do you really want to recover all selected records and optionally potential subpages?</source>
     			</trans-unit>
    +			<trans-unit id="flashmessage.delete.unauthorized">
    +				<source>Insufficient permissions to delete records.</source>
    +			</trans-unit>
     			<trans-unit id="flashmessage.delete.norecordsselected">
     				<source>No records set to delete.</source>
     			</trans-unit>
    
  • typo3/sysext/recycler/Tests/Functional/Controller/RecyclerAjaxControllerTest.php+215 0 added
    @@ -0,0 +1,215 @@
    +<?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\Recycler\Tests\Functional\Controller;
    +
    +use PHPUnit\Framework\Attributes\DataProvider;
    +use PHPUnit\Framework\Attributes\Test;
    +use TYPO3\CMS\Core\Http\JsonResponse;
    +use TYPO3\CMS\Core\Http\ServerRequest;
    +use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
    +use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
    +use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
    +use TYPO3\CMS\Recycler\Controller\RecyclerAjaxController;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +/**
    + * @phpstan-type DatabaseState array{regular: list<int>, softDeleted: list<int>}
    + */
    +final class RecyclerAjaxControllerTest extends FunctionalTestCase
    +{
    +    protected array $coreExtensionsToLoad = [
    +        'recycler',
    +    ];
    +
    +    private RecyclerAjaxController $subject;
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/Database/be_groups.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/Database/be_users.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/Database/pages.csv');
    +
    +        $this->subject = $this->get(RecyclerAjaxController::class);
    +    }
    +
    +    #[Test]
    +    public function dispatchWithDeleteRecordsActionDoesNothingIfUserLacksDeletionPermissions(): void
    +    {
    +        $this->setUpBackendUser(2);
    +
    +        $request = new ServerRequest('https://typo3-testing.local', 'POST');
    +        $request = $request->withParsedBody(['action' => 'deleteRecords']);
    +
    +        $expected = [
    +            'success' => false,
    +            'message' => LocalizationUtility::translate('flashmessage.delete.unauthorized', 'recycler'),
    +        ];
    +
    +        $actual = $this->subject->dispatch($request);
    +
    +        self::assertInstanceOf(JsonResponse::class, $actual);
    +        self::assertJsonStringEqualsJsonString(
    +            \json_encode($expected, JSON_THROW_ON_ERROR),
    +            (string)$actual->getBody(),
    +        );
    +    }
    +
    +    /**
    +     * @return \Generator<string, array{int}>
    +     */
    +    public static function dispatchWithDeleteRecordsActionDoesNothingIfNoRecordsAreProvidedInRequestDataProvider(): \Generator
    +    {
    +        yield 'admin' => [1];
    +        yield 'permitted editor with TSconfig' => [3];
    +    }
    +
    +    #[DataProvider('dispatchWithDeleteRecordsActionDoesNothingIfNoRecordsAreProvidedInRequestDataProvider')]
    +    #[Test]
    +    public function dispatchWithDeleteRecordsActionDoesNothingIfNoRecordsAreProvidedInRequest(int $backendUser): void
    +    {
    +        $this->setUpBackendUser($backendUser);
    +
    +        $request = new ServerRequest('https://typo3-testing.local', 'POST');
    +        $request = $request->withParsedBody(['action' => 'deleteRecords']);
    +
    +        $expected = [
    +            'success' => false,
    +            'message' => LocalizationUtility::translate('flashmessage.delete.norecordsselected', 'recycler'),
    +        ];
    +
    +        $actual = $this->subject->dispatch($request);
    +
    +        self::assertInstanceOf(JsonResponse::class, $actual);
    +        self::assertJsonStringEqualsJsonString(
    +            \json_encode($expected, JSON_THROW_ON_ERROR),
    +            (string)$actual->getBody(),
    +        );
    +    }
    +
    +    public static function dispatchWithDeleteRecordsActionDeletesGivenRecordsWherePermissionsAreGivenDataProvider(): \Generator
    +    {
    +        yield 'admin' => [
    +            'backendUser' => 1,
    +            'records' => ['pages:4', 'pages:6', 'pages:7'],
    +            // @todo response is misleading, actually 4, 5 (subpage of 4), 6, 6 were delete (= 4 records)
    +            'expectedResponse' => ['success' => true, 'message' => '3 records were deleted.'],
    +            'expectedDatabaseState' => [
    +                'pages' => ['regular' => [1, 2], 'softDeleted' => [3]],
    +            ],
    +        ];
    +
    +        yield 'editor with at least one record without permissions' => [
    +            'backendUser' => 3,
    +            'records' => [
    +                'pages:3',
    +                'pages:4',
    +                'pages:5', // subpage of 4 => already deleted when 4 is deleted
    +                'pages:6', // outside of configured mount points => no permission to delete
    +                'pages:7', // perms_everybody = 0 => no permission to delete
    +            ],
    +            'expectedResponse' => null,
    +            // @todo recycler does not return reliable counts
    +            // ['success' => false, 'message' => 'Could not delete 3 records.'],
    +            'expectedDatabaseState' => [
    +                'pages' => ['regular' => [1, 2], 'softDeleted' => [6, 7]],
    +            ],
    +        ];
    +
    +        yield 'editor with permissions on all records' => [
    +            'backendUser' => 3,
    +            'records' => ['pages:3', 'pages:4'],
    +            // @todo response is misleading, actually 3, 4, 5 (subpage of 4) were delete (= 3 records)
    +            'expectedResponse' => ['success' => true, 'message' => '2 records were deleted.'],
    +            'expectedDatabaseState' => [
    +                'pages' => ['regular' => [1, 2], 'softDeleted' => [6, 7]],
    +            ],
    +        ];
    +    }
    +
    +    /**
    +     * @param list<string> $records
    +     * @param array{success: bool, message: string} $expectedResponse
    +     * @param array<string, DatabaseState> $expectedDatabaseState
    +     */
    +    #[DataProvider('dispatchWithDeleteRecordsActionDeletesGivenRecordsWherePermissionsAreGivenDataProvider')]
    +    #[Test]
    +    public function dispatchWithDeleteRecordsActionDeletesGivenRecordsWherePermissionsAreGiven(
    +        int $backendUser,
    +        array $records,
    +        ?array $expectedResponse,
    +        array $expectedDatabaseState = [],
    +    ): void {
    +        $this->setUpBackendUser($backendUser);
    +
    +        $request = new ServerRequest('https://typo3-testing.local', 'POST');
    +        $request = $request->withParsedBody([
    +            'action' => 'deleteRecords',
    +            'records' => $records,
    +        ]);
    +
    +        $actual = $this->subject->dispatch($request);
    +
    +        self::assertInstanceOf(JsonResponse::class, $actual);
    +        if ($expectedResponse !== null) {
    +            self::assertJsonStringEqualsJsonString(
    +                \json_encode($expectedResponse, JSON_THROW_ON_ERROR),
    +                (string)$actual->getBody(),
    +            );
    +        }
    +        foreach ($expectedDatabaseState as $tableName => $expectedDatabaseStateForTable) {
    +            $actualDatabaseState = $this->fetchDatabaseState($tableName);
    +            self::assertSame($expectedDatabaseStateForTable, $actualDatabaseState);
    +        }
    +    }
    +
    +    /**
    +     * @return DatabaseState
    +     */
    +    private function fetchDatabaseState(string $tableName): array
    +    {
    +        $result = ['regular' => []];
    +
    +        $schema = $this->get(TcaSchemaFactory::class)->get($tableName);
    +        $softDeleteFieldName = $schema->hasCapability(TcaSchemaCapability::SoftDelete)
    +            ? $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName()
    +            : null;
    +
    +        $queryBuilder = $this->getConnectionPool()
    +            ->getQueryBuilderForTable($tableName);
    +        $queryBuilder->getRestrictions()->removeAll();
    +
    +        $selectFields = ['uid'];
    +        if ($softDeleteFieldName) {
    +            $selectFields[] = $softDeleteFieldName;
    +            $result['softDeleted'] = [];
    +        }
    +        $rows = $queryBuilder
    +            ->select(...$selectFields)
    +            ->from($tableName)
    +            ->fetchAllAssociative();
    +
    +        foreach ($rows as $row) {
    +            $id = $row['uid'];
    +            $key = empty($row[$softDeleteFieldName]) ? 'regular' : 'softDeleted';
    +            $result[$key][] = $id;
    +        }
    +        return $result;
    +    }
    +}
    
  • typo3/sysext/recycler/Tests/Functional/Fixtures/Database/be_users.csv+4 3 modified
    @@ -1,4 +1,5 @@
     "be_users"
    -,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","lastlogin","workspace_id","db_mountpoints","usergroup"
    -,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,1371033743,0,"",""
    -,2,0,1452944912,"editor","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1452944912,1,0,1452944915,0,"1","1"
    +,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","lastlogin","workspace_id","db_mountpoints","usergroup","TSconfig"
    +,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,1371033743,0,"","",
    +,2,0,1452944912,"editor","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1452944912,1,0,1452944915,0,"1","1",
    +,3,0,1751001736,"editor1","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1751001736,1,0,1452944915,0,"1","1","mod.recycler.allowDelete = 1"
    
a6604db66499

[SECURITY] Avoid record deletion without permissions in recycler module

https://github.com/TYPO3/typo3Elias HäußlerJan 13, 2026via ghsa
11 files changed · +478 54
  • typo3/sysext/backend/Classes/Utility/BackendUtility.php+11 8 modified
    @@ -345,23 +345,24 @@ public static function getRecordLocalization($table, $uid, $language, $andWhereC
          * @param bool $workspaceOL If TRUE, version overlay is applied. This must be requested specifically because it is
          *          usually only wanted when the rootline is used for visual output while for permission checking you want the raw thing!
          * @param string[] $additionalFields Additional Fields to select for rootline records
    +     * @param bool $useDeleteClause Use the deleteClause to check if a record is deleted (default TRUE)
          * @return array Root line array, all the way to the page tree root uid=0 (or as far as $clause allows!), including the page given as $uid
          */
    -    public static function BEgetRootLine($uid, $clause = '', $workspaceOL = false, array $additionalFields = [])
    +    public static function BEgetRootLine($uid, $clause = '', $workspaceOL = false, array $additionalFields = [], bool $useDeleteClause = true)
         {
             $runtimeCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime');
             $beGetRootLineCache = $runtimeCache->get('backendUtilityBeGetRootLine') ?: [];
             $output = [];
             $pid = $uid;
    -        $ident = $pid . '-' . $clause . '-' . $workspaceOL . ($additionalFields ? '-' . md5(implode(',', $additionalFields)) : '');
    +        $ident = $pid . '-' . $clause . '-' . $workspaceOL . ($additionalFields ? '-' . md5(implode(',', $additionalFields)) : '') . ($useDeleteClause ? '-delete' : '');
             if (is_array($beGetRootLineCache[$ident] ?? false)) {
                 $output = $beGetRootLineCache[$ident];
             } else {
                 $loopCheck = 100;
                 $theRowArray = [];
                 while ($uid != 0 && $loopCheck) {
                     $loopCheck--;
    -                $row = self::getPageForRootline($uid, $clause, $workspaceOL, $additionalFields);
    +                $row = self::getPageForRootline($uid, $clause, $workspaceOL, $additionalFields, $useDeleteClause);
                     if (is_array($row)) {
                         $uid = $row['pid'];
                         $theRowArray[] = $row;
    @@ -420,14 +421,15 @@ public static function BEgetRootLine($uid, $clause = '', $workspaceOL = false, a
          * @param string $clause Clause can be used to select other criteria. It would typically be where-clauses that stops the process if we meet a page, the user has no reading access to.
          * @param bool $workspaceOL If TRUE, version overlay is applied. This must be requested specifically because it is usually only wanted when the rootline is used for visual output while for permission checking you want the raw thing!
          * @param string[] $additionalFields AdditionalFields to fetch from the root line
    +     * @param bool $useDeleteClause Use the deleteClause to check if a record is deleted (default TRUE)
          * @return array Cached page record for the rootline
          * @see BEgetRootLine
          */
    -    protected static function getPageForRootline($uid, $clause, $workspaceOL, array $additionalFields = [])
    +    protected static function getPageForRootline($uid, $clause, $workspaceOL, array $additionalFields = [], bool $useDeleteClause = true)
         {
             $runtimeCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime');
             $pageForRootlineCache = $runtimeCache->get('backendUtilityPageForRootLine') ?: [];
    -        $statementCacheIdent = md5($clause . ($additionalFields ? '-' . implode(',', $additionalFields) : ''));
    +        $statementCacheIdent = md5($clause . ($additionalFields ? '-' . implode(',', $additionalFields) : '') . ($useDeleteClause ? '-delete' : ''));
             $ident = $uid . '-' . $workspaceOL . '-' . $statementCacheIdent;
             if (is_array($pageForRootlineCache[$ident] ?? false)) {
                 $row = $pageForRootlineCache[$ident];
    @@ -436,9 +438,10 @@ protected static function getPageForRootline($uid, $clause, $workspaceOL, array
                 $statement = $runtimeCache->get('getPageForRootlineStatement-' . $statementCacheIdent);
                 if (!$statement) {
                     $queryBuilder = static::getQueryBuilderForTable('pages');
    -                $queryBuilder->getRestrictions()
    -                             ->removeAll()
    -                             ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
    +                $queryBuilder->getRestrictions()->removeAll();
    +                if ($useDeleteClause) {
    +                    $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
    +                }
     
                     $queryBuilder
                         ->select(
    
  • typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php+13 8 modified
    @@ -301,11 +301,12 @@ public function isMemberOfGroup($groupId)
          *
          * @param array $row Is the pagerow for which the permissions is checked
          * @param int $perms Is the binary representation of the permission we are going to check. Every bit in this number represents a permission that must be set. See function explanation.
    +     * @param bool $useDeleteClause Use the delete clause to check if a record is deleted
          * @return bool
          */
    -    public function doesUserHaveAccess($row, $perms)
    +    public function doesUserHaveAccess($row, $perms, bool $useDeleteClause = true)
         {
    -        $userPerms = $this->calcPerms($row);
    +        $userPerms = $this->calcPerms($row, $useDeleteClause);
             return ($userPerms & $perms) == $perms;
         }
     
    @@ -320,10 +321,11 @@ public function doesUserHaveAccess($row, $perms)
          *
          * @param int|array $idOrRow Page ID or full page record to check
          * @param string $readPerms Content of "->getPagePermsClause(1)" (read-permissions). If not set, they will be internally calculated (but if you have the correct value right away you can save that database lookup!)
    -     * @throws \RuntimeException
    +     * @param bool $useDeleteClause Use the deleteClause to check if a record is deleted (default TRUE)
          * @return int|null The page UID of a page in the rootline that matched a mount point
    +     *@throws \RuntimeException
          */
    -    public function isInWebMount($idOrRow, $readPerms = '')
    +    public function isInWebMount($idOrRow, $readPerms = '', bool $useDeleteClause = true)
         {
             if ($this->isAdmin()) {
                 return 1;
    @@ -350,7 +352,9 @@ public function isInWebMount($idOrRow, $readPerms = '')
                     $id,
                     't3ver_oid,'
                     . $GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField'] . ','
    -                . $GLOBALS['TCA']['pages']['ctrl']['languageField']
    +                . $GLOBALS['TCA']['pages']['ctrl']['languageField'],
    +                '',
    +                $useDeleteClause,
                 );
             }
             if ((int)($checkRec['t3ver_oid'] ?? 0) > 0) {
    @@ -368,7 +372,7 @@ public function isInWebMount($idOrRow, $readPerms = '')
             }
             if ($id > 0) {
                 $wM = $this->getWebmounts();
    -            $rL = BackendUtility::BEgetRootLine($id, ' AND ' . $readPerms, true);
    +            $rL = BackendUtility::BEgetRootLine($id, ' AND ' . $readPerms, true, [], $useDeleteClause);
                 foreach ($rL as $v) {
                     if ($v['uid'] && in_array($v['uid'], $wM)) {
                         return $v['uid'];
    @@ -542,16 +546,17 @@ public function getPagePermsClause($perms)
          * If the user is admin, 31 is returned	(full permissions for all five flags)
          *
          * @param array $row Input page row with all perms_* fields available.
    +     * @param bool $useDeleteClause Use the deleteClause to check if a record is deleted (default TRUE)
          * @return int Bitwise representation of the users permissions in relation to input page row, $row
          */
    -    public function calcPerms($row)
    +    public function calcPerms($row, bool $useDeleteClause = true)
         {
             // Return 31 for admin users.
             if ($this->isAdmin()) {
                 return Permission::ALL;
             }
             // Return 0 if page is not within the allowed web mount
    -        if (!$this->isInWebMount($row)) {
    +        if (!$this->isInWebMount($row, '', $useDeleteClause)) {
                 return Permission::NOTHING;
             }
             $out = Permission::NOTHING;
    
  • typo3/sysext/core/Classes/DataHandling/DataHandler.php+47 26 modified
    @@ -5208,6 +5208,12 @@ public function deleteAction($table, $id)
          */
         public function deleteEl($table, $uid, $noRecordCheck = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
         {
    +        // Exit if the current user does not have permission to modify the table and $noRecordCheck is set to false
    +        if (!$noRecordCheck && !$this->checkModifyAccessList($table)) {
    +            $this->log($table, 0, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Cannot delete "{table}:{uid}" without permission', -1, ['table' => $table, 'uid' => $uid]);
    +            return;
    +        }
    +
             if ($table === 'pages') {
                 $this->deletePages($uid, $noRecordCheck, $forceHardDelete, $deleteRecordsOnPage);
             } else {
    @@ -5443,10 +5449,10 @@ public function deletePages($uid, $force = false, $forceHardDelete = false, bool
             // Getting list of pages to delete:
             if ($force) {
                 // Returns the branch WITHOUT permission checks (0 secures that), so it cannot return -1
    -            $pageIdsInBranch = $this->doesBranchExist('', $uid, 0, true);
    +            $pageIdsInBranch = $this->doesBranchExist('', $uid, 0, true, !$forceHardDelete);
                 $res = GeneralUtility::intExplode(',', $pageIdsInBranch . $uid, true);
             } else {
    -            $res = $this->canDeletePage($uid);
    +            $res = $this->canDeletePage($uid, !$forceHardDelete);
             }
             // Perform deletion if not error:
             if (is_array($res)) {
    @@ -5570,10 +5576,11 @@ public function deleteSpecificPage($uid, $forceHardDelete = false, bool $deleteR
          * Used to evaluate if a page can be deleted
          *
          * @param int $uid Page id
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          * @return int[]|string If array: List of page uids to traverse and delete (means OK), if string: error message.
          * @internal should only be used from within DataHandler
          */
    -    public function canDeletePage($uid)
    +    public function canDeletePage($uid, bool $useDeleteClause = true)
         {
             $uid = (int)$uid;
             $isTranslatedPage = null;
    @@ -5588,11 +5595,11 @@ public function canDeletePage($uid)
                 } else {
                     return 'Attempt to delete page without permissions';
                 }
    -        } elseif (!$this->doesRecordExist('pages', $uid, Permission::PAGE_DELETE)) {
    +        } elseif (!$this->doesRecordExist('pages', $uid, Permission::PAGE_DELETE, $useDeleteClause)) {
                 return 'Attempt to delete page without permissions';
             }
     
    -        $pageIdsInBranch = $this->doesBranchExist('', $uid, Permission::PAGE_DELETE, true);
    +        $pageIdsInBranch = $this->doesBranchExist('', $uid, Permission::PAGE_DELETE, true, $useDeleteClause);
     
             if ($pageIdsInBranch === -1) {
                 return 'Attempt to delete pages in branch without permissions';
    @@ -5605,7 +5612,7 @@ public function canDeletePage($uid)
             }
     
             foreach ($pagesInBranch as $pageInBranch) {
    -            if (!$this->BE_USER->recordEditAccessInternals('pages', $pageInBranch, false, false, $isTranslatedPage ? false : true)) {
    +            if (!$this->BE_USER->recordEditAccessInternals('pages', $pageInBranch, false, true, $isTranslatedPage ? false : true)) {
                     return 'Attempt to delete page which has prohibited localizations';
                 }
             }
    @@ -7112,31 +7119,35 @@ public function checkModifyAccessList($table)
          *
          * @param string $table Table name
          * @param int $id UID of record
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          * @return bool Returns TRUE if OK. Cached results.
          * @internal should only be used from within DataHandler
          */
    -    public function isRecordInWebMount($table, $id)
    +    public function isRecordInWebMount($table, $id, bool $useDeleteClause = true)
         {
    -        if (!isset($this->isRecordInWebMount_Cache[$table . ':' . $id])) {
    +        $cacheIdentifier = $table . ':' . $id . ($this->disableDeleteClause || !$useDeleteClause ? '' : '-delete');
    +        if (!isset($this->isRecordInWebMount_Cache[$cacheIdentifier])) {
                 $recP = $this->getRecordProperties($table, $id);
    -            $this->isRecordInWebMount_Cache[$table . ':' . $id] = $this->isInWebMount($recP['event_pid']);
    +            $this->isRecordInWebMount_Cache[$cacheIdentifier] = $this->isInWebMount($recP['event_pid'], $useDeleteClause);
             }
    -        return $this->isRecordInWebMount_Cache[$table . ':' . $id];
    +        return $this->isRecordInWebMount_Cache[$cacheIdentifier];
         }
     
         /**
          * Checks if the input page ID is in the BE_USER webmounts
          *
          * @param int $pid Page ID to check
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          * @return bool TRUE if OK. Cached results.
          * @internal should only be used from within DataHandler
          */
    -    public function isInWebMount($pid)
    +    public function isInWebMount($pid, bool $useDeleteClause = true)
         {
    -        if (!isset($this->isInWebMount_Cache[$pid])) {
    -            $this->isInWebMount_Cache[$pid] = $this->BE_USER->isInWebMount($pid);
    +        $cacheIdentifier = $pid . ($this->disableDeleteClause || !$useDeleteClause ? '' : '-delete');
    +        if (!isset($this->isInWebMount_Cache[$cacheIdentifier])) {
    +            $this->isInWebMount_Cache[$cacheIdentifier] = $this->BE_USER->isInWebMount($pid, '', !$this->disableDeleteClause && $useDeleteClause);
             }
    -        return $this->isInWebMount_Cache[$pid];
    +        return $this->isInWebMount_Cache[$cacheIdentifier];
         }
     
         /**
    @@ -7271,14 +7282,15 @@ public function isTableAllowedForThisPage($page_uid, $checkTable)
          * @param string $table Record table name
          * @param int $id Record UID
          * @param int $perms Permission restrictions to observe: integer that will be bitwise AND'ed.
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          * @return bool Returns TRUE if the record given by $table, $id and $perms can be selected
          *
          * @throws \RuntimeException
          * @internal should only be used from within DataHandler
          */
    -    public function doesRecordExist($table, $id, int $perms)
    +    public function doesRecordExist($table, $id, int $perms, bool $useDeleteClause = true)
         {
    -        return $this->recordInfoWithPermissionCheck($table, $id, $perms, 'uid, pid') !== false;
    +        return $this->recordInfoWithPermissionCheck($table, $id, $perms, 'uid, pid', $useDeleteClause) !== false;
         }
     
         /**
    @@ -7287,17 +7299,18 @@ public function doesRecordExist($table, $id, int $perms)
          * @param int $id Page id
          * @param int $perms Permission integer
          * @param array $columns Columns to select
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          * @return bool|array
          * @internal
          * @see doesRecordExist()
          */
    -    protected function doesRecordExist_pageLookUp($id, $perms, $columns = ['uid'])
    +    protected function doesRecordExist_pageLookUp($id, $perms, $columns = ['uid'], bool $useDeleteClause = true)
         {
             $permission = new Permission($perms);
             $cacheId = md5('doesRecordExist_pageLookUp_' . $id . '_' . $perms . '_' . implode(
                 '_',
                 $columns
    -        ) . '_' . (string)$this->admin);
    +        ) . '_' . (string)$this->admin) . ($useDeleteClause ? '_delete' : '');
     
             // If result is cached, return it
             $cachedResult = $this->runtimeCache->get($cacheId);
    @@ -7306,7 +7319,10 @@ protected function doesRecordExist_pageLookUp($id, $perms, $columns = ['uid'])
             }
     
             $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
    -        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
    +        $restrictions = $queryBuilder->getRestrictions()->removeAll();
    +        if ($useDeleteClause) {
    +            $this->addDeleteRestriction($restrictions);
    +        }
             $queryBuilder
                 ->select(...$columns)
                 ->from('pages')
    @@ -7342,15 +7358,19 @@ protected function doesRecordExist_pageLookUp($id, $perms, $columns = ['uid'])
          * @param int $pid Page ID to select subpages from.
          * @param int $perms Perms integer to check each page record for.
          * @param bool $recurse Recursion flag: If set, it will go out through the branch.
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          * @return string|int List of page IDs in branch, if there are subpages, empty string if there are none or -1 if no permission
          * @internal should only be used from within DataHandler
          */
    -    public function doesBranchExist($inList, $pid, $perms, $recurse)
    +    public function doesBranchExist($inList, $pid, $perms, $recurse, bool $useDeleteClause = true)
         {
             $pid = (int)$pid;
             $perms = (int)$perms;
             $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
    -        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
    +        $restrictions = $queryBuilder->getRestrictions()->removeAll();
    +        if ($useDeleteClause) {
    +            $this->addDeleteRestriction($restrictions);
    +        }
             $result = $queryBuilder
                 ->select('uid', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody')
                 ->from('pages')
    @@ -7359,11 +7379,11 @@ public function doesBranchExist($inList, $pid, $perms, $recurse)
                 ->executeQuery();
             while ($row = $result->fetchAssociative()) {
                 // IF admin, then it's OK
    -            if ($this->admin || $this->BE_USER->doesUserHaveAccess($row, $perms)) {
    +            if ($this->admin || $this->BE_USER->doesUserHaveAccess($row, $perms, !$this->disableDeleteClause && $useDeleteClause)) {
                     $inList .= $row['uid'] . ',';
                     if ($recurse) {
                         // Follow the subpages recursively...
    -                    $inList = $this->doesBranchExist($inList, $row['uid'], $perms, $recurse);
    +                    $inList = $this->doesBranchExist($inList, $row['uid'], $perms, $recurse, $useDeleteClause);
                         if ($inList === -1) {
                             return -1;
                         }
    @@ -7581,10 +7601,11 @@ public function recordInfo($table, $id)
          * @param int $id Record UID
          * @param int $perms Permission restrictions to observe: An integer that will be bitwise AND'ed.
          * @param string $fieldList - fields - default is '*'
    +     * @param bool $useDeleteClause Use the delete clause to check if the page is deleted
          * @throws \RuntimeException
          * @return array<string,mixed>|false Row if exists and accessible, false otherwise
          */
    -    protected function recordInfoWithPermissionCheck(string $table, int $id, int $perms, string $fieldList = '*')
    +    protected function recordInfoWithPermissionCheck(string $table, int $id, int $perms, string $fieldList = '*', bool $useDeleteClause = true)
         {
             if ($this->bypassAccessCheckForRecords) {
                 $columns = GeneralUtility::trimExplode(',', $fieldList, true);
    @@ -7605,7 +7626,7 @@ protected function recordInfoWithPermissionCheck(string $table, int $id, int $pe
             }
             // For all tables: Check if record exists:
             $isWebMountRestrictionIgnored = BackendUtility::isWebMountRestrictionIgnored($table);
    -        if (is_array($GLOBALS['TCA'][$table]) && $id > 0 && ($this->admin || $isWebMountRestrictionIgnored || $this->isRecordInWebMount($table, $id))) {
    +        if (is_array($GLOBALS['TCA'][$table]) && $id > 0 && ($this->admin || $isWebMountRestrictionIgnored || $this->isRecordInWebMount($table, $id, $useDeleteClause))) {
                 $columns = GeneralUtility::trimExplode(',', $fieldList, true);
                 if ($table !== 'pages') {
                     // Find record without checking page
    @@ -7630,7 +7651,7 @@ protected function recordInfoWithPermissionCheck(string $table, int $id, int $pe
                     }
                     return false;
                 }
    -            return $this->doesRecordExist_pageLookUp($id, $perms, $columns);
    +            return $this->doesRecordExist_pageLookUp($id, $perms, $columns, $useDeleteClause);
             }
             return false;
         }
    
  • typo3/sysext/core/Tests/Functional/DataHandling/DataHandler/DeleteActionTest.php+158 0 added
    @@ -0,0 +1,158 @@
    +<?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\Localization\LanguageServiceFactory;
    +use TYPO3\CMS\Core\Utility\GeneralUtility;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +final class DeleteActionTest extends FunctionalTestCase
    +{
    +    private const LOG_TEMPLATE_TABLE = 'Cannot delete "%s:%d" without permission';
    +    private const LOG_TEMPLATE_WEBMOUNT = 'Attempt to delete page without permissions';
    +
    +    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]);
    +        $GLOBALS['LANG'] = $this->get(LanguageServiceFactory::class)->createFromUserPreferences($this->backendUser);
    +
    +        $this->subject = GeneralUtility::makeInstance(DataHandler::class);
    +        $this->subject->start([], []);
    +    }
    +
    +    #[Test]
    +    public function softDeletingPageInWebMountIsAllowed(): void
    +    {
    +        $this->subject->deleteEl('pages', 10);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertTrue($this->databaseRecordExists('pages', 10, true));
    +    }
    +
    +    #[Test]
    +    public function softDeletingNestedPageInWebMountIsDenied(): void
    +    {
    +        $this->subject->deleteEl('pages', 4);
    +        // @todo due to https://forge.typo3.org/issues/101635, the `runtime` cache cannot be a `NullCache`
    +        // (besides the fact, that `DataHandler` fails to clear `runtime` caches when generating the root-line)
    +        $this->get('cache.runtime')->flush();
    +        $this->subject->deleteEl('pages', 10);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_WEBMOUNT);
    +        self::assertTrue($this->databaseRecordExists('pages', 4, true));
    +        self::assertTrue($this->databaseRecordExists('pages', 10, true));
    +    }
    +
    +    #[Test]
    +    public function softDeletingPageWithoutTablePermissionIsDenied(): void
    +    {
    +        $this->backendUser->groupData['tables_modify'] = '';
    +
    +        $this->subject->deleteEl('pages', 10);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_TABLE, 'pages', 10);
    +        self::assertTrue($this->databaseRecordExists('pages', 10, false));
    +    }
    +
    +    #[Test]
    +    public function softDeletingPageNotInWebMountIsDenied(): void
    +    {
    +        $this->backendUser->setWebmounts([9]);
    +
    +        $this->subject->deleteEl('pages', 10);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_WEBMOUNT);
    +        self::assertTrue($this->databaseRecordExists('pages', 10, false));
    +    }
    +
    +    #[Test]
    +    public function softDeletingDanglingPageVersionIsDenied(): void
    +    {
    +        $this->subject->deleteEl('pages', 11);
    +        $this->assertLogEntry(self::LOG_TEMPLATE_WEBMOUNT);
    +        self::assertTrue($this->databaseRecordExists('pages', 11, false));
    +    }
    +
    +    #[Test]
    +    public function hardDeletingPageInWebMountIsAllowed(): void
    +    {
    +        // first soft-delete
    +        $this->subject->deleteEl('pages', 10);
    +        // second hard-delete
    +        $this->subject->deleteEl('pages', 10, false, true);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertFalse($this->databaseRecordExists('pages', 10, null));
    +    }
    +
    +    #[Test]
    +    public function hardDeletingNestedPageInWebMountIsDenied(): void
    +    {
    +        // first soft-delete the parent page
    +        $this->subject->deleteEl('pages', 4);
    +        // @todo due to https://forge.typo3.org/issues/101635, the `runtime` cache cannot be a `NullCache`
    +        // (besides the fact, that `DataHandler` fails to clear `runtime` caches when generating the root-line)
    +        $this->get('cache.runtime')->flush();
    +        // second hard-delete the nested page
    +        $this->subject->deleteEl('pages', 10, false, true);
    +        self::assertSame([], $this->subject->errorLog);
    +        self::assertTrue($this->databaseRecordExists('pages', 4, true));
    +        self::assertFalse($this->databaseRecordExists('pages', 10, null));
    +    }
    +
    +    private function databaseRecordExists(string $tableName, int $id, ?bool $expectDeleted): bool
    +    {
    +        $identifiers = ['uid' => $id];
    +        $softDeleteFieldName = $GLOBALS['TCA'][$tableName]['ctrl']['delete'] ?? null;
    +        if ($expectDeleted !== null && !empty($softDeleteFieldName)) {
    +            $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);
    +    }
    +}
    
  • typo3/sysext/core/Tests/Functional/DataScenarios/Regular/Modify/DataSet/deleteThenHardDeletePageWithSubpages.csv+0 2 modified
    @@ -4,8 +4,6 @@
     ,50,0,512,0,0,0,0,0,0,"Second Root Page","/",,
     ,51,50,128,0,0,0,0,0,0,"DataHandlerTest in second tree","/data-handler",,
     ,52,51,128,0,0,0,0,0,0,"Relations in second tree","/data-handler/relations",,
    -,89,88,256,1,0,0,0,0,0,"Relations",,,
    -,90,88,512,1,0,0,0,0,0,"Target",,,
     "tt_content",,,,,,,,,,,,,
     ,"uid","pid","sorting","deleted","sys_language_uid","l18n_parent","l10n_source","t3_origuid","t3ver_wsid","t3ver_state","t3ver_stage","t3ver_oid","header"
     ,296,88,256,1,0,0,0,0,0,0,0,0,"Regular Element #0"
    
  • typo3/sysext/recycler/Classes/Controller/RecyclerAjaxController.php+21 5 modified
    @@ -98,13 +98,10 @@ public function dispatch(ServerRequestInterface $request): ResponseInterface
                     $model = GeneralUtility::makeInstance(DeletedRecords::class);
                     $totalDeleted = $model->getTotalCount($this->conf['startUid'], $this->conf['table'], $this->conf['depth'], $this->conf['filterTxt']);
     
    -                $allowDelete = $this->getBackendUser()->isAdmin()
    -                    ?: (bool)($this->getBackendUser()->getTSConfig()['mod.']['recycler.']['allowDelete'] ?? false);
    -
                     $view = $this->backendViewFactory->create($request);
                     $view->assign('showTableHeader', empty($this->conf['table']));
                     $view->assign('showTableName', $this->getBackendUser()->shallDisplayDebugInformation());
    -                $view->assign('allowDelete', $allowDelete);
    +                $view->assign('allowDelete', $this->isDeleteAllowed());
                     $view->assign('groupedRecords', $this->transform($deletedRowsArray));
                     $content = [
                         'rows' => $view->render('Ajax/RecordsTable'),
    @@ -129,6 +126,14 @@ public function dispatch(ServerRequestInterface $request): ResponseInterface
                     ];
                     break;
                 case 'deleteRecords':
    +                if (!$this->isDeleteAllowed()) {
    +                    $content = [
    +                        'success' => false,
    +                        'message' => LocalizationUtility::translate('flashmessage.delete.unauthorized', 'recycler'),
    +                    ];
    +                    break;
    +                }
    +
                     if (empty($this->conf['records']) || !is_array($this->conf['records'])) {
                         $content = [
                             'success' => false,
    @@ -142,14 +147,25 @@ public function dispatch(ServerRequestInterface $request): ResponseInterface
                     $affectedRecords = count($this->conf['records']);
                     $messageKey = 'flashmessage.delete.' . ($success ? 'success' : 'failure') . '.' . ($affectedRecords === 1 ? 'singular' : 'plural');
                     $content = [
    -                    'success' => true,
    +                    'success' => $success,
                         'message' => sprintf((string)LocalizationUtility::translate($messageKey, 'recycler'), $affectedRecords),
                     ];
                     break;
             }
             return new JsonResponse($content);
         }
     
    +    protected function isDeleteAllowed(): bool
    +    {
    +        $backendUser = $this->getBackendUser();
    +
    +        if ($backendUser->isAdmin()) {
    +            return true;
    +        }
    +
    +        return (bool)($backendUser->getTSConfig()['mod.']['recycler.']['allowDelete'] ?? false);
    +    }
    +
         /**
          * Transforms the rows for the deleted records by grouping them
          * by their corresponding table and processing the raw record data.
    
  • typo3/sysext/recycler/Classes/Domain/Model/DeletedRecords.php+1 1 modified
    @@ -347,7 +347,7 @@ public function deleteData($recordsArray)
                 $tce->disableDeleteClause();
                 foreach ($recordsArray as $record) {
                     [$table, $uid] = explode(':', $record);
    -                $tce->deleteEl($table, (int)$uid, true, true);
    +                $tce->deleteEl($table, (int)$uid, false, true);
                 }
                 return true;
             }
    
  • typo3/sysext/recycler/Resources/Private/Language/locallang.xlf+3 0 modified
    @@ -75,6 +75,9 @@
     			<trans-unit id="modal.massundo.text" resname="modal.massundo.text">
     				<source>Do you really want to recover all selected records and optionally potential subpages?</source>
     			</trans-unit>
    +			<trans-unit id="flashmessage.delete.unauthorized">
    +				<source>Insufficient permissions to delete records.</source>
    +			</trans-unit>
     			<trans-unit id="flashmessage.delete.norecordsselected" resname="flashmessage.delete.norecordsselected">
     				<source>No records set to delete.</source>
     			</trans-unit>
    
  • typo3/sysext/recycler/Tests/Functional/Controller/RecyclerAjaxControllerTest.php+219 0 added
    @@ -0,0 +1,219 @@
    +<?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\Recycler\Tests\Functional\Controller;
    +
    +use PHPUnit\Framework\Attributes\DataProvider;
    +use PHPUnit\Framework\Attributes\Test;
    +use TYPO3\CMS\Core\Http\JsonResponse;
    +use TYPO3\CMS\Core\Http\ServerRequest;
    +use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
    +use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
    +use TYPO3\CMS\Recycler\Controller\RecyclerAjaxController;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +/**
    + * @phpstan-type DatabaseState array{regular: list<int>, softDeleted: list<int>}
    + */
    +final class RecyclerAjaxControllerTest extends FunctionalTestCase
    +{
    +    protected array $coreExtensionsToLoad = [
    +        'recycler',
    +    ];
    +
    +    private RecyclerAjaxController $subject;
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/Database/be_groups.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/Database/be_users.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/Database/pages.csv');
    +
    +        $this->subject = $this->get(RecyclerAjaxController::class);
    +    }
    +
    +    #[Test]
    +    public function dispatchWithDeleteRecordsActionDoesNothingIfUserLacksDeletionPermissions(): void
    +    {
    +        $this->setUpBackendUserAndLanguageService(2);
    +
    +        $request = new ServerRequest('https://typo3-testing.local', 'POST');
    +        $request = $request->withParsedBody(['action' => 'deleteRecords']);
    +
    +        $expected = [
    +            'success' => false,
    +            'message' => LocalizationUtility::translate('flashmessage.delete.unauthorized', 'recycler'),
    +        ];
    +
    +        $actual = $this->subject->dispatch($request);
    +
    +        self::assertInstanceOf(JsonResponse::class, $actual);
    +        self::assertJsonStringEqualsJsonString(
    +            \json_encode($expected, JSON_THROW_ON_ERROR),
    +            (string)$actual->getBody(),
    +        );
    +    }
    +
    +    /**
    +     * @return \Generator<string, array{int}>
    +     */
    +    public static function dispatchWithDeleteRecordsActionDoesNothingIfNoRecordsAreProvidedInRequestDataProvider(): \Generator
    +    {
    +        yield 'admin' => [1];
    +        yield 'permitted editor with TSconfig' => [3];
    +    }
    +
    +    #[DataProvider('dispatchWithDeleteRecordsActionDoesNothingIfNoRecordsAreProvidedInRequestDataProvider')]
    +    #[Test]
    +    public function dispatchWithDeleteRecordsActionDoesNothingIfNoRecordsAreProvidedInRequest(int $backendUser): void
    +    {
    +        $this->setUpBackendUserAndLanguageService($backendUser);
    +
    +        $request = new ServerRequest('https://typo3-testing.local', 'POST');
    +        $request = $request->withParsedBody(['action' => 'deleteRecords']);
    +
    +        $expected = [
    +            'success' => false,
    +            'message' => LocalizationUtility::translate('flashmessage.delete.norecordsselected', 'recycler'),
    +        ];
    +
    +        $actual = $this->subject->dispatch($request);
    +
    +        self::assertInstanceOf(JsonResponse::class, $actual);
    +        self::assertJsonStringEqualsJsonString(
    +            \json_encode($expected, JSON_THROW_ON_ERROR),
    +            (string)$actual->getBody(),
    +        );
    +    }
    +
    +    public static function dispatchWithDeleteRecordsActionDeletesGivenRecordsWherePermissionsAreGivenDataProvider(): \Generator
    +    {
    +        yield 'admin' => [
    +            'backendUser' => 1,
    +            'records' => ['pages:4', 'pages:6', 'pages:7'],
    +            // @todo response is misleading, actually 4, 5 (subpage of 4), 6, 6 were delete (= 4 records)
    +            'expectedResponse' => ['success' => true, 'message' => '3 records were deleted.'],
    +            'expectedDatabaseState' => [
    +                'pages' => ['regular' => [1, 2], 'softDeleted' => [3]],
    +            ],
    +        ];
    +
    +        yield 'editor with at least one record without permissions' => [
    +            'backendUser' => 3,
    +            'records' => [
    +                'pages:3',
    +                'pages:4',
    +                'pages:5', // subpage of 4 => already deleted when 4 is deleted
    +                'pages:6', // outside of configured mount points => no permission to delete
    +                'pages:7', // perms_everybody = 0 => no permission to delete
    +            ],
    +            'expectedResponse' => null,
    +            // @todo recycler does not return reliable counts
    +            // ['success' => false, 'message' => 'Could not delete 3 records.'],
    +            'expectedDatabaseState' => [
    +                'pages' => ['regular' => [1, 2], 'softDeleted' => [6, 7]],
    +            ],
    +        ];
    +
    +        yield 'editor with permissions on all records' => [
    +            'backendUser' => 3,
    +            'records' => ['pages:3', 'pages:4'],
    +            // @todo response is misleading, actually 3, 4, 5 (subpage of 4) were delete (= 3 records)
    +            'expectedResponse' => ['success' => true, 'message' => '2 records were deleted.'],
    +            'expectedDatabaseState' => [
    +                'pages' => ['regular' => [1, 2], 'softDeleted' => [6, 7]],
    +            ],
    +        ];
    +    }
    +
    +    /**
    +     * @param list<string> $records
    +     * @param array{success: bool, message: string} $expectedResponse
    +     * @param array<string, DatabaseState> $expectedDatabaseState
    +     */
    +    #[DataProvider('dispatchWithDeleteRecordsActionDeletesGivenRecordsWherePermissionsAreGivenDataProvider')]
    +    #[Test]
    +    public function dispatchWithDeleteRecordsActionDeletesGivenRecordsWherePermissionsAreGiven(
    +        int $backendUser,
    +        array $records,
    +        ?array $expectedResponse,
    +        array $expectedDatabaseState = [],
    +    ): void {
    +        $this->setUpBackendUserAndLanguageService($backendUser);
    +
    +        $request = new ServerRequest('https://typo3-testing.local', 'POST');
    +        $request = $request->withParsedBody([
    +            'action' => 'deleteRecords',
    +            'records' => $records,
    +        ]);
    +
    +        $actual = $this->subject->dispatch($request);
    +
    +        self::assertInstanceOf(JsonResponse::class, $actual);
    +        if ($expectedResponse !== null) {
    +            self::assertJsonStringEqualsJsonString(
    +                \json_encode($expectedResponse, JSON_THROW_ON_ERROR),
    +                (string)$actual->getBody(),
    +            );
    +        }
    +        foreach ($expectedDatabaseState as $tableName => $expectedDatabaseStateForTable) {
    +            $actualDatabaseState = $this->fetchDatabaseState($tableName);
    +            self::assertSame($expectedDatabaseStateForTable, $actualDatabaseState);
    +        }
    +    }
    +
    +    /**
    +     * @return DatabaseState
    +     */
    +    private function fetchDatabaseState(string $tableName): array
    +    {
    +        $result = ['regular' => []];
    +
    +        $softDeleteFieldName = $GLOBALS['TCA'][$tableName]['ctrl']['delete'] ?? null;
    +
    +        $queryBuilder = $this->getConnectionPool()
    +            ->getQueryBuilderForTable($tableName);
    +        $queryBuilder->getRestrictions()->removeAll();
    +
    +        $selectFields = ['uid'];
    +        if ($softDeleteFieldName) {
    +            $selectFields[] = $softDeleteFieldName;
    +            $result['softDeleted'] = [];
    +        }
    +
    +        $rows = $queryBuilder
    +            ->select(...$selectFields)
    +            ->from($tableName)
    +            ->executeQuery()
    +            ->fetchAllAssociative();
    +
    +        foreach ($rows as $row) {
    +            $id = $row['uid'];
    +            $key = empty($row[$softDeleteFieldName]) ? 'regular' : 'softDeleted';
    +            $result[$key][] = $id;
    +        }
    +        return $result;
    +    }
    +
    +    private function setUpBackendUserAndLanguageService(int $userId): void
    +    {
    +        $backendUser = $this->setUpBackendUser($userId);
    +        $GLOBALS['LANG'] = $this->get(LanguageServiceFactory::class)->createFromUserPreferences($backendUser);
    +    }
    +}
    
  • typo3/sysext/recycler/Tests/Functional/Fixtures/Database/be_users.csv+4 3 modified
    @@ -1,4 +1,5 @@
     "be_users"
    -,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","lastlogin","workspace_id","db_mountpoints","usergroup"
    -,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,1371033743,0,"",""
    -,2,0,1452944912,"editor","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1452944912,1,0,1452944915,0,"1","1"
    +,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","lastlogin","workspace_id","db_mountpoints","usergroup","TSconfig"
    +,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,1371033743,0,"","",
    +,2,0,1452944912,"editor","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1452944912,1,0,1452944915,0,"1","1",
    +,3,0,1751001736,"editor1","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1751001736,1,0,1452944915,0,"1","1","mod.recycler.allowDelete = 1"
    
  • typo3/sysext/workspaces/Classes/Authentication/PreviewUserAuthentication.php+1 1 modified
    @@ -103,7 +103,7 @@ public function getPagePermsClause($perms)
          * @param array $row
          * @return int
          */
    -    public function calcPerms($row)
    +    public function calcPerms($row, bool $useDeleteClause = true)
         {
             return Permission::PAGE_SHOW;
         }
    

Vulnerability mechanics

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

References

7

News mentions

0

No linked articles in our index yet.