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

TYPO3 CMS Allows Broken Access Control in Redirects Module

CVE-2025-59021

Description

Backend users with access to the redirects module and write permission on the sys_redirect table were able to read, create, and modify any redirect record without restriction to the user’s own file-mounts or web-mounts. This allowed attackers to insert or alter redirects pointing to arbitrary URLs – facilitating phishing or other malicious redirect attacks. 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-redirectsPackagist
>= 14.0.0, < 14.0.214.0.2
typo3/cms-redirectsPackagist
>= 13.0.0, < 13.4.2313.4.23
typo3/cms-redirectsPackagist
>= 12.0.0, < 12.4.4112.4.41
typo3/cms-redirectsPackagist
>= 11.0.0, < 11.5.4911.5.49
typo3/cms-redirectsPackagist
>= 10.0.0, < 10.4.5510.4.55

Affected products

1

Patches

3
8a46abd8993e

[SECURITY] Prevent unauthorized access to resources in redirects module

https://github.com/TYPO3/typo3Elias HäußlerJan 13, 2026via ghsa
19 files changed · +1021 221
  • typo3/sysext/redirects/Classes/Controller/ManagementController.php+44 19 modified
    @@ -26,6 +26,7 @@
     use TYPO3\CMS\Backend\Template\Components\MultiRecordSelection\Action;
     use TYPO3\CMS\Backend\Template\ModuleTemplate;
     use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
     use TYPO3\CMS\Core\Configuration\Features;
     use TYPO3\CMS\Core\Imaging\IconFactory;
     use TYPO3\CMS\Core\Imaging\IconSize;
    @@ -70,6 +71,10 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
             $view->makeDocHeaderModuleMenu();
             $this->registerDocHeaderButtons($view);
     
    +        if (!$this->canListRedirects()) {
    +            return $view->renderResponse('Management/Overview');
    +        }
    +
             $event = $this->eventDispatcher->dispatch(
                 new ModifyRedirectManagementControllerViewDataEvent(
                     $demand,
    @@ -87,6 +92,7 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
             $pagination = $this->modulePaginationService->preparePagination($demand);
             $languageService = $this->getLanguageService();
             $view = $event->getView();
    +        $hasEditPermissions = $this->canEditRedirects();
             $view->assignMultiple([
                 'redirects' => $event->getRedirects(),
                 'hosts' => $event->getHosts(),
    @@ -97,13 +103,15 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
                 'demand' => $event->getDemand(),
                 'showHitCounter' => $event->getShowHitCounter(),
                 'pagination' => $pagination,
    +            'canEditRedirects' => $hasEditPermissions,
    +            'canListRedirects' => true,
                 'returnUrl' => $this->uriBuilder->buildUriFromRoute('redirects', [
                     'page' => $pagination['current'],
                     'demand' =>  $demand->getParameters(),
                     'orderField' => $demand->getOrderField(),
                     'orderDirection' => $demand->getOrderDirection(),
                 ]),
    -            'actions' => [
    +            'actions' => $hasEditPermissions ? [
                     new Action(
                         'edit',
                         [
    @@ -128,11 +136,21 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
                         'actions-edit-delete',
                         'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'
                     ),
    -            ],
    +            ] : [],
             ]);
             return $view->renderResponse('Management/Overview');
         }
     
    +    protected function canListRedirects(): bool
    +    {
    +        return $this->getBackendUser()->check('tables_select', 'sys_redirect');
    +    }
    +
    +    protected function canEditRedirects(): bool
    +    {
    +        return $this->getBackendUser()->check('tables_modify', 'sys_redirect');
    +    }
    +
         /**
          * Create document header buttons
          */
    @@ -141,24 +159,26 @@ protected function registerDocHeaderButtons(ModuleTemplate $view): void
             $languageService = $this->getLanguageService();
     
             // Create new
    -        $newRecordButton = $this->componentFactory->createLinkButton()
    -            ->setHref((string)$this->uriBuilder->buildUriFromRoute(
    -                'record_edit',
    -                [
    -                    'edit' => ['sys_redirect' => ['new']],
    -                    'module' => 'redirects',
    -                    'defVals' => [
    -                        'sys_redirect' => [
    -                            'redirect_type' => Demand::DEFAULT_REDIRECT_TYPE,
    +        if ($this->canEditRedirects()) {
    +            $newRecordButton = $this->componentFactory->createLinkButton()
    +                ->setHref((string)$this->uriBuilder->buildUriFromRoute(
    +                    'record_edit',
    +                    [
    +                        'edit' => ['sys_redirect' => ['new']],
    +                        'module' => 'redirects',
    +                        'defVals' => [
    +                            'sys_redirect' => [
    +                                'redirect_type' => Demand::DEFAULT_REDIRECT_TYPE,
    +                            ],
                             ],
    -                    ],
    -                    'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('redirects'),
    -                ]
    -            ))
    -            ->setTitle($languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_add_text'))
    -            ->setShowLabelText(true)
    -            ->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
    -        $view->getDocHeaderComponent()->getButtonBar()->addButton($newRecordButton);
    +                        'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('redirects'),
    +                    ]
    +                ))
    +                ->setTitle($languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_add_text'))
    +                ->setShowLabelText(true)
    +                ->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
    +            $view->getDocHeaderComponent()->getButtonBar()->addButton($newRecordButton);
    +        }
     
             // Shortcut
             $view->getDocHeaderComponent()->setShortcutContext(
    @@ -171,4 +191,9 @@ protected function getLanguageService(): LanguageService
         {
             return $GLOBALS['LANG'];
         }
    +
    +    protected function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
     }
    
  • typo3/sysext/redirects/Classes/Data/SourceHostProvider.php+105 0 added
    @@ -0,0 +1,105 @@
    +<?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\Redirects\Data;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autowire;
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
    +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
    +use TYPO3\CMS\Core\Exception\SiteNotFoundException;
    +use TYPO3\CMS\Core\Site\SiteFinder;
    +
    +/**
    + * Data provider for source hosts in sys_redirect records
    + *
    + * @internal
    + */
    +final readonly class SourceHostProvider
    +{
    +    public function __construct(
    +        private SiteFinder $siteFinder,
    +        #[Autowire(service: 'cache.runtime')]
    +        private FrontendInterface $cache,
    +    ) {}
    +
    +    /**
    +     * Get all available hosts for current backend user.
    +     *
    +     * @return list<non-empty-string>
    +     */
    +    public function getHosts(bool $includeWildcard = false): array
    +    {
    +        $cacheIdentifier = 'RedirectsSourceHostProvider' . ($includeWildcard ? '-wildcard' : '');
    +
    +        if (!$this->cache->has($cacheIdentifier)) {
    +            $this->cache->set($cacheIdentifier, $this->filterAllowedSourceHosts($includeWildcard));
    +        }
    +
    +        return $this->cache->get($cacheIdentifier);
    +    }
    +
    +    /**
    +     * @return list<non-empty-string>
    +     */
    +    private function filterAllowedSourceHosts(bool $includeWildcard): array
    +    {
    +        $backendUser = $this->getBackendUser();
    +
    +        if ($includeWildcard) {
    +            $hosts = ['*'];
    +        } else {
    +            $hosts = [];
    +        }
    +
    +        if ($backendUser->isAdmin()) {
    +            foreach ($this->siteFinder->getAllSites() as $site) {
    +                foreach ($site->getAllLanguages() as $language) {
    +                    $host = $language->getBase()->getHost();
    +
    +                    if ($host !== '' && !in_array($host, $hosts, true)) {
    +                        $hosts[] = $host;
    +                    }
    +                }
    +            }
    +        } else {
    +            foreach ($backendUser->getWebmounts() as $pageId) {
    +                try {
    +                    $site = $this->siteFinder->getSiteByPageId($pageId);
    +
    +                    foreach ($site->getAvailableLanguages($backendUser) as $language) {
    +                        $host = $language->getBase()->getHost();
    +
    +                        if ($host !== '' && !in_array($host, $hosts, true)) {
    +                            $hosts[] = $host;
    +                        }
    +                    }
    +                } catch (SiteNotFoundException) {
    +                    // Ignore unavailable sites
    +                }
    +            }
    +        }
    +
    +        sort($hosts, SORT_NATURAL);
    +
    +        return $hosts;
    +    }
    +
    +    private function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/EventListener/RedirectEditPermissionGuard.php+44 0 added
    @@ -0,0 +1,44 @@
    +<?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\Redirects\EventListener;
    +
    +use TYPO3\CMS\Backend\Form\Event\ModifyEditFormUserAccessEvent;
    +use TYPO3\CMS\Core\Attribute\AsEventListener;
    +use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
    +
    +/**
    + * @internal
    + */
    +final readonly class RedirectEditPermissionGuard
    +{
    +    public function __construct(
    +        private RedirectPermissionGuard $redirectPermissionGuard,
    +    ) {}
    +
    +    #[AsEventListener('redirect-edit-permission-guard')]
    +    public function __invoke(ModifyEditFormUserAccessEvent $event): void
    +    {
    +        if ($event->getTableName() !== 'sys_redirect' || $event->getCommand() === 'new') {
    +            return;
    +        }
    +
    +        if (!$this->redirectPermissionGuard->isAllowedRedirect($event->getDatabaseRow())) {
    +            $event->denyUserAccess();
    +        }
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/FormDataProvider/ValuePickerItemDataProvider.php+8 31 modified
    @@ -17,26 +17,21 @@
     
     namespace TYPO3\CMS\Redirects\FormDataProvider;
     
    +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
     use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
    -use TYPO3\CMS\Core\Site\SiteFinder;
    -use TYPO3\CMS\Core\Utility\GeneralUtility;
    +use TYPO3\CMS\Redirects\Data\SourceHostProvider;
     use TYPO3\CMS\Redirects\Repository\Demand;
     
     /**
      * Inject available domain hosts into a valuepicker form
      * @internal
      */
    -class ValuePickerItemDataProvider implements FormDataProviderInterface
    +#[Autoconfigure(public: true)]
    +final readonly class ValuePickerItemDataProvider implements FormDataProviderInterface
     {
    -    /**
    -     * @var SiteFinder
    -     */
    -    protected $siteFinder;
    -
    -    public function __construct(?SiteFinder $siteFinder = null)
    -    {
    -        $this->siteFinder = $siteFinder ?? GeneralUtility::makeInstance(SiteFinder::class);
    -    }
    +    public function __construct(
    +        private SourceHostProvider $sourceHostProvider,
    +    ) {}
     
         /**
          * Add sys_domains into $result data array
    @@ -56,7 +51,7 @@ public function addData(array $result): array
                     ];
                 }
     
    -            $domains = $this->getHosts();
    +            $domains = $this->sourceHostProvider->getHosts();
                 foreach ($domains as $domain) {
                     $result['processedTca']['columns']['source_host']['config']['valuePicker']['items'][] =
                         [
    @@ -67,22 +62,4 @@ public function addData(array $result): array
             }
             return $result;
         }
    -
    -    /**
    -     * Get all hosts from sites
    -     *
    -     * @return string[] domain records
    -     */
    -    protected function getHosts(): array
    -    {
    -        $domains = [];
    -        foreach ($this->siteFinder->getAllSites() as $site) {
    -            foreach ($site->getAllLanguages() as $language) {
    -                $domains[] = $language->getBase()->getHost();
    -            }
    -        }
    -        $domains = array_unique($domains);
    -        sort($domains, SORT_NATURAL);
    -        return $domains;
    -    }
     }
    
  • typo3/sysext/redirects/Classes/Hooks/DataHandlerPermissionGuardHook.php+77 0 added
    @@ -0,0 +1,77 @@
    +<?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\Redirects\Hooks;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
    +use TYPO3\CMS\Core\DataHandling\DataHandler;
    +use TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
    +use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
    +use TYPO3\CMS\Core\Utility\MathUtility;
    +use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
    +
    +/**
    + * @internal This class is a specific TYPO3 hook implementation and is not part of the Public TYPO3 API.
    + */
    +#[Autoconfigure(public: true)]
    +final readonly class DataHandlerPermissionGuardHook
    +{
    +    public function __construct(
    +        private RedirectPermissionGuard $redirectPermissionGuard,
    +    ) {}
    +
    +    /**
    +     * @param array<string, mixed> $incomingFieldArray
    +     * @param-out array<string, mixed>|null $incomingFieldArray
    +     */
    +    public function processDatamap_preProcessFieldArray(
    +        array &$incomingFieldArray,
    +        string $table,
    +        string|int $id,
    +        DataHandler $dataHandler,
    +    ): void {
    +        if ($table === 'sys_redirect' && !$this->redirectPermissionGuard->isAllowedRedirect($incomingFieldArray)) {
    +            // Reset incoming field array to avoid further processing in DataHandler
    +            // in case the given source host is not allowed for the current user
    +            $incomingFieldArray = null;
    +
    +            if (MathUtility::canBeInterpretedAsInteger($id)) {
    +                // Record update
    +                $dataHandler->log(
    +                    'sys_redirect',
    +                    (int)$id,
    +                    SystemLogDatabaseAction::UPDATE,
    +                    null,
    +                    SystemLogErrorClassification::USER_ERROR,
    +                    'Attempt to modify sys_redirect record "%d" is disallowed',
    +                    null,
    +                    [$id],
    +                );
    +            } else {
    +                // New record
    +                $dataHandler->log(
    +                    'sys_redirect',
    +                    0,
    +                    SystemLogDatabaseAction::INSERT,
    +                    null,
    +                    SystemLogErrorClassification::USER_ERROR,
    +                    'Attempt to create a new sys_redirect record is disallowed',
    +                );
    +            }
    +        }
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/Repository/RedirectRepository.php+201 21 modified
    @@ -17,13 +17,15 @@
     
     namespace TYPO3\CMS\Redirects\Repository;
     
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
     use TYPO3\CMS\Core\Database\Connection;
     use TYPO3\CMS\Core\Database\ConnectionPool;
     use TYPO3\CMS\Core\Database\Query\QueryBuilder;
     use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
     use TYPO3\CMS\Core\Schema\TcaSchema;
     use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
     use TYPO3\CMS\Core\Utility\GeneralUtility;
    +use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
     
     /**
      * Class for accessing redirect records from the database
    @@ -33,8 +35,10 @@ class RedirectRepository
     {
         private TcaSchema $schema;
     
    -    public function __construct(TcaSchemaFactory $schemaFactory)
    -    {
    +    public function __construct(
    +        TcaSchemaFactory $schemaFactory,
    +        private readonly RedirectPermissionGuard $redirectPermissionGuard,
    +    ) {
             $this->schema = $schemaFactory->get('sys_redirect');
         }
     
    @@ -43,18 +47,85 @@ public function __construct(TcaSchemaFactory $schemaFactory)
          */
         public function findRedirectsByDemand(Demand $demand): array
         {
    -        return $this->getQueryBuilderForDemand($demand)
    -            ->setMaxResults($demand->getLimit())
    -            ->setFirstResult($demand->getOffset())
    +        // Fast path for admin users - use SQL pagination directly
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return $this->getQueryBuilderForDemand($demand)
    +                ->select('*')
    +                ->setMaxResults($demand->getLimit())
    +                ->setFirstResult($demand->getOffset())
    +                ->executeQuery()
    +                ->fetchAllAssociative();
    +        }
    +
    +        // Non-admin: Two-phase fetch without caching
    +        // Phase 1: Fetch minimal fields for ALL matching records with SQL source host filtering
    +        $queryBuilder = $this->getQueryBuilderForDemand($demand);
    +
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return [];
    +        }
    +
    +        $redirects = $queryBuilder
    +            ->executeQuery()
    +            ->fetchAllAssociative();
    +
    +        // Phase 2: Apply PHP target permission filtering
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
    +
    +        // Phase 3: Get UIDs for current page (applying pagination in PHP)
    +        $filteredUids = array_column($filteredRedirects, 'uid');
    +        $currentPageUids = array_slice($filteredUids, $demand->getOffset(), $demand->getLimit());
    +
    +        if ($currentPageUids === []) {
    +            return [];
    +        }
    +
    +        // Phase 4: Fetch full records only for the current page
    +        $queryBuilder = $this->getQueryBuilder();
    +        return $queryBuilder
    +            ->select('*')
    +            ->from('sys_redirect')
    +            ->where(
    +                $queryBuilder->expr()->in(
    +                    'uid',
    +                    $queryBuilder->createNamedParameter($currentPageUids, Connection::PARAM_INT_ARRAY)
    +                )
    +            )
    +            ->orderBy($demand->getOrderField(), $demand->getOrderDirection())
                 ->executeQuery()
                 ->fetchAllAssociative();
         }
     
    -    public function countRedirectsByByDemand(Demand $demand): int
    +    public function countRedirectsByDemand(Demand $demand): int
         {
    -        return (int)$this->getQueryBuilderForDemand($demand, true)
    +        // Fast path for admin users - use SQL COUNT
    +        if ($this->getBackendUser()->isAdmin()) {
    +            $queryBuilder = $this->getQueryBuilderForDemand($demand, true);
    +            return (int)$queryBuilder
    +                ->count('uid')
    +                ->executeQuery()
    +                ->fetchOne();
    +        }
    +
    +        // Non-admin: Fetch minimal fields with SQL source host filtering
    +        $queryBuilder = $this->getQueryBuilderForDemand($demand);
    +
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return 0;
    +        }
    +
    +        $redirects = $queryBuilder
                 ->executeQuery()
    -            ->fetchOne();
    +            ->fetchAllAssociative();
    +
    +        // Apply PHP target permission filtering and count
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
    +
    +        return count($filteredRedirects);
         }
     
         public function countActiveRedirects(): int
    @@ -67,6 +138,37 @@ public function countActiveRedirects(): int
                 ->fetchOne();
         }
     
    +    /**
    +     * Adds source host constraint to query for non-admin users
    +     * This significantly reduces the dataset before PHP filtering
    +     * @throws StopQueryException
    +     */
    +    protected function addSourceHostConstraint(QueryBuilder $queryBuilder): void
    +    {
    +        // Admin users see all hosts
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return;
    +        }
    +
    +        // Get allowed hosts for the current user
    +        $allowedHosts = $this->redirectPermissionGuard->getAllowedHosts();
    +        if (empty($allowedHosts)) {
    +            throw new StopQueryException('No allowed hosts found for current user', 1764702053);
    +        }
    +
    +        $queryBuilder->andWhere(
    +            $queryBuilder->expr()->in(
    +                'source_host',
    +                $queryBuilder->createNamedParameter($allowedHosts, Connection::PARAM_STR_ARRAY)
    +            )
    +        );
    +    }
    +
    +    protected function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
    +
         /**
          * Prepares the QueryBuilder with Constraints from the Demand
          */
    @@ -75,9 +177,9 @@ protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQue
             $queryBuilder = $this->getQueryBuilder();
     
             if ($createCountQuery) {
    -            $queryBuilder->count('*');
    +            $queryBuilder->count('uid');
             } else {
    -            $queryBuilder->select('*');
    +            $queryBuilder->select('uid', 'source_host', 'target');
             }
     
             $queryBuilder->from('sys_redirect');
    @@ -87,12 +189,14 @@ protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQue
                     $demand->getOrderField(),
                     $demand->getOrderDirection()
                 );
    +
                 if ($demand->hasSecondaryOrdering()) {
                     $queryBuilder->addOrderBy($demand->getSecondaryOrderField());
                 }
             }
     
             $constraints = [];
    +
             if ($demand->hasRedirectType()) {
                 $constraints[] = $queryBuilder->expr()->eq(
                     'redirect_type',
    @@ -166,6 +270,7 @@ protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQue
             if (!empty($constraints)) {
                 $queryBuilder->where(...$constraints);
             }
    +
             return $queryBuilder;
         }
     
    @@ -226,30 +331,96 @@ public function findIntegrityStatusCodes(?string $type = null): array
          */
         public function findRedirectTypes(): array
         {
    -        $result = $this->getQueryBuilder()
    -            ->select('redirect_type')
    +        // Admin: Direct SQL query with GROUP BY
    +        if ($this->getBackendUser()->isAdmin()) {
    +            $result = $this->getQueryBuilder()
    +                ->select('redirect_type')
    +                ->from('sys_redirect')
    +                ->groupBy('redirect_type')
    +                ->executeQuery()
    +                ->fetchAllAssociative();
    +
    +            return array_column($result, 'redirect_type');
    +        }
    +
    +        // Non-admin: GROUP BY + SQL source host filter + minimal PHP target filtering
    +        $queryBuilder = $this->getQueryBuilder()
    +            ->select('redirect_type', 'source_host', 'target')
                 ->from('sys_redirect')
    -            ->groupBy('redirect_type')
    -            ->executeQuery();
    +            ->groupBy('redirect_type', 'source_host', 'target');
    +
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return [];
    +        }
    +
    +        $redirects = $queryBuilder
    +            ->executeQuery()
    +            ->fetchAllAssociative();
    +
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
     
    -        return array_column($result->fetchAllAssociative(), 'redirect_type');
    +        return array_values(array_unique(array_column($filteredRedirects, 'redirect_type')));
         }
     
    +    /**
    +     * @return list<array<string, scalar|null>>
    +     */
         protected function getGroupedRows(string $field, string $as, ?string $type = 'default'): array
         {
    -        $queryBuilder = $this->getQueryBuilder();
    -        $queryBuilder
    -            ->select(sprintf('%s as %s', $field, $as))
    -            ->from('sys_redirect');
    +        // Admin: Direct SQL query
    +        if ($this->getBackendUser()->isAdmin()) {
    +            $queryBuilder = $this->getQueryBuilder()
    +                ->select(sprintf('%s as %s', $field, $as))
    +                ->from('sys_redirect')
    +                ->orderBy($field)
    +                ->groupBy($field);
    +
    +            if ($type !== null) {
    +                $queryBuilder->where($queryBuilder->expr()->eq('redirect_type', $queryBuilder->createNamedParameter($type)));
    +            }
    +
    +            return $queryBuilder
    +                ->executeQuery()
    +                ->fetchAllAssociative();
    +        }
    +
    +        // Non-admin: Need to include source_host and target for filtering
    +        $fields = [$field];
    +        if ($field !== 'source_host') {
    +            $fields[] = 'source_host';
    +        }
    +        if ($field !== 'target') {
    +            $fields[] = 'target';
    +        }
    +
    +        $queryBuilder = $this->getQueryBuilder()
    +            ->select(...$fields)
    +            ->from('sys_redirect')
    +            ->orderBy($field)
    +            ->groupBy(...$fields);
     
             if ($type !== null) {
                 $queryBuilder->where($queryBuilder->expr()->eq('redirect_type', $queryBuilder->createNamedParameter($type)));
             }
     
    -        return $queryBuilder->orderBy($field)
    -            ->groupBy($field)
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return [];
    +        }
    +
    +        $redirects = $queryBuilder
                 ->executeQuery()
                 ->fetchAllAssociative();
    +
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
    +
    +        return array_map(
    +            static fn(mixed $value) => [$as => $value],
    +            array_values(array_unique(array_column($filteredRedirects, $field))),
    +        );
         }
     
         protected function getQueryBuilder(): QueryBuilder
    @@ -306,4 +477,13 @@ public function removeByDemand(Demand $demand): void
     
             $queryBuilder->executeStatement();
         }
    +
    +    /**
    +     * @param list<non-empty-array> $redirects
    +     * @return list<non-empty-array>
    +     */
    +    protected function sortOutInaccessibleRedirects(array $redirects): array
    +    {
    +        return array_filter($redirects, $this->redirectPermissionGuard->isAllowedRedirect(...));
    +    }
     }
    
  • typo3/sysext/redirects/Classes/Repository/StopQueryException.php+24 0 added
    @@ -0,0 +1,24 @@
    +<?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\Redirects\Repository;
    +
    +/**
    + * Class signaling that the query should be stopped.
    + * @internal
    + */
    +class StopQueryException extends \RuntimeException {}
    
  • typo3/sysext/redirects/Classes/Security/RedirectPermissionGuard.php+121 0 added
    @@ -0,0 +1,121 @@
    +<?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\Redirects\Security;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autowire;
    +use TYPO3\CMS\Backend\Utility\BackendUtility;
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
    +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
    +use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
    +use TYPO3\CMS\Core\LinkHandling\Exception\UnknownUrnException;
    +use TYPO3\CMS\Core\LinkHandling\LinkService;
    +use TYPO3\CMS\Core\Resource\FileInterface;
    +use TYPO3\CMS\Core\Type\Bitmask\Permission;
    +use TYPO3\CMS\Redirects\Data\SourceHostProvider;
    +
    +/**
    + * Security guard to validate access to sys_redirect records for the current backend user.
    + *
    + * @internal
    + */
    +final class RedirectPermissionGuard
    +{
    +    /**
    +     * @var list<non-empty-string>|null
    +     */
    +    private ?array $allowedHosts = null;
    +
    +    public function __construct(
    +        private readonly LinkService $linkService,
    +        private readonly SourceHostProvider $sourceHostProvider,
    +        #[Autowire(service: 'cache.runtime')]
    +        private readonly FrontendInterface $cache,
    +    ) {}
    +
    +    public function isAllowedRedirect(array $redirect): bool
    +    {
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return true;
    +        }
    +
    +        return $this->isAllowedSourceHost($redirect['source_host'] ?? '')
    +            && $this->isAllowedTarget($redirect['target'] ?? '');
    +    }
    +
    +    public function getAllowedHosts(): array
    +    {
    +        $this->allowedHosts ??= $this->sourceHostProvider->getHosts(true);
    +
    +        return $this->allowedHosts;
    +    }
    +
    +    private function isAllowedSourceHost(string $host): bool
    +    {
    +        return in_array($host, $this->getAllowedHosts(), true);
    +    }
    +
    +    private function isAllowedTarget(string $target): bool
    +    {
    +        $cacheIdentifier = 'RedirectPermissionGuard-isAllowedTarget-' . md5($target);
    +
    +        if ($this->cache->has($cacheIdentifier)) {
    +            return $this->cache->get($cacheIdentifier);
    +        }
    +
    +        $result = true;
    +
    +        if (str_starts_with($target, 't3://')) {
    +            try {
    +                $resolvedLink = $this->linkService->resolveByStringRepresentation($target);
    +
    +                if ((int)($resolvedLink['pageuid'] ?? 0) > 0) {
    +                    $result = $this->canAccessPage((int)$resolvedLink['pageuid']);
    +                } elseif (($resolvedLink['file'] ?? null) instanceof FileInterface) {
    +                    $result = $this->canAccessFile($resolvedLink['file']);
    +                }
    +            } catch (UnknownUrnException|UnknownLinkHandlerException) {
    +            }
    +        }
    +
    +        $this->cache->set($cacheIdentifier, $result);
    +
    +        return $result;
    +    }
    +
    +    private function canAccessPage(int $pageUid): bool
    +    {
    +        $page = BackendUtility::getRecord('pages', $pageUid, '*', '', false);
    +
    +        // If the page does no longer exist, we allow access to the redirect
    +        if ($page === null) {
    +            return true;
    +        }
    +
    +        return $this->getBackendUser()->doesUserHaveAccess($page, Permission::PAGE_SHOW);
    +    }
    +
    +    private function canAccessFile(FileInterface $file): bool
    +    {
    +        return $file->getStorage()->checkFileActionPermission('read', $file);
    +    }
    +
    +    private function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/Service/ModulePaginationService.php+1 1 modified
    @@ -31,7 +31,7 @@ public function __construct(
     
         public function preparePagination(Demand $demand): array
         {
    -        $count = $this->redirectRepository->countRedirectsByByDemand($demand);
    +        $count = $this->redirectRepository->countRedirectsByDemand($demand);
             $numberOfPages = ceil($count / $demand->getLimit());
             $endRecord = $demand->getOffset() + $demand->getLimit();
             if ($endRecord > $count) {
    
  • typo3/sysext/redirects/ext_localconf.php+2 0 modified
    @@ -7,6 +7,7 @@
     use TYPO3\CMS\Redirects\FormDataProvider\QrCodeSourceHostDataProvider;
     use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
     use TYPO3\CMS\Redirects\Hooks\DataHandlerCacheFlushingHook;
    +use TYPO3\CMS\Redirects\Hooks\DataHandlerPermissionGuardHook;
     use TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook;
     use TYPO3\CMS\Redirects\Hooks\DispatchNotificationHook;
     use TYPO3\CMS\Redirects\Hooks\HandleNewQrCodeRecord;
    @@ -17,6 +18,7 @@
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc']['redirects'] = DataHandlerCacheFlushingHook::class . '->rebuildRedirectCacheIfNecessary';
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects'] = DataHandlerSlugUpdateHook::class;
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects-qrcode'] = HandleNewQrCodeRecord::class;
    +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirectsAccessGuard'] = DataHandlerPermissionGuardHook::class;
     
     // Inject sys_domains into valuepicker form
     $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRecord']
    
  • typo3/sysext/redirects/Resources/Private/Language/locallang_module_redirect.xlf+6 0 modified
    @@ -30,6 +30,12 @@
           <trans-unit id="redirect_create">
             <source>Create new redirect</source>
           </trans-unit>
    +      <trans-unit id="noAccessPermissions.title">
    +        <source>Access denied</source>
    +      </trans-unit>
    +      <trans-unit id="noAccessPermissions.message">
    +        <source>You are not allowed to list or modify redirects due to insufficient permissions.</source>
    +      </trans-unit>
           <trans-unit id="redirect_not_found_with_filter.title">
             <source>No redirects found</source>
           </trans-unit>
    
  • typo3/sysext/redirects/Resources/Private/Templates/Management/Overview.fluid.html+71 42 modified
    @@ -8,7 +8,20 @@
     <f:layout name="Module" />
     
     <f:section name="Content">
    +    <f:if condition="{canListRedirects}">
    +        <f:then>
    +            <f:render section="overview" arguments="{_all}" />
    +        </f:then>
    +        <f:else>
    +            <f:be.infobox state="{f:constant(name: 'TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::ERROR')}"
    +                message="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:noAccessPermissions.message')}"
    +                title="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:noAccessPermissions.title')}"
    +            />
    +        </f:else>
    +    </f:if>
    +</f:section>
     
    +<f:section name="overview">
         <f:asset.module identifier="@typo3/backend/modal.js"/>
         <f:asset.module identifier="@typo3/backend/multi-record-selection.js"/>
         <f:asset.module identifier="@typo3/backend/multi-record-selection-edit-action.js"/>
    @@ -43,9 +56,11 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                     <f:else>
                         <f:be.infobox state="{f:constant(name: 'TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::INFO')}" title="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_not_found.title')}">
                             <p><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_not_found.message"/></p>
    -                        <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_redirect">
    -                            <f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_create"/>
    -                        </be:link.newRecord>
    +                        <f:if condition="{canEditRedirects}">
    +                            <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_redirect">
    +                                <f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_create"/>
    +                            </be:link.newRecord>
    +                        </f:if>
                         </f:be.infobox>
                     </f:else>
                 </f:if>
    @@ -154,14 +169,21 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                             </td>
                             <td class="col-path">{redirect.source_host}</td>
                             <td class="col-path">
    -                            <be:link.editRecord
    -                                returnUrl="{returnUrl}"
    -                                table="sys_redirect"
    -                                uid="{redirect.uid}"
    -                                title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}: {redirect.source_path}"
    -                            >
    -                                {redirect.source_path}
    -                            </be:link.editRecord>
    +                            <f:if condition="{canEditRedirects}">
    +                                <f:then>
    +                                    <be:link.editRecord
    +                                        returnUrl="{returnUrl}"
    +                                        table="sys_redirect"
    +                                        uid="{redirect.uid}"
    +                                        title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}: {redirect.source_path}"
    +                                    >
    +                                        {redirect.source_path}
    +                                    </be:link.editRecord>
    +                                </f:then>
    +                                <f:else>
    +                                    {redirect.source_path}
    +                                </f:else>
    +                            </f:if>
                             </td>
                             <td class="col-path">
                                 <f:variable name="targetUri" value="{f:uri.typolink(parameter:redirect.target)}" />
    @@ -206,44 +228,51 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                             </f:if>
                             <td class="col-control">
                                 <div class="btn-group" role="group">
    -                                <be:link.editRecord
    -                                    returnUrl="{returnUrl}"
    -                                    class="btn btn-default"
    -                                    table="sys_redirect"
    -                                    uid="{redirect.uid}"
    -                                    title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}"
    -                                >
    -                                    <core:icon identifier="actions-open" />
    -                                </be:link.editRecord>
    -                                <f:if condition="{redirect.disabled} == 1">
    +                                <f:if condition="{canEditRedirects}">
                                         <f:then>
    -                                        <a
    +                                        <be:link.editRecord
    +                                            returnUrl="{returnUrl}"
                                                 class="btn btn-default"
    -                                            href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=0', arguments:'{redirect: returnUrl}')}"
    -                                            title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')}"
    +                                            table="sys_redirect"
    +                                            uid="{redirect.uid}"
    +                                            title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}"
                                             >
    -                                            <core:icon identifier="actions-edit-unhide" />
    +                                            <core:icon identifier="actions-open" />
    +                                        </be:link.editRecord>
    +                                        <f:if condition="{redirect.disabled} == 1">
    +                                            <f:then>
    +                                                <a
    +                                                    class="btn btn-default"
    +                                                    href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=0', arguments:'{redirect: returnUrl}')}"
    +                                                    title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')}"
    +                                                >
    +                                                    <core:icon identifier="actions-edit-unhide" />
    +                                                </a>
    +                                            </f:then>
    +                                            <f:else>
    +                                                <a
    +                                                    class="btn btn-default"
    +                                                    href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=1', arguments:'{redirect: returnUrl}')}"
    +                                                    title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide')}"
    +                                                >
    +                                                    <core:icon identifier="actions-edit-hide" />
    +                                                </a>
    +                                            </f:else>
    +                                        </f:if>
    +                                        <a class="btn btn-default t3js-modal-trigger"
    +                                            href="{be:moduleLink(route:'tce_db', query:'cmd[sys_redirect][{redirect.uid}][delete]=1', arguments:'{redirect: returnUrl}')}"
    +                                            title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')}"
    +                                            data-severity="warning"
    +                                            data-title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title')}"
    +                                            data-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}"
    +                                            data-button-close-text="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}">
    +                                            <core:icon identifier="actions-delete" />
                                             </a>
                                         </f:then>
                                         <f:else>
    -                                        <a
    -                                            class="btn btn-default"
    -                                            href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=1', arguments:'{redirect: returnUrl}')}"
    -                                            title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide')}"
    -                                        >
    -                                            <core:icon identifier="actions-edit-hide" />
    -                                        </a>
    +                                        <span class="btn btn-default disabled"><core:icon identifier="empty-empty" /></span>
                                         </f:else>
                                     </f:if>
    -                                <a class="btn btn-default t3js-modal-trigger"
    -                                    href="{be:moduleLink(route:'tce_db', query:'cmd[sys_redirect][{redirect.uid}][delete]=1', arguments:'{redirect: returnUrl}')}"
    -                                    title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')}"
    -                                    data-severity="warning"
    -                                    data-title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title')}"
    -                                    data-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}"
    -                                    data-button-close-text="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}">
    -                                    <core:icon identifier="actions-delete" />
    -                                </a>
                                 </div>
                                 <div class="btn-group dropdown" role="group">
                                     <f:if condition="{redirect.is_regexp} || ({redirect.source_host} === '*')">
    @@ -307,7 +336,7 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                                                 </f:if>
                                             </ul>
                                         </f:then>
    -                                    <f:else>
    +                                    <f:else if="{canEditRedirects}">
                                             <span class="btn btn-default disabled"><core:icon identifier="empty-empty" /></span>
                                         </f:else>
                                     </f:if>
    
  • typo3/sysext/redirects/Tests/Functional/Fixtures/be_users.csv+3 2 modified
    @@ -1,4 +1,5 @@
     "be_users"
    -,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","TSconfig","lastlogin","workspace_id"
    +,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","TSconfig","lastlogin","workspace_id","db_mountpoints"
     # The password is "password"
    -,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,,1371033743,0
    +,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,,1371033743,0,
    +,2,0,1366642540,"editor","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1366642540,1,0,,1371033743,0,"13"
    
  • typo3/sysext/redirects/Tests/Functional/Fixtures/pages.csv+4 0 added
    @@ -0,0 +1,4 @@
    +"pages"
    +,"uid","pid","sorting","title","deleted","perms_everybody","TSconfig"
    +,13,0,128,"Root",0,15,""
    +,14,0,256,"Root 2",0,15,""
    
  • typo3/sysext/redirects/Tests/Functional/FormDataProvider/ValuePickerItemDataProviderTest.php+136 0 added
    @@ -0,0 +1,136 @@
    +<?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\Redirects\Tests\Functional\FormDataProvider;
    +
    +use PHPUnit\Framework\Attributes\Test;
    +use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
    +use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +final class ValuePickerItemDataProviderTest extends FunctionalTestCase
    +{
    +    use SiteBasedTestTrait;
    +
    +    protected const LANGUAGE_PRESETS = [
    +        'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en-US'],
    +    ];
    +
    +    protected array $coreExtensionsToLoad = [
    +        'redirects',
    +    ];
    +
    +    private array $sysRedirectResultSet = [
    +        'tableName' => 'sys_redirect',
    +        'processedTca' => [
    +            'columns' => [
    +                'source_host' => [
    +                    'config' => [
    +                        'valuePicker' => [
    +                            'items' => [
    +                            ],
    +                        ],
    +                    ],
    +                ],
    +            ],
    +        ],
    +    ];
    +
    +    private ValuePickerItemDataProvider $subject;
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/be_users.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/pages.csv');
    +
    +        $this->setUpBackendUser(1);
    +
    +        $this->subject = $this->get(ValuePickerItemDataProvider::class);
    +    }
    +
    +    #[Test]
    +    public function addDataDoesNothingIfNoRedirectDataGiven(): void
    +    {
    +        $result = [
    +            'tableName' => 'tt_content',
    +        ];
    +
    +        self::assertSame($result, $this->subject->addData($result));
    +    }
    +
    +    #[Test]
    +    public function addDataAddsAllHostsAsKeyAndValueToRedirectValuePickerAsAdmin(): void
    +    {
    +        $this->createSites();
    +
    +        $expected = $this->sysRedirectResultSet;
    +        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    +            ['label' => '*', 'value' => '*'],
    +            ['label' => 'bar.test', 'value' => 'bar.test'],
    +            ['label' => 'foo.test', 'value' => 'foo.test'],
    +        ];
    +
    +        self::assertSame($expected, $this->subject->addData($this->sysRedirectResultSet));
    +    }
    +
    +    #[Test]
    +    public function addDataAddsAllAvailableHostsAsKeyAndValueToRedirectValuePickerAsNonAdmin(): void
    +    {
    +        $this->createSites();
    +        $this->setUpBackendUser(2);
    +
    +        $expected = $this->sysRedirectResultSet;
    +        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    +            ['label' => '*', 'value' => '*'],
    +            ['label' => 'bar.test', 'value' => 'bar.test'],
    +        ];
    +
    +        self::assertSame($expected, $this->subject->addData($this->sysRedirectResultSet));
    +    }
    +
    +    #[Test]
    +    public function addDataDoesNotChangeResultSetIfNoSitesAreFound(): void
    +    {
    +        $actualResult = $this->subject->addData($this->sysRedirectResultSet);
    +        $expected = $this->sysRedirectResultSet;
    +        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    +            ['label' => '*', 'value' => '*'],
    +        ];
    +        self::assertSame($expected, $actualResult);
    +    }
    +
    +    private function createSites(): void
    +    {
    +        $this->writeSiteConfiguration(
    +            'bar',
    +            $this->buildSiteConfiguration(13, 'https://bar.test/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://bar.test/'),
    +            ],
    +        );
    +
    +        $this->writeSiteConfiguration(
    +            'foo',
    +            $this->buildSiteConfiguration(14, 'https://foo.test/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://foo.test/'),
    +            ],
    +        );
    +    }
    +}
    
  • typo3/sysext/redirects/Tests/Functional/Repository/Fixtures/DemandFixture.php+29 0 added
    @@ -0,0 +1,29 @@
    +<?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\Redirects\Tests\Functional\Repository\Fixtures;
    +
    +use TYPO3\CMS\Redirects\Repository\Demand;
    +
    +class DemandFixture extends Demand
    +{
    +    public function setLimit(int $limit): self
    +    {
    +        $this->limit = $limit;
    +        return $this;
    +    }
    +}
    
  • typo3/sysext/redirects/Tests/Functional/Repository/Fixtures/sys_redirect.csv+2 2 renamed
    @@ -1,8 +1,8 @@
     sys_redirect,,,,,,,,,,,,
     ,uid,pid,createdon,deleted,disabled,source_host,source_path,target,target_statuscode,hitcount,protected,creation_type,redirect_type
     ,1,0,2147483647,0,0,*,/foo,https://example.com/bar,301,0,1,0,default
    -,2,0,1,0,0,*,/foo,https://example.com/bar,302,10,0,0,default
    +,2,0,1,0,0,*,/foo,t3://page?uid=1,302,10,0,0,default
     ,3,0,1,0,0,foo.com,/foo/bar,https://example.com/bar,303,0,0,1,default
     ,4,0,1,0,0,foo.com,/foo/baz,https://example.com/bar,304,0,0,0,default
    -,5,0,1,0,0,bar.com,/bar,https://example.com/bar,305,0,0,0,default
    +,5,0,1,0,0,bar.com,/bar,https://example.com/bar,305,0,0,1,default
     ,6,0,2147483647,0,0,bar.com,/bar/foo,https://example.com/foo,305,0,0,0,default
    
  • typo3/sysext/redirects/Tests/Functional/Repository/RedirectRepositoryTest.php+143 13 modified
    @@ -20,15 +20,48 @@
     use PHPUnit\Framework\Attributes\DataProvider;
     use PHPUnit\Framework\Attributes\Test;
     use TYPO3\CMS\Core\Database\ConnectionPool;
    -use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
    +use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
     use TYPO3\CMS\Redirects\Repository\Demand;
     use TYPO3\CMS\Redirects\Repository\RedirectRepository;
    +use TYPO3\CMS\Redirects\Tests\Functional\Repository\Fixtures\DemandFixture;
     use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
     
     final class RedirectRepositoryTest extends FunctionalTestCase
     {
    +    use SiteBasedTestTrait;
    +
    +    protected const LANGUAGE_PRESETS = [
    +        'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en-US'],
    +    ];
    +
         protected array $coreExtensionsToLoad = ['redirects'];
     
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/be_users.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/pages.csv');
    +
    +        $this->setUpBackendUser(1);
    +
    +        $this->writeSiteConfiguration(
    +            'bar',
    +            $this->buildSiteConfiguration(13, 'https://bar.com/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://bar.com/'),
    +            ],
    +        );
    +
    +        $this->writeSiteConfiguration(
    +            'foo',
    +            $this->buildSiteConfiguration(14, 'https://foo.com/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://foo.com/'),
    +            ],
    +        );
    +    }
    +
         public static function demandProvider(): array
         {
             $allRecordCount = 6;
    @@ -86,7 +119,7 @@ public static function demandProvider(): array
                 'demand with creation type "manually created"' => [
                     self::getDemand(0, [], [], '', 1),
                     $allRecordCount,
    -                $allRecordCount - 1,
    +                $allRecordCount - 2,
                 ],
             ];
         }
    @@ -96,12 +129,10 @@ public static function demandProvider(): array
         public function removeByDemandWorks(Demand $demand, int $redirectBeforeCleanup, int $redirectAfterCleanup): void
         {
             self::assertSame(0, $this->getRedirectCount());
    -        $this->importCSVDataSet(__DIR__ . '/Fixtures/RedirectRepositoryTest_redirects.csv');
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
     
             self::assertSame($redirectBeforeCleanup, $this->getRedirectCount());
    -        $repository = new RedirectRepository(
    -            $this->get(TcaSchemaFactory::class)
    -        );
    +        $repository = $this->get(RedirectRepository::class);
             $repository->removeByDemand($demand);
             self::assertSame($redirectAfterCleanup, $this->getRedirectCount());
         }
    @@ -145,11 +176,11 @@ public static function countRedirectsByDemandCountsCorrectlyDataProvider(): iter
     
             yield 'demand with target' => [
                 new Demand(target: 'https://example.com/bar'),
    -            5,
    +            4,
             ];
             yield 'demand with creation type "manually created"' => [
                 new Demand(creationType: 1),
    -            1,
    +            2,
             ];
             yield 'demand with protected state' => [
                 new Demand(protected: 1),
    @@ -161,16 +192,115 @@ public static function countRedirectsByDemandCountsCorrectlyDataProvider(): iter
         #[Test]
         public function countRedirectsByDemandCountsCorrectly(Demand $demand, int $expectedCount): void
         {
    -        $this->importCSVDataSet(__DIR__ . '/Fixtures/RedirectRepositoryTest_redirects.csv');
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
     
    -        $repository = new RedirectRepository(
    -            $this->get(TcaSchemaFactory::class)
    -        );
    -        $redirectsCount = $repository->countRedirectsByByDemand($demand);
    +        $repository = $this->get(RedirectRepository::class);
    +        $redirectsCount = $repository->countRedirectsByDemand($demand);
    +
    +        self::assertSame($expectedCount, $redirectsCount);
    +    }
    +
    +    public static function countRedirectsByDemandRespectsUserPermissionsDataProvider(): iterable
    +    {
    +        yield 'default demand' => [
    +            new Demand(),
    +            4,
    +        ];
    +
    +        yield 'configuration with hitCount' => [
    +            new Demand(maxHits: 2),
    +            3,
    +        ];
    +
    +        yield 'configuration with statusCode 302' => [
    +            new Demand(statusCodes: [302]),
    +            1,
    +        ];
    +
    +        yield 'demand with statusCode 302, 303' => [
    +            new Demand(statusCodes: [302, 303]),
    +            1,
    +        ];
    +
    +        yield 'demand with domain' => [
    +            new Demand(sourceHosts: ['bar.com']),
    +            2,
    +        ];
    +
    +        yield 'demand with domains' => [
    +            new Demand(sourceHosts: ['foo.com', 'bar.com']),
    +            2,
    +        ];
    +
    +        yield 'demand with path' => [
    +            new Demand(sourcePath: '/foo'),
    +            3,
    +        ];
    +
    +        yield 'demand with target' => [
    +            new Demand(target: 'https://example.com/bar'),
    +            2,
    +        ];
    +        yield 'demand with creation type "manually created"' => [
    +            new Demand(creationType: 1),
    +            1,
    +        ];
    +        yield 'demand with protected state' => [
    +            new Demand(protected: 1),
    +            1,
    +        ];
    +    }
    +
    +    #[DataProvider('countRedirectsByDemandRespectsUserPermissionsDataProvider')]
    +    #[Test]
    +    public function countRedirectsByDemandRespectsUserPermissions(Demand $demand, int $expectedCount): void
    +    {
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
    +
    +        $backendUser = $this->setUpBackendUser(2);
    +        $backendUser->userGroupsUID = [1];
    +        $backendUser->groupData['webmounts'] = '13';
    +
    +        $repository = $this->get(RedirectRepository::class);
    +        $redirectsCount = $repository->countRedirectsByDemand($demand);
     
             self::assertSame($expectedCount, $redirectsCount);
         }
     
    +    public static function filteredRedirectsArePaginatedCorrectlyDataProvider(): iterable
    +    {
    +        yield 'first page' => [
    +            (new DemandFixture(page: 1))->setLimit(2),
    +            [1, 2],
    +        ];
    +        // the second page skips uids 3 and 4, as they are not in web-mount 13
    +        yield 'second page' => [
    +            (new DemandFixture(page: 2))->setLimit(2),
    +            [5, 6],
    +        ];
    +        // the third page does not have any more redirects in web-mount 13
    +        yield 'third page' => [
    +            (new DemandFixture(page: 3))->setLimit(2),
    +            [],
    +        ];
    +    }
    +
    +    #[DataProvider('filteredRedirectsArePaginatedCorrectlyDataProvider')]
    +    #[Test]
    +    public function filteredRedirectsArePaginatedCorrectly(Demand $demand, array $expectation): void
    +    {
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
    +
    +        $backendUser = $this->setUpBackendUser(2);
    +        $backendUser->userGroupsUID = [1];
    +        $backendUser->groupData['webmounts'] = '13';
    +
    +        $repository = $this->get(RedirectRepository::class);
    +        $redirects = $repository->findRedirectsByDemand($demand);
    +        $redirectUids = array_column($redirects, 'uid');
    +        self::assertSame($expectation, $redirectUids);
    +    }
    +
         private function getRedirectCount(): int
         {
             $queryBuilder = $this->get(ConnectionPool::class)
    
  • typo3/sysext/redirects/Tests/Unit/FormDataProvider/ValuePickerItemDataProviderTest.php+0 90 removed
    @@ -1,90 +0,0 @@
    -<?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\Redirects\Tests\Unit\FormDataProvider;
    -
    -use PHPUnit\Framework\Attributes\Test;
    -use TYPO3\CMS\Core\Site\Entity\Site;
    -use TYPO3\CMS\Core\Site\SiteFinder;
    -use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
    -use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
    -
    -final class ValuePickerItemDataProviderTest extends UnitTestCase
    -{
    -    private array $sysRedirectResultSet = [
    -        'tableName' => 'sys_redirect',
    -        'processedTca' => [
    -            'columns' => [
    -                'source_host' => [
    -                    'config' => [
    -                        'valuePicker' => [
    -                            'items' => [
    -                            ],
    -                        ],
    -                    ],
    -                ],
    -            ],
    -        ],
    -    ];
    -
    -    #[Test]
    -    public function addDataDoesNothingIfNoRedirectDataGiven(): void
    -    {
    -        $result = [
    -            'tableName' => 'tt_content',
    -        ];
    -
    -        $siteFinderMock = $this->getMockBuilder(SiteFinder::class)->disableOriginalConstructor()->getMock();
    -        $valuePickerItemDataProvider = new ValuePickerItemDataProvider($siteFinderMock);
    -        $actualResult = $valuePickerItemDataProvider->addData($result);
    -        self::assertSame($result, $actualResult);
    -    }
    -
    -    #[Test]
    -    public function addDataAddsHostsAsKeyAndValueToRedirectValuePicker(): void
    -    {
    -        // no results for now
    -        $siteFinderMock = $this->getMockBuilder(SiteFinder::class)->disableOriginalConstructor()->getMock();
    -        $siteFinderMock->expects($this->once())->method('getAllSites')->willReturn([
    -            new Site('bar', 13, ['base' => 'bar.test']),
    -            new Site('foo', 14, ['base' => 'foo.test']),
    -        ]);
    -        $valuePickerItemDataProvider = new ValuePickerItemDataProvider($siteFinderMock);
    -        $actualResult = $valuePickerItemDataProvider->addData($this->sysRedirectResultSet);
    -        $expected = $this->sysRedirectResultSet;
    -        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    -            ['label' => '*', 'value' => '*'],
    -            ['label' => 'bar.test', 'value' => 'bar.test'],
    -            ['label' => 'foo.test', 'value' => 'foo.test'],
    -        ];
    -        self::assertSame($expected, $actualResult);
    -    }
    -
    -    #[Test]
    -    public function addDataDoesNotChangeResultSetIfNoSitesAreFound(): void
    -    {
    -        $siteFinderMock = $this->getMockBuilder(SiteFinder::class)->disableOriginalConstructor()->getMock();
    -        $siteFinderMock->expects($this->once())->method('getAllSites')->willReturn([]);
    -        $valuePickerItemDataProvider = new ValuePickerItemDataProvider($siteFinderMock);
    -        $actualResult = $valuePickerItemDataProvider->addData($this->sysRedirectResultSet);
    -        $expected = $this->sysRedirectResultSet;
    -        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    -            ['label' => '*', 'value' => '*'],
    -        ];
    -        self::assertSame($expected, $actualResult);
    -    }
    -}
    
bac370df5c1c

[SECURITY] Prevent unauthorized access to resources in redirects module

https://github.com/TYPO3/typo3Elias HäußlerJan 13, 2026via ghsa
18 files changed · +973 197
  • typo3/sysext/redirects/Classes/Controller/ManagementController.php+41 16 modified
    @@ -26,6 +26,7 @@
     use TYPO3\CMS\Backend\Template\Components\MultiRecordSelection\Action;
     use TYPO3\CMS\Backend\Template\ModuleTemplate;
     use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
     use TYPO3\CMS\Core\Configuration\Features;
     use TYPO3\CMS\Core\Imaging\IconFactory;
     use TYPO3\CMS\Core\Imaging\IconSize;
    @@ -65,6 +66,10 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
             );
             $this->registerDocHeaderButtons($view, $request->getAttribute('normalizedParams')->getRequestUri());
     
    +        if (!$this->canListRedirects()) {
    +            return $view->renderResponse('Management/Overview');
    +        }
    +
             $event = $this->eventDispatcher->dispatch(
                 new ModifyRedirectManagementControllerViewDataEvent(
                     $demand,
    @@ -81,6 +86,7 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
             $requestUri = $request->getAttribute('normalizedParams')->getRequestUri();
             $languageService = $this->getLanguageService();
             $view = $event->getView();
    +        $hasEditPermissions = $this->canEditRedirects();
             $view->assignMultiple([
                 'redirects' => $event->getRedirects(),
                 'hosts' => $event->getHosts(),
    @@ -91,7 +97,9 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
                 'demand' => $event->getDemand(),
                 'showHitCounter' => $event->getShowHitCounter(),
                 'pagination' => $this->preparePagination($event->getDemand()),
    -            'actions' => [
    +            'canEditRedirects' => $hasEditPermissions,
    +            'canListRedirects' => true,
    +            'actions' => $hasEditPermissions ? [
                     new Action(
                         'edit',
                         [
    @@ -116,17 +124,27 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
                         'actions-edit-delete',
                         'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'
                     ),
    -            ],
    +            ] : [],
             ]);
             return $view->renderResponse('Management/Overview');
         }
     
    +    protected function canListRedirects(): bool
    +    {
    +        return $this->getBackendUser()->check('tables_select', 'sys_redirect');
    +    }
    +
    +    protected function canEditRedirects(): bool
    +    {
    +        return $this->getBackendUser()->check('tables_modify', 'sys_redirect');
    +    }
    +
         /**
          * Prepares information for the pagination of the module
          */
         protected function preparePagination(Demand $demand): array
         {
    -        $count = $this->redirectRepository->countRedirectsByByDemand($demand);
    +        $count = $this->redirectRepository->countRedirectsByDemand($demand);
             $numberOfPages = ceil($count / $demand->getLimit());
             $endRecord = $demand->getOffset() + $demand->getLimit();
             if ($endRecord > $count) {
    @@ -159,19 +177,21 @@ protected function registerDocHeaderButtons(ModuleTemplate $view, string $reques
             $buttonBar = $view->getDocHeaderComponent()->getButtonBar();
     
             // Create new
    -        $newRecordButton = $buttonBar->makeLinkButton()
    -            ->setHref((string)$this->uriBuilder->buildUriFromRoute(
    -                'record_edit',
    -                [
    -                    'edit' => ['sys_redirect' => ['new'],
    -                    ],
    -                    'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('site_redirects'),
    -                ]
    -            ))
    -            ->setTitle($languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_add_text'))
    -            ->setShowLabelText(true)
    -            ->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
    -        $buttonBar->addButton($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10);
    +        if ($this->canEditRedirects()) {
    +            $newRecordButton = $buttonBar->makeLinkButton()
    +                ->setHref((string)$this->uriBuilder->buildUriFromRoute(
    +                    'record_edit',
    +                    [
    +                        'edit' => ['sys_redirect' => ['new'],
    +                        ],
    +                        'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('site_redirects'),
    +                    ]
    +                ))
    +                ->setTitle($languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_add_text'))
    +                ->setShowLabelText(true)
    +                ->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL));
    +            $buttonBar->addButton($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10);
    +        }
     
             // Reload
             $reloadButton = $buttonBar->makeLinkButton()
    @@ -191,4 +211,9 @@ protected function getLanguageService(): LanguageService
         {
             return $GLOBALS['LANG'];
         }
    +
    +    protected function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
     }
    
  • typo3/sysext/redirects/Classes/Data/SourceHostProvider.php+105 0 added
    @@ -0,0 +1,105 @@
    +<?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\Redirects\Data;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autowire;
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
    +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
    +use TYPO3\CMS\Core\Exception\SiteNotFoundException;
    +use TYPO3\CMS\Core\Site\SiteFinder;
    +
    +/**
    + * Data provider for source hosts in sys_redirect records
    + *
    + * @internal
    + */
    +final readonly class SourceHostProvider
    +{
    +    public function __construct(
    +        private SiteFinder $siteFinder,
    +        #[Autowire(service: 'cache.runtime')]
    +        private FrontendInterface $cache,
    +    ) {}
    +
    +    /**
    +     * Get all available hosts for current backend user.
    +     *
    +     * @return list<non-empty-string>
    +     */
    +    public function getHosts(bool $includeWildcard = false): array
    +    {
    +        $cacheIdentifier = 'RedirectsSourceHostProvider' . ($includeWildcard ? '-wildcard' : '');
    +
    +        if (!$this->cache->has($cacheIdentifier)) {
    +            $this->cache->set($cacheIdentifier, $this->filterAllowedSourceHosts($includeWildcard));
    +        }
    +
    +        return $this->cache->get($cacheIdentifier);
    +    }
    +
    +    /**
    +     * @return list<non-empty-string>
    +     */
    +    private function filterAllowedSourceHosts(bool $includeWildcard): array
    +    {
    +        $backendUser = $this->getBackendUser();
    +
    +        if ($includeWildcard) {
    +            $hosts = ['*'];
    +        } else {
    +            $hosts = [];
    +        }
    +
    +        if ($backendUser->isAdmin()) {
    +            foreach ($this->siteFinder->getAllSites() as $site) {
    +                foreach ($site->getAllLanguages() as $language) {
    +                    $host = $language->getBase()->getHost();
    +
    +                    if ($host !== '' && !in_array($host, $hosts, true)) {
    +                        $hosts[] = $host;
    +                    }
    +                }
    +            }
    +        } else {
    +            foreach ($backendUser->getWebmounts() as $pageId) {
    +                try {
    +                    $site = $this->siteFinder->getSiteByPageId($pageId);
    +
    +                    foreach ($site->getAvailableLanguages($backendUser) as $language) {
    +                        $host = $language->getBase()->getHost();
    +
    +                        if ($host !== '' && !in_array($host, $hosts, true)) {
    +                            $hosts[] = $host;
    +                        }
    +                    }
    +                } catch (SiteNotFoundException) {
    +                    // Ignore unavailable sites
    +                }
    +            }
    +        }
    +
    +        sort($hosts, SORT_NATURAL);
    +
    +        return $hosts;
    +    }
    +
    +    private function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/EventListener/RedirectEditPermissionGuard.php+44 0 added
    @@ -0,0 +1,44 @@
    +<?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\Redirects\EventListener;
    +
    +use TYPO3\CMS\Backend\Form\Event\ModifyEditFormUserAccessEvent;
    +use TYPO3\CMS\Core\Attribute\AsEventListener;
    +use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
    +
    +/**
    + * @internal
    + */
    +final readonly class RedirectEditPermissionGuard
    +{
    +    public function __construct(
    +        private RedirectPermissionGuard $redirectPermissionGuard,
    +    ) {}
    +
    +    #[AsEventListener('redirect-edit-permission-guard')]
    +    public function __invoke(ModifyEditFormUserAccessEvent $event): void
    +    {
    +        if ($event->getTableName() !== 'sys_redirect' || $event->getCommand() === 'new') {
    +            return;
    +        }
    +
    +        if (!$this->redirectPermissionGuard->isAllowedRedirect($event->getDatabaseRow())) {
    +            $event->denyUserAccess();
    +        }
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/FormDataProvider/ValuePickerItemDataProvider.php+8 31 modified
    @@ -17,25 +17,20 @@
     
     namespace TYPO3\CMS\Redirects\FormDataProvider;
     
    +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
     use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
    -use TYPO3\CMS\Core\Site\SiteFinder;
    -use TYPO3\CMS\Core\Utility\GeneralUtility;
    +use TYPO3\CMS\Redirects\Data\SourceHostProvider;
     
     /**
      * Inject available domain hosts into a valuepicker form
      * @internal
      */
    -class ValuePickerItemDataProvider implements FormDataProviderInterface
    +#[Autoconfigure(public: true)]
    +final readonly class ValuePickerItemDataProvider implements FormDataProviderInterface
     {
    -    /**
    -     * @var SiteFinder
    -     */
    -    protected $siteFinder;
    -
    -    public function __construct(?SiteFinder $siteFinder = null)
    -    {
    -        $this->siteFinder = $siteFinder ?? GeneralUtility::makeInstance(SiteFinder::class);
    -    }
    +    public function __construct(
    +        private SourceHostProvider $sourceHostProvider,
    +    ) {}
     
         /**
          * Add sys_domains into $result data array
    @@ -46,7 +41,7 @@ public function __construct(?SiteFinder $siteFinder = null)
         public function addData(array $result): array
         {
             if ($result['tableName'] === 'sys_redirect' && isset($result['processedTca']['columns']['source_host'])) {
    -            $domains = $this->getHosts();
    +            $domains = $this->sourceHostProvider->getHosts();
                 foreach ($domains as $domain) {
                     $result['processedTca']['columns']['source_host']['config']['valuePicker']['items'][] =
                         [
    @@ -57,22 +52,4 @@ public function addData(array $result): array
             }
             return $result;
         }
    -
    -    /**
    -     * Get all hosts from sites
    -     *
    -     * @return string[] domain records
    -     */
    -    protected function getHosts(): array
    -    {
    -        $domains = [];
    -        foreach ($this->siteFinder->getAllSites() as $site) {
    -            foreach ($site->getAllLanguages() as $language) {
    -                $domains[] = $language->getBase()->getHost();
    -            }
    -        }
    -        $domains = array_unique($domains);
    -        sort($domains, SORT_NATURAL);
    -        return $domains;
    -    }
     }
    
  • typo3/sysext/redirects/Classes/Hooks/DataHandlerPermissionGuardHook.php+77 0 added
    @@ -0,0 +1,77 @@
    +<?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\Redirects\Hooks;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
    +use TYPO3\CMS\Core\DataHandling\DataHandler;
    +use TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
    +use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
    +use TYPO3\CMS\Core\Utility\MathUtility;
    +use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
    +
    +/**
    + * @internal This class is a specific TYPO3 hook implementation and is not part of the Public TYPO3 API.
    + */
    +#[Autoconfigure(public: true)]
    +final readonly class DataHandlerPermissionGuardHook
    +{
    +    public function __construct(
    +        private RedirectPermissionGuard $redirectPermissionGuard,
    +    ) {}
    +
    +    /**
    +     * @param array<string, mixed> $incomingFieldArray
    +     * @param-out array<string, mixed>|null $incomingFieldArray
    +     */
    +    public function processDatamap_preProcessFieldArray(
    +        array &$incomingFieldArray,
    +        string $table,
    +        string|int $id,
    +        DataHandler $dataHandler,
    +    ): void {
    +        if ($table === 'sys_redirect' && !$this->redirectPermissionGuard->isAllowedRedirect($incomingFieldArray)) {
    +            // Reset incoming field array to avoid further processing in DataHandler
    +            // in case the given source host is not allowed for the current user
    +            $incomingFieldArray = null;
    +
    +            if (MathUtility::canBeInterpretedAsInteger($id)) {
    +                // Record update
    +                $dataHandler->log(
    +                    'sys_redirect',
    +                    (int)$id,
    +                    SystemLogDatabaseAction::UPDATE,
    +                    null,
    +                    SystemLogErrorClassification::USER_ERROR,
    +                    'Attempt to modify sys_redirect record "%d" is disallowed',
    +                    null,
    +                    [$id],
    +                );
    +            } else {
    +                // New record
    +                $dataHandler->log(
    +                    'sys_redirect',
    +                    0,
    +                    SystemLogDatabaseAction::INSERT,
    +                    null,
    +                    SystemLogErrorClassification::USER_ERROR,
    +                    'Attempt to create a new sys_redirect record is disallowed',
    +                );
    +            }
    +        }
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/Repository/RedirectRepository.php+165 11 modified
    @@ -17,35 +17,108 @@
     
     namespace TYPO3\CMS\Redirects\Repository;
     
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
     use TYPO3\CMS\Core\Database\Connection;
     use TYPO3\CMS\Core\Database\ConnectionPool;
     use TYPO3\CMS\Core\Database\Query\QueryBuilder;
     use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
     use TYPO3\CMS\Core\Utility\GeneralUtility;
    +use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
     
     /**
      * Class for accessing redirect records from the database
      * @internal
      */
     class RedirectRepository
     {
    +    public function __construct(
    +        private readonly RedirectPermissionGuard $redirectPermissionGuard,
    +    ) {}
    +
         /**
          * Used within the backend module, which also includes the hidden records, but never deleted records.
          */
         public function findRedirectsByDemand(Demand $demand): array
         {
    -        return $this->getQueryBuilderForDemand($demand)
    -            ->setMaxResults($demand->getLimit())
    -            ->setFirstResult($demand->getOffset())
    +        // Fast path for admin users - use SQL pagination directly
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return $this->getQueryBuilderForDemand($demand)
    +                ->select('*')
    +                ->setMaxResults($demand->getLimit())
    +                ->setFirstResult($demand->getOffset())
    +                ->executeQuery()
    +                ->fetchAllAssociative();
    +        }
    +
    +        // Non-admin: Two-phase fetch without caching
    +        // Phase 1: Fetch minimal fields for ALL matching records with SQL source host filtering
    +        $queryBuilder = $this->getQueryBuilderForDemand($demand);
    +
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return [];
    +        }
    +
    +        $redirects = $queryBuilder
    +            ->executeQuery()
    +            ->fetchAllAssociative();
    +
    +        // Phase 2: Apply PHP target permission filtering
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
    +
    +        // Phase 3: Get UIDs for current page (applying pagination in PHP)
    +        $filteredUids = array_column($filteredRedirects, 'uid');
    +        $currentPageUids = array_slice($filteredUids, $demand->getOffset(), $demand->getLimit());
    +
    +        if ($currentPageUids === []) {
    +            return [];
    +        }
    +
    +        // Phase 4: Fetch full records only for the current page
    +        $queryBuilder = $this->getQueryBuilder();
    +        return $queryBuilder
    +            ->select('*')
    +            ->from('sys_redirect')
    +            ->where(
    +                $queryBuilder->expr()->in(
    +                    'uid',
    +                    $queryBuilder->createNamedParameter($currentPageUids, Connection::PARAM_INT_ARRAY)
    +                )
    +            )
    +            ->orderBy($demand->getOrderField(), $demand->getOrderDirection())
                 ->executeQuery()
                 ->fetchAllAssociative();
         }
     
    -    public function countRedirectsByByDemand(Demand $demand): int
    +    public function countRedirectsByDemand(Demand $demand): int
         {
    -        return (int)$this->getQueryBuilderForDemand($demand, true)
    +        // Fast path for admin users - use SQL COUNT
    +        if ($this->getBackendUser()->isAdmin()) {
    +            $queryBuilder = $this->getQueryBuilderForDemand($demand, true);
    +            return (int)$queryBuilder
    +                ->count('uid')
    +                ->executeQuery()
    +                ->fetchOne();
    +        }
    +
    +        // Non-admin: Fetch minimal fields with SQL source host filtering
    +        $queryBuilder = $this->getQueryBuilderForDemand($demand);
    +
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return 0;
    +        }
    +
    +        $redirects = $queryBuilder
                 ->executeQuery()
    -            ->fetchOne();
    +            ->fetchAllAssociative();
    +
    +        // Apply PHP target permission filtering and count
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
    +
    +        return count($filteredRedirects);
         }
     
         public function countActiveRedirects(): int
    @@ -58,6 +131,37 @@ public function countActiveRedirects(): int
                 ->fetchOne();
         }
     
    +    /**
    +     * Adds source host constraint to query for non-admin users
    +     * This significantly reduces the dataset before PHP filtering
    +     * @throws StopQueryException
    +     */
    +    protected function addSourceHostConstraint(QueryBuilder $queryBuilder): void
    +    {
    +        // Admin users see all hosts
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return;
    +        }
    +
    +        // Get allowed hosts for the current user
    +        $allowedHosts = $this->redirectPermissionGuard->getAllowedHosts();
    +        if (empty($allowedHosts)) {
    +            throw new StopQueryException('No allowed hosts found for current user', 1764702053);
    +        }
    +
    +        $queryBuilder->andWhere(
    +            $queryBuilder->expr()->in(
    +                'source_host',
    +                $queryBuilder->createNamedParameter($allowedHosts, Connection::PARAM_STR_ARRAY)
    +            )
    +        );
    +    }
    +
    +    protected function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
    +
         /**
          * Prepares the QueryBuilder with Constraints from the Demand
          */
    @@ -66,9 +170,9 @@ protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQue
             $queryBuilder = $this->getQueryBuilder();
     
             if ($createCountQuery) {
    -            $queryBuilder->count('*');
    +            $queryBuilder->count('uid');
             } else {
    -            $queryBuilder->select('*');
    +            $queryBuilder->select('uid', 'source_host', 'target');
             }
     
             $queryBuilder->from('sys_redirect');
    @@ -78,12 +182,14 @@ protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQue
                     $demand->getOrderField(),
                     $demand->getOrderDirection()
                 );
    +
                 if ($demand->hasSecondaryOrdering()) {
                     $queryBuilder->addOrderBy($demand->getSecondaryOrderField());
                 }
             }
     
             $constraints = [];
    +
             if ($demand->hasSourceHosts()) {
                 $constraints[] = $queryBuilder->expr()->in(
                     'source_host',
    @@ -150,6 +256,7 @@ protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQue
             if (!empty($constraints)) {
                 $queryBuilder->where(...$constraints);
             }
    +
             return $queryBuilder;
         }
     
    @@ -205,15 +312,53 @@ public function findIntegrityStatusCodes(): array
             return $statusCodes;
         }
     
    +    /**
    +     * @return list<array<string, scalar|null>>
    +     */
         protected function getGroupedRows(string $field, string $as): array
         {
    -        return $this->getQueryBuilder()
    -            ->select(sprintf('%s as %s', $field, $as))
    +        // Admin: Direct SQL query
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return $this->getQueryBuilder()
    +                ->select(sprintf('%s as %s', $field, $as))
    +                ->from('sys_redirect')
    +                ->orderBy($field)
    +                ->groupBy($field)
    +                ->executeQuery()
    +                ->fetchAllAssociative();
    +        }
    +
    +        // Non-admin: Need to include source_host and target for filtering
    +        $fields = [$field];
    +        if ($field !== 'source_host') {
    +            $fields[] = 'source_host';
    +        }
    +        if ($field !== 'target') {
    +            $fields[] = 'target';
    +        }
    +
    +        $queryBuilder = $this->getQueryBuilder()
    +            ->select(...$fields)
                 ->from('sys_redirect')
                 ->orderBy($field)
    -            ->groupBy($field)
    +            ->groupBy(...$fields);
    +
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return [];
    +        }
    +
    +        $redirects = $queryBuilder
                 ->executeQuery()
                 ->fetchAllAssociative();
    +
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
    +
    +        return array_map(
    +            static fn(mixed $value) => [$as => $value],
    +            array_values(array_unique(array_column($filteredRedirects, $field))),
    +        );
         }
     
         protected function getQueryBuilder(): QueryBuilder
    @@ -270,4 +415,13 @@ public function removeByDemand(Demand $demand): void
     
             $queryBuilder->executeStatement();
         }
    +
    +    /**
    +     * @param list<non-empty-array> $redirects
    +     * @return list<non-empty-array>
    +     */
    +    protected function sortOutInaccessibleRedirects(array $redirects): array
    +    {
    +        return array_filter($redirects, $this->redirectPermissionGuard->isAllowedRedirect(...));
    +    }
     }
    
  • typo3/sysext/redirects/Classes/Repository/StopQueryException.php+24 0 added
    @@ -0,0 +1,24 @@
    +<?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\Redirects\Repository;
    +
    +/**
    + * Class signaling that the query should be stopped.
    + * @internal
    + */
    +class StopQueryException extends \RuntimeException {}
    
  • typo3/sysext/redirects/Classes/Security/RedirectPermissionGuard.php+121 0 added
    @@ -0,0 +1,121 @@
    +<?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\Redirects\Security;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autowire;
    +use TYPO3\CMS\Backend\Utility\BackendUtility;
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
    +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
    +use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
    +use TYPO3\CMS\Core\LinkHandling\Exception\UnknownUrnException;
    +use TYPO3\CMS\Core\LinkHandling\LinkService;
    +use TYPO3\CMS\Core\Resource\FileInterface;
    +use TYPO3\CMS\Core\Type\Bitmask\Permission;
    +use TYPO3\CMS\Redirects\Data\SourceHostProvider;
    +
    +/**
    + * Security guard to validate access to sys_redirect records for the current backend user.
    + *
    + * @internal
    + */
    +final class RedirectPermissionGuard
    +{
    +    /**
    +     * @var list<non-empty-string>|null
    +     */
    +    private ?array $allowedHosts = null;
    +
    +    public function __construct(
    +        private readonly LinkService $linkService,
    +        private readonly SourceHostProvider $sourceHostProvider,
    +        #[Autowire(service: 'cache.runtime')]
    +        private readonly FrontendInterface $cache,
    +    ) {}
    +
    +    public function isAllowedRedirect(array $redirect): bool
    +    {
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return true;
    +        }
    +
    +        return $this->isAllowedSourceHost($redirect['source_host'] ?? '')
    +            && $this->isAllowedTarget($redirect['target'] ?? '');
    +    }
    +
    +    public function getAllowedHosts(): array
    +    {
    +        $this->allowedHosts ??= $this->sourceHostProvider->getHosts(true);
    +
    +        return $this->allowedHosts;
    +    }
    +
    +    private function isAllowedSourceHost(string $host): bool
    +    {
    +        return in_array($host, $this->getAllowedHosts(), true);
    +    }
    +
    +    private function isAllowedTarget(string $target): bool
    +    {
    +        $cacheIdentifier = 'RedirectPermissionGuard-isAllowedTarget-' . md5($target);
    +
    +        if ($this->cache->has($cacheIdentifier)) {
    +            return $this->cache->get($cacheIdentifier);
    +        }
    +
    +        $result = true;
    +
    +        if (str_starts_with($target, 't3://')) {
    +            try {
    +                $resolvedLink = $this->linkService->resolveByStringRepresentation($target);
    +
    +                if ((int)($resolvedLink['pageuid'] ?? 0) > 0) {
    +                    $result = $this->canAccessPage((int)$resolvedLink['pageuid']);
    +                } elseif (($resolvedLink['file'] ?? null) instanceof FileInterface) {
    +                    $result = $this->canAccessFile($resolvedLink['file']);
    +                }
    +            } catch (UnknownUrnException|UnknownLinkHandlerException) {
    +            }
    +        }
    +
    +        $this->cache->set($cacheIdentifier, $result);
    +
    +        return $result;
    +    }
    +
    +    private function canAccessPage(int $pageUid): bool
    +    {
    +        $page = BackendUtility::getRecord('pages', $pageUid, '*', '', false);
    +
    +        // If the page does no longer exist, we allow access to the redirect
    +        if ($page === null) {
    +            return true;
    +        }
    +
    +        return $this->getBackendUser()->doesUserHaveAccess($page, Permission::PAGE_SHOW);
    +    }
    +
    +    private function canAccessFile(FileInterface $file): bool
    +    {
    +        return $file->getStorage()->checkFileActionPermission('read', $file);
    +    }
    +
    +    private function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
    +}
    
  • typo3/sysext/redirects/ext_localconf.php+2 0 modified
    @@ -6,6 +6,7 @@
     use TYPO3\CMS\Redirects\Evaluation\SourceHost;
     use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
     use TYPO3\CMS\Redirects\Hooks\DataHandlerCacheFlushingHook;
    +use TYPO3\CMS\Redirects\Hooks\DataHandlerPermissionGuardHook;
     use TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook;
     use TYPO3\CMS\Redirects\Hooks\DispatchNotificationHook;
     
    @@ -14,6 +15,7 @@
     // Rebuild cache in DataHandler on changing / inserting / adding redirect records
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc']['redirects'] = DataHandlerCacheFlushingHook::class . '->rebuildRedirectCacheIfNecessary';
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects'] = DataHandlerSlugUpdateHook::class;
    +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirectsAccessGuard'] = DataHandlerPermissionGuardHook::class;
     
     // Inject sys_domains into valuepicker form
     $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRecord']
    
  • typo3/sysext/redirects/Resources/Private/Language/locallang_module_redirect.xlf+6 0 modified
    @@ -33,6 +33,12 @@
     				<source>Create new redirect</source>
     			</trans-unit>
     
    +			<trans-unit id="noAccessPermissions.title">
    +				<source>Access denied</source>
    +			</trans-unit>
    +			<trans-unit id="noAccessPermissions.message">
    +				<source>You are not allowed to list or modify redirects due to insufficient permissions.</source>
    +			</trans-unit>
     			<trans-unit id="redirect_not_found_with_filter.title">
     				<source>No redirects found!</source>
     			</trans-unit>
    
  • typo3/sysext/redirects/Resources/Private/Templates/Management/Overview.html+71 42 modified
    @@ -8,7 +8,20 @@
     <f:layout name="Module" />
     
     <f:section name="Content">
    +    <f:if condition="{canListRedirects}">
    +        <f:then>
    +            <f:render section="overview" arguments="{_all}" />
    +        </f:then>
    +        <f:else>
    +            <f:be.infobox state="{f:constant(name: 'TYPO3\CMS\Fluid\ViewHelpers\Be\InfoboxViewHelper::STATE_ERROR')}"
    +                message="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:noAccessPermissions.message')}"
    +                title="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:noAccessPermissions.title')}"
    +            />
    +        </f:else>
    +    </f:if>
    +</f:section>
     
    +<f:section name="overview">
         <f:asset.module identifier="@typo3/backend/modal.js"/>
         <f:asset.module identifier="@typo3/backend/multi-record-selection.js"/>
         <f:asset.module identifier="@typo3/backend/multi-record-selection-edit-action.js"/>
    @@ -47,9 +60,11 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                     <f:else>
                         <f:be.infobox state="{f:constant(name: 'TYPO3\CMS\Fluid\ViewHelpers\Be\InfoboxViewHelper::STATE_INFO')}" title="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_not_found.title')}">
                             <p><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_not_found.message"/></p>
    -                        <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_redirect">
    -                            <f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_create"/>
    -                        </be:link.newRecord>
    +                        <f:if condition="{canEditRedirects}">
    +                            <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_redirect">
    +                                <f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_create"/>
    +                            </be:link.newRecord>
    +                        </f:if>
                         </f:be.infobox>
                     </f:else>
                 </f:if>
    @@ -158,14 +173,21 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                             </td>
                             <td class="col-path">{redirect.source_host}</td>
                             <td class="col-path">
    -                            <be:link.editRecord
    -                                returnUrl="{returnUrl}"
    -                                table="sys_redirect"
    -                                uid="{redirect.uid}"
    -                                title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}: {redirect.source_path}"
    -                            >
    -                                {redirect.source_path}
    -                            </be:link.editRecord>
    +                            <f:if condition="{canEditRedirects}">
    +                                <f:then>
    +                                    <be:link.editRecord
    +                                        returnUrl="{returnUrl}"
    +                                        table="sys_redirect"
    +                                        uid="{redirect.uid}"
    +                                        title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}: {redirect.source_path}"
    +                                    >
    +                                        {redirect.source_path}
    +                                    </be:link.editRecord>
    +                                </f:then>
    +                                <f:else>
    +                                    {redirect.source_path}
    +                                </f:else>
    +                            </f:if>
                             </td>
                             <td class="col-path">
                                 <f:variable name="targetUri" value="{f:uri.typolink(parameter:redirect.target)}" />
    @@ -210,44 +232,51 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                             </f:if>
                             <td class="col-control">
                                 <div class="btn-group" role="group">
    -                                <be:link.editRecord
    -                                    returnUrl="{returnUrl}"
    -                                    class="btn btn-default"
    -                                    table="sys_redirect"
    -                                    uid="{redirect.uid}"
    -                                    title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}"
    -                                >
    -                                    <core:icon identifier="actions-open" />
    -                                </be:link.editRecord>
    -                                <f:if condition="{redirect.disabled} == 1">
    +                                <f:if condition="{canEditRedirects}">
                                         <f:then>
    -                                        <a
    +                                        <be:link.editRecord
    +                                            returnUrl="{returnUrl}"
                                                 class="btn btn-default"
    -                                            href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=0', arguments:'{redirect: returnUrl}')}"
    -                                            title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')}"
    +                                            table="sys_redirect"
    +                                            uid="{redirect.uid}"
    +                                            title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}"
                                             >
    -                                            <core:icon identifier="actions-edit-unhide" />
    +                                            <core:icon identifier="actions-open" />
    +                                        </be:link.editRecord>
    +                                        <f:if condition="{redirect.disabled} == 1">
    +                                            <f:then>
    +                                                <a
    +                                                    class="btn btn-default"
    +                                                    href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=0', arguments:'{redirect: returnUrl}')}"
    +                                                    title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')}"
    +                                                >
    +                                                    <core:icon identifier="actions-edit-unhide" />
    +                                                </a>
    +                                            </f:then>
    +                                            <f:else>
    +                                                <a
    +                                                    class="btn btn-default"
    +                                                    href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=1', arguments:'{redirect: returnUrl}')}"
    +                                                    title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide')}"
    +                                                >
    +                                                    <core:icon identifier="actions-edit-hide" />
    +                                                </a>
    +                                            </f:else>
    +                                        </f:if>
    +                                        <a class="btn btn-default t3js-modal-trigger"
    +                                            href="{be:moduleLink(route:'tce_db', query:'cmd[sys_redirect][{redirect.uid}][delete]=1', arguments:'{redirect: returnUrl}')}"
    +                                            title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')}"
    +                                            data-severity="warning"
    +                                            data-title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title')}"
    +                                            data-bs-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}"
    +                                            data-button-close-text="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}">
    +                                            <core:icon identifier="actions-delete" />
                                             </a>
                                         </f:then>
                                         <f:else>
    -                                        <a
    -                                            class="btn btn-default"
    -                                            href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=1', arguments:'{redirect: returnUrl}')}"
    -                                            title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide')}"
    -                                        >
    -                                            <core:icon identifier="actions-edit-hide" />
    -                                        </a>
    +                                        <span class="btn btn-default disabled"><core:icon identifier="empty-empty" /></span>
                                         </f:else>
                                     </f:if>
    -                                <a class="btn btn-default t3js-modal-trigger"
    -                                    href="{be:moduleLink(route:'tce_db', query:'cmd[sys_redirect][{redirect.uid}][delete]=1', arguments:'{redirect: returnUrl}')}"
    -                                    title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')}"
    -                                    data-severity="warning"
    -                                    data-title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title')}"
    -                                    data-bs-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}"
    -                                    data-button-close-text="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}">
    -                                    <core:icon identifier="actions-delete" />
    -                                </a>
                                 </div>
                                 <div class="btn-group dropdown" role="group">
                                     <f:if condition="{redirect.is_regexp} || ({redirect.source_host} === '*')">
    @@ -311,7 +340,7 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                                                 </f:if>
                                             </ul>
                                         </f:then>
    -                                    <f:else>
    +                                    <f:else if="{canEditRedirects}">
                                             <span class="btn btn-default disabled"><core:icon identifier="empty-empty" /></span>
                                         </f:else>
                                     </f:if>
    
  • typo3/sysext/redirects/Tests/Functional/Fixtures/be_users.csv+3 2 modified
    @@ -1,4 +1,5 @@
     "be_users"
    -,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","TSconfig","lastlogin","workspace_id"
    +,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","TSconfig","lastlogin","workspace_id","db_mountpoints"
     # The password is "password"
    -,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,,1371033743,0
    +,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,,1371033743,0,
    +,2,0,1366642540,"editor","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1366642540,1,0,,1371033743,0,"13"
    
  • typo3/sysext/redirects/Tests/Functional/Fixtures/pages.csv+4 0 added
    @@ -0,0 +1,4 @@
    +"pages"
    +,"uid","pid","sorting","title","deleted","perms_everybody","TSconfig"
    +,13,0,128,"Root",0,15,""
    +,14,0,256,"Root 2",0,15,""
    
  • typo3/sysext/redirects/Tests/Functional/FormDataProvider/ValuePickerItemDataProviderTest.php+128 0 added
    @@ -0,0 +1,128 @@
    +<?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\Redirects\Tests\Functional\FormDataProvider;
    +
    +use PHPUnit\Framework\Attributes\Test;
    +use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
    +use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +final class ValuePickerItemDataProviderTest extends FunctionalTestCase
    +{
    +    use SiteBasedTestTrait;
    +
    +    protected const LANGUAGE_PRESETS = [
    +        'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en-US'],
    +    ];
    +
    +    protected array $coreExtensionsToLoad = [
    +        'redirects',
    +    ];
    +
    +    private array $sysRedirectResultSet = [
    +        'tableName' => 'sys_redirect',
    +        'processedTca' => [
    +            'columns' => [
    +                'source_host' => [
    +                    'config' => [
    +                        'valuePicker' => [
    +                            'items' => [],
    +                        ],
    +                    ],
    +                ],
    +            ],
    +        ],
    +    ];
    +
    +    private ValuePickerItemDataProvider $subject;
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/be_users.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/pages.csv');
    +
    +        $this->setUpBackendUser(1);
    +
    +        $this->subject = $this->get(ValuePickerItemDataProvider::class);
    +    }
    +
    +    #[Test]
    +    public function addDataDoesNothingIfNoRedirectDataGiven(): void
    +    {
    +        $result = [
    +            'tableName' => 'tt_content',
    +        ];
    +
    +        self::assertSame($result, $this->subject->addData($result));
    +    }
    +
    +    #[Test]
    +    public function addDataAddsAllHostsAsKeyAndValueToRedirectValuePickerAsAdmin(): void
    +    {
    +        $this->createSites();
    +
    +        $expected = $this->sysRedirectResultSet;
    +        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    +            ['bar.test', 'bar.test'],
    +            ['foo.test', 'foo.test'],
    +        ];
    +
    +        self::assertSame($expected, $this->subject->addData($this->sysRedirectResultSet));
    +    }
    +
    +    #[Test]
    +    public function addDataAddsAllAvailableHostsAsKeyAndValueToRedirectValuePickerAsNonAdmin(): void
    +    {
    +        $this->createSites();
    +        $this->setUpBackendUser(2);
    +
    +        $expected = $this->sysRedirectResultSet;
    +        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    +            ['bar.test', 'bar.test'],
    +        ];
    +
    +        self::assertSame($expected, $this->subject->addData($this->sysRedirectResultSet));
    +    }
    +
    +    #[Test]
    +    public function addDataDoesNotChangeResultSetIfNoSitesAreFound(): void
    +    {
    +        self::assertSame($this->sysRedirectResultSet, $this->subject->addData($this->sysRedirectResultSet));
    +    }
    +
    +    private function createSites(): void
    +    {
    +        $this->writeSiteConfiguration(
    +            'bar',
    +            $this->buildSiteConfiguration(13, 'https://bar.test/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://bar.test/'),
    +            ],
    +        );
    +
    +        $this->writeSiteConfiguration(
    +            'foo',
    +            $this->buildSiteConfiguration(14, 'https://foo.test/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://foo.test/'),
    +            ],
    +        );
    +    }
    +}
    
  • typo3/sysext/redirects/Tests/Functional/Repository/Fixtures/DemandFixture.php+29 0 added
    @@ -0,0 +1,29 @@
    +<?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\Redirects\Tests\Functional\Repository\Fixtures;
    +
    +use TYPO3\CMS\Redirects\Repository\Demand;
    +
    +class DemandFixture extends Demand
    +{
    +    public function setLimit(int $limit): self
    +    {
    +        $this->limit = $limit;
    +        return $this;
    +    }
    +}
    
  • typo3/sysext/redirects/Tests/Functional/Repository/Fixtures/sys_redirect.csv+2 2 renamed
    @@ -1,8 +1,8 @@
     sys_redirect,,,,,,,,,,,
     ,uid,pid,createdon,deleted,disabled,source_host,source_path,target,target_statuscode,hitcount,protected,creation_type
     ,1,0,2147483647,0,0,*,/foo,https://example.com/bar,301,0,1,0
    -,2,0,1,0,0,*,/foo,https://example.com/bar,302,10,0,0
    +,2,0,1,0,0,*,/foo,t3://page?uid=1,302,10,0,0
     ,3,0,1,0,0,foo.com,/foo/bar,https://example.com/bar,303,0,0,1
     ,4,0,1,0,0,foo.com,/foo/baz,https://example.com/bar,304,0,0,0
    -,5,0,1,0,0,bar.com,/bar,https://example.com/bar,305,0,0,0
    +,5,0,1,0,0,bar.com,/bar,https://example.com/bar,305,0,0,1
     ,6,0,2147483647,0,0,bar.com,/bar/foo,https://example.com/foo,305,0,0,0
    
  • typo3/sysext/redirects/Tests/Functional/Repository/RedirectRepositoryTest.php+143 8 modified
    @@ -20,14 +20,48 @@
     use PHPUnit\Framework\Attributes\DataProvider;
     use PHPUnit\Framework\Attributes\Test;
     use TYPO3\CMS\Core\Database\ConnectionPool;
    +use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
     use TYPO3\CMS\Redirects\Repository\Demand;
     use TYPO3\CMS\Redirects\Repository\RedirectRepository;
    +use TYPO3\CMS\Redirects\Tests\Functional\Repository\Fixtures\DemandFixture;
     use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
     
     final class RedirectRepositoryTest extends FunctionalTestCase
     {
    +    use SiteBasedTestTrait;
    +
    +    protected const LANGUAGE_PRESETS = [
    +        'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en-US'],
    +    ];
    +
         protected array $coreExtensionsToLoad = ['redirects'];
     
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/be_users.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/pages.csv');
    +
    +        $this->setUpBackendUser(1);
    +
    +        $this->writeSiteConfiguration(
    +            'bar',
    +            $this->buildSiteConfiguration(13, 'https://bar.com/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://bar.com/'),
    +            ],
    +        );
    +
    +        $this->writeSiteConfiguration(
    +            'foo',
    +            $this->buildSiteConfiguration(14, 'https://foo.com/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://foo.com/'),
    +            ],
    +        );
    +    }
    +
         public static function demandProvider(): array
         {
             $allRecordCount = 6;
    @@ -85,7 +119,7 @@ public static function demandProvider(): array
                 'demand with creation type "manually created"' => [
                     self::getDemand(0, [], [], '', 1),
                     $allRecordCount,
    -                $allRecordCount - 1,
    +                $allRecordCount - 2,
                 ],
             ];
         }
    @@ -95,10 +129,10 @@ public static function demandProvider(): array
         public function removeByDemandWorks(Demand $demand, int $redirectBeforeCleanup, int $redirectAfterCleanup): void
         {
             self::assertSame(0, $this->getRedirectCount());
    -        $this->importCSVDataSet(__DIR__ . '/Fixtures/RedirectRepositoryTest_redirects.csv');
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
     
             self::assertSame($redirectBeforeCleanup, $this->getRedirectCount());
    -        $repository = new RedirectRepository();
    +        $repository = $this->get(RedirectRepository::class);
             $repository->removeByDemand($demand);
             self::assertSame($redirectAfterCleanup, $this->getRedirectCount());
         }
    @@ -142,11 +176,11 @@ public static function countRedirectsByDemandCountsCorrectlyDataProvider(): iter
     
             yield 'demand with target' => [
                 new Demand(target: 'https://example.com/bar'),
    -            5,
    +            4,
             ];
             yield 'demand with creation type "manually created"' => [
                 new Demand(creationType: 1),
    -            1,
    +            2,
             ];
             yield 'demand with protected state' => [
                 new Demand(protected: 1),
    @@ -158,14 +192,115 @@ public static function countRedirectsByDemandCountsCorrectlyDataProvider(): iter
         #[Test]
         public function countRedirectsByDemandCountsCorrectly(Demand $demand, int $expectedCount): void
         {
    -        $this->importCSVDataSet(__DIR__ . '/Fixtures/RedirectRepositoryTest_redirects.csv');
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
    +
    +        $repository = $this->get(RedirectRepository::class);
    +        $redirectsCount = $repository->countRedirectsByDemand($demand);
    +
    +        self::assertSame($expectedCount, $redirectsCount);
    +    }
    +
    +    public static function countRedirectsByDemandRespectsUserPermissionsDataProvider(): iterable
    +    {
    +        yield 'default demand' => [
    +            new Demand(),
    +            4,
    +        ];
    +
    +        yield 'configuration with hitCount' => [
    +            new Demand(maxHits: 2),
    +            3,
    +        ];
    +
    +        yield 'configuration with statusCode 302' => [
    +            new Demand(statusCodes: [302]),
    +            1,
    +        ];
    +
    +        yield 'demand with statusCode 302, 303' => [
    +            new Demand(statusCodes: [302, 303]),
    +            1,
    +        ];
    +
    +        yield 'demand with domain' => [
    +            new Demand(sourceHosts: ['bar.com']),
    +            2,
    +        ];
    +
    +        yield 'demand with domains' => [
    +            new Demand(sourceHosts: ['foo.com', 'bar.com']),
    +            2,
    +        ];
    +
    +        yield 'demand with path' => [
    +            new Demand(sourcePath: '/foo'),
    +            3,
    +        ];
    +
    +        yield 'demand with target' => [
    +            new Demand(target: 'https://example.com/bar'),
    +            2,
    +        ];
    +        yield 'demand with creation type "manually created"' => [
    +            new Demand(creationType: 1),
    +            1,
    +        ];
    +        yield 'demand with protected state' => [
    +            new Demand(protected: 1),
    +            1,
    +        ];
    +    }
    +
    +    #[DataProvider('countRedirectsByDemandRespectsUserPermissionsDataProvider')]
    +    #[Test]
    +    public function countRedirectsByDemandRespectsUserPermissions(Demand $demand, int $expectedCount): void
    +    {
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
    +
    +        $backendUser = $this->setUpBackendUser(2);
    +        $backendUser->userGroupsUID = [1];
    +        $backendUser->groupData['webmounts'] = '13';
     
    -        $repository = new RedirectRepository();
    -        $redirectsCount = $repository->countRedirectsByByDemand($demand);
    +        $repository = $this->get(RedirectRepository::class);
    +        $redirectsCount = $repository->countRedirectsByDemand($demand);
     
             self::assertSame($expectedCount, $redirectsCount);
         }
     
    +    public static function filteredRedirectsArePaginatedCorrectlyDataProvider(): iterable
    +    {
    +        yield 'first page' => [
    +            (new DemandFixture(page: 1))->setLimit(2),
    +            [1, 2],
    +        ];
    +        // the second page skips uids 3 and 4, as they are not in web-mount 13
    +        yield 'second page' => [
    +            (new DemandFixture(page: 2))->setLimit(2),
    +            [5, 6],
    +        ];
    +        // the third page does not have any more redirects in web-mount 13
    +        yield 'third page' => [
    +            (new DemandFixture(page: 3))->setLimit(2),
    +            [],
    +        ];
    +    }
    +
    +    #[DataProvider('filteredRedirectsArePaginatedCorrectlyDataProvider')]
    +    #[Test]
    +    public function filteredRedirectsArePaginatedCorrectly(Demand $demand, array $expectation): void
    +    {
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
    +
    +        $backendUser = $this->setUpBackendUser(2);
    +        $backendUser->userGroupsUID = [1];
    +        $backendUser->groupData['webmounts'] = '13';
    +
    +        $repository = $this->get(RedirectRepository::class);
    +        $redirects = $repository->findRedirectsByDemand($demand);
    +        $redirectUids = array_column($redirects, 'uid');
    +        self::assertSame($expectation, $redirectUids);
    +    }
    +
         private function getRedirectCount(): int
         {
             $queryBuilder = $this->get(ConnectionPool::class)
    
  • typo3/sysext/redirects/Tests/Unit/FormDataProvider/ValuePickerItemDataProviderTest.php+0 85 removed
    @@ -1,85 +0,0 @@
    -<?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\Redirects\Tests\Unit\FormDataProvider;
    -
    -use PHPUnit\Framework\Attributes\Test;
    -use TYPO3\CMS\Core\Site\Entity\Site;
    -use TYPO3\CMS\Core\Site\SiteFinder;
    -use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
    -use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
    -
    -final class ValuePickerItemDataProviderTest extends UnitTestCase
    -{
    -    protected array $sysRedirectResultSet = [
    -        'tableName' => 'sys_redirect',
    -        'processedTca' => [
    -            'columns' => [
    -                'source_host' => [
    -                    'config' => [
    -                        'valuePicker' => [
    -                            'items' => [],
    -                        ],
    -                    ],
    -                ],
    -            ],
    -        ],
    -    ];
    -
    -    #[Test]
    -    public function addDataDoesNothingIfNoRedirectDataGiven(): void
    -    {
    -        $result = [
    -            'tableName' => 'tt_content',
    -        ];
    -
    -        $siteFinderMock = $this->getMockBuilder(SiteFinder::class)->disableOriginalConstructor()->getMock();
    -        $valuePickerItemDataProvider = new ValuePickerItemDataProvider($siteFinderMock);
    -        $actualResult = $valuePickerItemDataProvider->addData($result);
    -        self::assertSame($result, $actualResult);
    -    }
    -
    -    #[Test]
    -    public function addDataAddsHostsAsKeyAndValueToRedirectValuePicker(): void
    -    {
    -        // no results for now
    -        $siteFinderMock = $this->getMockBuilder(SiteFinder::class)->disableOriginalConstructor()->getMock();
    -        $siteFinderMock->expects($this->once())->method('getAllSites')->willReturn([
    -            new Site('bar', 13, ['base' => 'bar.test']),
    -            new Site('foo', 14, ['base' => 'foo.test']),
    -        ]);
    -        $valuePickerItemDataProvider = new ValuePickerItemDataProvider($siteFinderMock);
    -        $actualResult = $valuePickerItemDataProvider->addData($this->sysRedirectResultSet);
    -        $expected = $this->sysRedirectResultSet;
    -        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    -            ['bar.test', 'bar.test'],
    -            ['foo.test', 'foo.test'],
    -        ];
    -        self::assertSame($expected, $actualResult);
    -    }
    -
    -    #[Test]
    -    public function addDataDoesNotChangeResultSetIfNoSitesAreFound(): void
    -    {
    -        $siteFinderMock = $this->getMockBuilder(SiteFinder::class)->disableOriginalConstructor()->getMock();
    -        $siteFinderMock->expects($this->once())->method('getAllSites')->willReturn([]);
    -        $valuePickerItemDataProvider = new ValuePickerItemDataProvider($siteFinderMock);
    -        $actualResult = $valuePickerItemDataProvider->addData($this->sysRedirectResultSet);
    -
    -        self::assertSame($this->sysRedirectResultSet, $actualResult);
    -    }
    -}
    
fbbae3b9a40d

[SECURITY] Prevent unauthorized access to resources in redirects module

https://github.com/TYPO3/typo3Elias HäußlerJan 13, 2026via ghsa
20 files changed · +1002 210
  • typo3/sysext/redirects/Classes/Controller/ManagementController.php+41 16 modified
    @@ -25,6 +25,7 @@
     use TYPO3\CMS\Backend\Template\Components\MultiRecordSelection\Action;
     use TYPO3\CMS\Backend\Template\ModuleTemplate;
     use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
     use TYPO3\CMS\Core\Configuration\Features;
     use TYPO3\CMS\Core\Imaging\Icon;
     use TYPO3\CMS\Core\Imaging\IconFactory;
    @@ -62,6 +63,10 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
             );
             $this->registerDocHeaderButtons($view, $request->getAttribute('normalizedParams')->getRequestUri());
     
    +        if (!$this->canListRedirects()) {
    +            return $view->renderResponse('Management/Overview');
    +        }
    +
             $event = $this->eventDispatcher->dispatch(
                 new ModifyRedirectManagementControllerViewDataEvent(
                     $demand,
    @@ -77,6 +82,7 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
             $requestUri = $request->getAttribute('normalizedParams')->getRequestUri();
             $languageService = $this->getLanguageService();
             $view = $event->getView();
    +        $hasEditPermissions = $this->canEditRedirects();
             $view->assignMultiple([
                 'redirects' => $event->getRedirects(),
                 'hosts' => $event->getHosts(),
    @@ -85,7 +91,9 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
                 'demand' => $event->getDemand(),
                 'showHitCounter' => $event->getShowHitCounter(),
                 'pagination' => $this->preparePagination($event->getDemand()),
    -            'actions' => [
    +            'canEditRedirects' => $hasEditPermissions,
    +            'canListRedirects' => true,
    +            'actions' => $hasEditPermissions ? [
                     new Action(
                         'edit',
                         [
    @@ -110,17 +118,27 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
                         'actions-edit-delete',
                         'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'
                     ),
    -            ],
    +            ] : [],
             ]);
             return $view->renderResponse('Management/Overview');
         }
     
    +    protected function canListRedirects(): bool
    +    {
    +        return $this->getBackendUser()->check('tables_select', 'sys_redirect');
    +    }
    +
    +    protected function canEditRedirects(): bool
    +    {
    +        return $this->getBackendUser()->check('tables_modify', 'sys_redirect');
    +    }
    +
         /**
          * Prepares information for the pagination of the module
          */
         protected function preparePagination(Demand $demand): array
         {
    -        $count = $this->redirectRepository->countRedirectsByByDemand($demand);
    +        $count = $this->redirectRepository->countRedirectsByDemand($demand);
             $numberOfPages = ceil($count / $demand->getLimit());
             $endRecord = $demand->getOffset() + $demand->getLimit();
             if ($endRecord > $count) {
    @@ -153,19 +171,21 @@ protected function registerDocHeaderButtons(ModuleTemplate $view, string $reques
             $buttonBar = $view->getDocHeaderComponent()->getButtonBar();
     
             // Create new
    -        $newRecordButton = $buttonBar->makeLinkButton()
    -            ->setHref((string)$this->uriBuilder->buildUriFromRoute(
    -                'record_edit',
    -                [
    -                    'edit' => ['sys_redirect' => ['new'],
    -                    ],
    -                    'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('site_redirects'),
    -                ]
    -            ))
    -            ->setTitle($languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_add_text'))
    -            ->setShowLabelText(true)
    -            ->setIcon($this->iconFactory->getIcon('actions-plus', Icon::SIZE_SMALL));
    -        $buttonBar->addButton($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10);
    +        if ($this->canEditRedirects()) {
    +            $newRecordButton = $buttonBar->makeLinkButton()
    +                ->setHref((string)$this->uriBuilder->buildUriFromRoute(
    +                    'record_edit',
    +                    [
    +                        'edit' => ['sys_redirect' => ['new'],
    +                        ],
    +                        'returnUrl' => (string)$this->uriBuilder->buildUriFromRoute('site_redirects'),
    +                    ]
    +                ))
    +                ->setTitle($languageService->sL('LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_add_text'))
    +                ->setShowLabelText(true)
    +                ->setIcon($this->iconFactory->getIcon('actions-plus', Icon::SIZE_SMALL));
    +            $buttonBar->addButton($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10);
    +        }
     
             // Reload
             $reloadButton = $buttonBar->makeLinkButton()
    @@ -185,4 +205,9 @@ protected function getLanguageService(): LanguageService
         {
             return $GLOBALS['LANG'];
         }
    +
    +    protected function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
     }
    
  • typo3/sysext/redirects/Classes/Data/SourceHostProvider.php+105 0 added
    @@ -0,0 +1,105 @@
    +<?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\Redirects\Data;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autowire;
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
    +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
    +use TYPO3\CMS\Core\Exception\SiteNotFoundException;
    +use TYPO3\CMS\Core\Site\SiteFinder;
    +
    +/**
    + * Data provider for source hosts in sys_redirect records
    + *
    + * @internal
    + */
    +final class SourceHostProvider
    +{
    +    public function __construct(
    +        private readonly SiteFinder $siteFinder,
    +        #[Autowire(service: 'cache.runtime')]
    +        private readonly FrontendInterface $cache,
    +    ) {}
    +
    +    /**
    +     * Get all available hosts for current backend user.
    +     *
    +     * @return list<non-empty-string>
    +     */
    +    public function getHosts(bool $includeWildcard = false): array
    +    {
    +        $cacheIdentifier = 'RedirectsSourceHostProvider' . ($includeWildcard ? '-wildcard' : '');
    +
    +        if (!$this->cache->has($cacheIdentifier)) {
    +            $this->cache->set($cacheIdentifier, $this->filterAllowedSourceHosts($includeWildcard));
    +        }
    +
    +        return $this->cache->get($cacheIdentifier);
    +    }
    +
    +    /**
    +     * @return list<non-empty-string>
    +     */
    +    private function filterAllowedSourceHosts(bool $includeWildcard): array
    +    {
    +        $backendUser = $this->getBackendUser();
    +
    +        if ($includeWildcard) {
    +            $hosts = ['*'];
    +        } else {
    +            $hosts = [];
    +        }
    +
    +        if ($backendUser->isAdmin()) {
    +            foreach ($this->siteFinder->getAllSites() as $site) {
    +                foreach ($site->getAllLanguages() as $language) {
    +                    $host = $language->getBase()->getHost();
    +
    +                    if ($host !== '' && !in_array($host, $hosts, true)) {
    +                        $hosts[] = $host;
    +                    }
    +                }
    +            }
    +        } else {
    +            foreach ($backendUser->getWebmounts() as $pageId) {
    +                try {
    +                    $site = $this->siteFinder->getSiteByPageId($pageId);
    +
    +                    foreach ($site->getAvailableLanguages($backendUser) as $language) {
    +                        $host = $language->getBase()->getHost();
    +
    +                        if ($host !== '' && !in_array($host, $hosts, true)) {
    +                            $hosts[] = $host;
    +                        }
    +                    }
    +                } catch (SiteNotFoundException) {
    +                    // Ignore unavailable sites
    +                }
    +            }
    +        }
    +
    +        sort($hosts, SORT_NATURAL);
    +
    +        return $hosts;
    +    }
    +
    +    private function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/EventListener/RedirectEditPermissionGuard.php+42 0 added
    @@ -0,0 +1,42 @@
    +<?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\Redirects\EventListener;
    +
    +use TYPO3\CMS\Backend\Form\Event\ModifyEditFormUserAccessEvent;
    +use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
    +
    +/**
    + * @internal
    + */
    +final class RedirectEditPermissionGuard
    +{
    +    public function __construct(
    +        private readonly RedirectPermissionGuard $redirectPermissionGuard,
    +    ) {}
    +
    +    public function __invoke(ModifyEditFormUserAccessEvent $event): void
    +    {
    +        if ($event->getTableName() !== 'sys_redirect' || $event->getCommand() === 'new') {
    +            return;
    +        }
    +
    +        if (!$this->redirectPermissionGuard->isAllowedRedirect($event->getDatabaseRow())) {
    +            $event->denyUserAccess();
    +        }
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/FormDataProvider/ValuePickerItemDataProvider.php+6 31 modified
    @@ -18,24 +18,17 @@
     namespace TYPO3\CMS\Redirects\FormDataProvider;
     
     use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
    -use TYPO3\CMS\Core\Site\SiteFinder;
    -use TYPO3\CMS\Core\Utility\GeneralUtility;
    +use TYPO3\CMS\Redirects\Data\SourceHostProvider;
     
     /**
      * Inject available domain hosts into a valuepicker form
      * @internal
      */
    -class ValuePickerItemDataProvider implements FormDataProviderInterface
    +final class ValuePickerItemDataProvider implements FormDataProviderInterface
     {
    -    /**
    -     * @var SiteFinder
    -     */
    -    protected $siteFinder;
    -
    -    public function __construct(?SiteFinder $siteFinder = null)
    -    {
    -        $this->siteFinder = $siteFinder ?? GeneralUtility::makeInstance(SiteFinder::class);
    -    }
    +    public function __construct(
    +        private readonly SourceHostProvider $sourceHostProvider,
    +    ) {}
     
         /**
          * Add sys_domains into $result data array
    @@ -46,7 +39,7 @@ public function __construct(?SiteFinder $siteFinder = null)
         public function addData(array $result): array
         {
             if ($result['tableName'] === 'sys_redirect' && isset($result['processedTca']['columns']['source_host'])) {
    -            $domains = $this->getHosts();
    +            $domains = $this->sourceHostProvider->getHosts();
                 foreach ($domains as $domain) {
                     $result['processedTca']['columns']['source_host']['config']['valuePicker']['items'][] =
                         [
    @@ -57,22 +50,4 @@ public function addData(array $result): array
             }
             return $result;
         }
    -
    -    /**
    -     * Get all hosts from sites
    -     *
    -     * @return string[] domain records
    -     */
    -    protected function getHosts(): array
    -    {
    -        $domains = [];
    -        foreach ($this->siteFinder->getAllSites() as $site) {
    -            foreach ($site->getAllLanguages() as $language) {
    -                $domains[] = $language->getBase()->getHost();
    -            }
    -        }
    -        $domains = array_unique($domains);
    -        sort($domains, SORT_NATURAL);
    -        return $domains;
    -    }
     }
    
  • typo3/sysext/redirects/Classes/Hooks/DataHandlerPermissionGuardHook.php+75 0 added
    @@ -0,0 +1,75 @@
    +<?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\Redirects\Hooks;
    +
    +use TYPO3\CMS\Core\DataHandling\DataHandler;
    +use TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
    +use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
    +use TYPO3\CMS\Core\Utility\MathUtility;
    +use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
    +
    +/**
    + * @internal This class is a specific TYPO3 hook implementation and is not part of the Public TYPO3 API.
    + */
    +final class DataHandlerPermissionGuardHook
    +{
    +    public function __construct(
    +        private readonly RedirectPermissionGuard $redirectPermissionGuard,
    +    ) {}
    +
    +    /**
    +     * @param array<string, mixed> $incomingFieldArray
    +     * @param-out array<string, mixed>|null $incomingFieldArray
    +     */
    +    public function processDatamap_preProcessFieldArray(
    +        array &$incomingFieldArray,
    +        string $table,
    +        string|int $id,
    +        DataHandler $dataHandler,
    +    ): void {
    +        if ($table === 'sys_redirect' && !$this->redirectPermissionGuard->isAllowedRedirect($incomingFieldArray)) {
    +            // Reset incoming field array to avoid further processing in DataHandler
    +            // in case the given source host is not allowed for the current user
    +            $incomingFieldArray = null;
    +
    +            if (MathUtility::canBeInterpretedAsInteger($id)) {
    +                // Record update
    +                $dataHandler->log(
    +                    'sys_redirect',
    +                    (int)$id,
    +                    SystemLogDatabaseAction::UPDATE,
    +                    0,
    +                    SystemLogErrorClassification::USER_ERROR,
    +                    'Attempt to modify sys_redirect record "%d" is disallowed',
    +                    -1,
    +                    [$id],
    +                );
    +            } else {
    +                // New record
    +                $dataHandler->log(
    +                    'sys_redirect',
    +                    0,
    +                    SystemLogDatabaseAction::INSERT,
    +                    0,
    +                    SystemLogErrorClassification::USER_ERROR,
    +                    'Attempt to create a new sys_redirect record is disallowed',
    +                );
    +            }
    +        }
    +    }
    +}
    
  • typo3/sysext/redirects/Classes/Repository/RedirectRepository.php+165 11 modified
    @@ -17,35 +17,139 @@
     
     namespace TYPO3\CMS\Redirects\Repository;
     
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
     use TYPO3\CMS\Core\Database\Connection;
     use TYPO3\CMS\Core\Database\ConnectionPool;
     use TYPO3\CMS\Core\Database\Query\QueryBuilder;
     use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
     use TYPO3\CMS\Core\Utility\GeneralUtility;
    +use TYPO3\CMS\Redirects\Security\RedirectPermissionGuard;
     
     /**
      * Class for accessing redirect records from the database
      * @internal
      */
     class RedirectRepository
     {
    +    public function __construct(
    +        private readonly RedirectPermissionGuard $redirectPermissionGuard,
    +    ) {}
    +
         /**
          * Used within the backend module, which also includes the hidden records, but never deleted records.
          */
         public function findRedirectsByDemand(Demand $demand): array
         {
    -        return $this->getQueryBuilderForDemand($demand)
    -            ->setMaxResults($demand->getLimit())
    -            ->setFirstResult($demand->getOffset())
    +        // Fast path for admin users - use SQL pagination directly
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return $this->getQueryBuilderForDemand($demand)
    +                ->select('*')
    +                ->setMaxResults($demand->getLimit())
    +                ->setFirstResult($demand->getOffset())
    +                ->executeQuery()
    +                ->fetchAllAssociative();
    +        }
    +
    +        // Non-admin: Two-phase fetch without caching
    +        // Phase 1: Fetch minimal fields for ALL matching records with SQL source host filtering
    +        $queryBuilder = $this->getQueryBuilderForDemand($demand);
    +
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return [];
    +        }
    +
    +        $redirects = $queryBuilder
    +            ->executeQuery()
    +            ->fetchAllAssociative();
    +
    +        // Phase 2: Apply PHP target permission filtering
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
    +
    +        // Phase 3: Get UIDs for current page (applying pagination in PHP)
    +        $filteredUids = array_column($filteredRedirects, 'uid');
    +        $currentPageUids = array_slice($filteredUids, $demand->getOffset(), $demand->getLimit());
    +
    +        if ($currentPageUids === []) {
    +            return [];
    +        }
    +
    +        // Phase 4: Fetch full records only for the current page
    +        $queryBuilder = $this->getQueryBuilder();
    +        return $queryBuilder
    +            ->select('*')
    +            ->from('sys_redirect')
    +            ->where(
    +                $queryBuilder->expr()->in(
    +                    'uid',
    +                    $queryBuilder->createNamedParameter($currentPageUids, Connection::PARAM_INT_ARRAY)
    +                )
    +            )
    +            ->orderBy($demand->getOrderField(), $demand->getOrderDirection())
                 ->executeQuery()
                 ->fetchAllAssociative();
         }
     
    -    public function countRedirectsByByDemand(Demand $demand): int
    +    public function countRedirectsByDemand(Demand $demand): int
         {
    -        return (int)$this->getQueryBuilderForDemand($demand, true)
    +        // Fast path for admin users - use SQL COUNT
    +        if ($this->getBackendUser()->isAdmin()) {
    +            $queryBuilder = $this->getQueryBuilderForDemand($demand, true);
    +            return (int)$queryBuilder
    +                ->count('uid')
    +                ->executeQuery()
    +                ->fetchOne();
    +        }
    +
    +        // Non-admin: Fetch minimal fields with SQL source host filtering
    +        $queryBuilder = $this->getQueryBuilderForDemand($demand);
    +
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return 0;
    +        }
    +
    +        $redirects = $queryBuilder
                 ->executeQuery()
    -            ->fetchOne();
    +            ->fetchAllAssociative();
    +
    +        // Apply PHP target permission filtering and count
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
    +
    +        return count($filteredRedirects);
    +    }
    +
    +    /**
    +     * Adds source host constraint to query for non-admin users
    +     * This significantly reduces the dataset before PHP filtering
    +     * @throws StopQueryException
    +     */
    +    protected function addSourceHostConstraint(QueryBuilder $queryBuilder): void
    +    {
    +        // Admin users see all hosts
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return;
    +        }
    +
    +        // Get allowed hosts for the current user
    +        $allowedHosts = $this->redirectPermissionGuard->getAllowedHosts();
    +        if (empty($allowedHosts)) {
    +            throw new StopQueryException('No allowed hosts found for current user', 1764702053);
    +        }
    +
    +        $queryBuilder->andWhere(
    +            $queryBuilder->expr()->in(
    +                'source_host',
    +                $queryBuilder->createNamedParameter($allowedHosts, Connection::PARAM_STR_ARRAY)
    +            )
    +        );
    +    }
    +
    +    protected function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
         }
     
         /**
    @@ -56,9 +160,9 @@ protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQue
             $queryBuilder = $this->getQueryBuilder();
     
             if ($createCountQuery) {
    -            $queryBuilder->count('*');
    +            $queryBuilder->count('uid');
             } else {
    -            $queryBuilder->select('*');
    +            $queryBuilder->select('uid', 'source_host', 'target');
             }
     
             $queryBuilder->from('sys_redirect');
    @@ -68,12 +172,14 @@ protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQue
                     $demand->getOrderField(),
                     $demand->getOrderDirection()
                 );
    +
                 if ($demand->hasSecondaryOrdering()) {
                     $queryBuilder->addOrderBy($demand->getSecondaryOrderField());
                 }
             }
     
             $constraints = [];
    +
             if ($demand->hasSourceHosts()) {
                 $constraints[] = $queryBuilder->expr()->in(
                     'source_host',
    @@ -126,6 +232,7 @@ protected function getQueryBuilderForDemand(Demand $demand, bool $createCountQue
             if (!empty($constraints)) {
                 $queryBuilder->where(...$constraints);
             }
    +
             return $queryBuilder;
         }
     
    @@ -163,15 +270,53 @@ public function findCreationTypes(): array
             return $types;
         }
     
    +    /**
    +     * @return list<array<string, scalar|null>>
    +     */
         protected function getGroupedRows(string $field, string $as): array
         {
    -        return $this->getQueryBuilder()
    -            ->select(sprintf('%s as %s', $field, $as))
    +        // Admin: Direct SQL query
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return $this->getQueryBuilder()
    +                ->select(sprintf('%s as %s', $field, $as))
    +                ->from('sys_redirect')
    +                ->orderBy($field)
    +                ->groupBy($field)
    +                ->executeQuery()
    +                ->fetchAllAssociative();
    +        }
    +
    +        // Non-admin: Need to include source_host and target for filtering
    +        $fields = [$field];
    +        if ($field !== 'source_host') {
    +            $fields[] = 'source_host';
    +        }
    +        if ($field !== 'target') {
    +            $fields[] = 'target';
    +        }
    +
    +        $queryBuilder = $this->getQueryBuilder()
    +            ->select(...$fields)
                 ->from('sys_redirect')
                 ->orderBy($field)
    -            ->groupBy($field)
    +            ->groupBy(...$fields);
    +
    +        try {
    +            $this->addSourceHostConstraint($queryBuilder);
    +        } catch (StopQueryException) {
    +            return [];
    +        }
    +
    +        $redirects = $queryBuilder
                 ->executeQuery()
                 ->fetchAllAssociative();
    +
    +        $filteredRedirects = $this->sortOutInaccessibleRedirects($redirects);
    +
    +        return array_map(
    +            static fn(mixed $value) => [$as => $value],
    +            array_values(array_unique(array_column($filteredRedirects, $field))),
    +        );
         }
     
         protected function getQueryBuilder(): QueryBuilder
    @@ -228,4 +373,13 @@ public function removeByDemand(Demand $demand): void
     
             $queryBuilder->executeStatement();
         }
    +
    +    /**
    +     * @param list<non-empty-array> $redirects
    +     * @return list<non-empty-array>
    +     */
    +    protected function sortOutInaccessibleRedirects(array $redirects): array
    +    {
    +        return array_filter($redirects, $this->redirectPermissionGuard->isAllowedRedirect(...));
    +    }
     }
    
  • typo3/sysext/redirects/Classes/Repository/StopQueryException.php+24 0 added
    @@ -0,0 +1,24 @@
    +<?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\Redirects\Repository;
    +
    +/**
    + * Class signaling that the query should be stopped.
    + * @internal
    + */
    +class StopQueryException extends \RuntimeException {}
    
  • typo3/sysext/redirects/Classes/Security/RedirectPermissionGuard.php+121 0 added
    @@ -0,0 +1,121 @@
    +<?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\Redirects\Security;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autowire;
    +use TYPO3\CMS\Backend\Utility\BackendUtility;
    +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
    +use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
    +use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
    +use TYPO3\CMS\Core\LinkHandling\Exception\UnknownUrnException;
    +use TYPO3\CMS\Core\LinkHandling\LinkService;
    +use TYPO3\CMS\Core\Resource\FileInterface;
    +use TYPO3\CMS\Core\Type\Bitmask\Permission;
    +use TYPO3\CMS\Redirects\Data\SourceHostProvider;
    +
    +/**
    + * Security guard to validate access to sys_redirect records for the current backend user.
    + *
    + * @internal
    + */
    +final class RedirectPermissionGuard
    +{
    +    /**
    +     * @var list<non-empty-string>|null
    +     */
    +    private ?array $allowedHosts = null;
    +
    +    public function __construct(
    +        private readonly LinkService $linkService,
    +        private readonly SourceHostProvider $sourceHostProvider,
    +        #[Autowire(service: 'cache.runtime')]
    +        private readonly FrontendInterface $cache,
    +    ) {}
    +
    +    public function isAllowedRedirect(array $redirect): bool
    +    {
    +        if ($this->getBackendUser()->isAdmin()) {
    +            return true;
    +        }
    +
    +        return $this->isAllowedSourceHost($redirect['source_host'] ?? '')
    +            && $this->isAllowedTarget($redirect['target'] ?? '');
    +    }
    +
    +    public function getAllowedHosts(): array
    +    {
    +        $this->allowedHosts ??= $this->sourceHostProvider->getHosts(true);
    +
    +        return $this->allowedHosts;
    +    }
    +
    +    private function isAllowedSourceHost(string $host): bool
    +    {
    +        return in_array($host, $this->getAllowedHosts(), true);
    +    }
    +
    +    private function isAllowedTarget(string $target): bool
    +    {
    +        $cacheIdentifier = 'RedirectPermissionGuard-isAllowedTarget-' . md5($target);
    +
    +        if ($this->cache->has($cacheIdentifier)) {
    +            return $this->cache->get($cacheIdentifier);
    +        }
    +
    +        $result = true;
    +
    +        if (str_starts_with($target, 't3://')) {
    +            try {
    +                $resolvedLink = $this->linkService->resolveByStringRepresentation($target);
    +
    +                if ((int)($resolvedLink['pageuid'] ?? 0) > 0) {
    +                    $result = $this->canAccessPage((int)$resolvedLink['pageuid']);
    +                } elseif (($resolvedLink['file'] ?? null) instanceof FileInterface) {
    +                    $result = $this->canAccessFile($resolvedLink['file']);
    +                }
    +            } catch (UnknownUrnException|UnknownLinkHandlerException) {
    +            }
    +        }
    +
    +        $this->cache->set($cacheIdentifier, $result);
    +
    +        return $result;
    +    }
    +
    +    private function canAccessPage(int $pageUid): bool
    +    {
    +        $page = BackendUtility::getRecord('pages', $pageUid, '*', '', false);
    +
    +        // If the page does no longer exist, we allow access to the redirect
    +        if ($page === null) {
    +            return true;
    +        }
    +
    +        return $this->getBackendUser()->doesUserHaveAccess($page, Permission::PAGE_SHOW);
    +    }
    +
    +    private function canAccessFile(FileInterface $file): bool
    +    {
    +        return $file->getStorage()->checkFileActionPermission('read', $file);
    +    }
    +
    +    private function getBackendUser(): BackendUserAuthentication
    +    {
    +        return $GLOBALS['BE_USER'];
    +    }
    +}
    
  • typo3/sysext/redirects/Configuration/Services.yaml+11 0 modified
    @@ -19,6 +19,12 @@ services:
       TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook:
         public: true
     
    +  TYPO3\CMS\Redirects\Hooks\DataHandlerPermissionGuardHook:
    +    public: true
    +
    +  TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider:
    +    public: true
    +
       TYPO3\CMS\Redirects\Command\CheckIntegrityCommand:
         tags:
           - name: 'console.command'
    @@ -60,3 +66,8 @@ services:
           - name: event.listener
             identifier: 'redirects-add-page-type-zero-source'
             after: 'redirects-add-plain-slug-replacement-source'
    +
    +  TYPO3\CMS\Redirects\EventListener\RedirectEditPermissionGuard:
    +    tags:
    +      - name: event.listener
    +        identifier: redirect-edit-permission-guard
    
  • typo3/sysext/redirects/ext_localconf.php+2 0 modified
    @@ -6,6 +6,7 @@
     use TYPO3\CMS\Redirects\Evaluation\SourceHost;
     use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
     use TYPO3\CMS\Redirects\Hooks\DataHandlerCacheFlushingHook;
    +use TYPO3\CMS\Redirects\Hooks\DataHandlerPermissionGuardHook;
     use TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook;
     use TYPO3\CMS\Redirects\Hooks\DispatchNotificationHook;
     
    @@ -14,6 +15,7 @@
     // Rebuild cache in DataHandler on changing / inserting / adding redirect records
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc']['redirects'] = DataHandlerCacheFlushingHook::class . '->rebuildRedirectCacheIfNecessary';
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects'] = DataHandlerSlugUpdateHook::class;
    +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirectsAccessGuard'] = DataHandlerPermissionGuardHook::class;
     
     // Inject sys_domains into valuepicker form
     $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRecord']
    
  • typo3/sysext/redirects/Resources/Private/Language/locallang_module_redirect.xlf+6 1 modified
    @@ -32,7 +32,12 @@
     			<trans-unit id="redirect_create" resname="redirect_create">
     				<source>Create new redirect</source>
     			</trans-unit>
    -
    +			<trans-unit id="noAccessPermissions.title" resname="noAccessPermissions.title">
    +				<source>Access denied</source>
    +			</trans-unit>
    +			<trans-unit id="noAccessPermissions.message" resname="noAccessPermissions.message">
    +				<source>You are not allowed to list or modify redirects due to insufficient permissions.</source>
    +			</trans-unit>
     			<trans-unit id="redirect_not_found_with_filter.title" resname="redirect_not_found_with_filter.title">
     				<source>No redirects found!</source>
     			</trans-unit>
    
  • typo3/sysext/redirects/Resources/Private/Templates/Management/Overview.html+72 48 modified
    @@ -8,7 +8,20 @@
     <f:layout name="Module" />
     
     <f:section name="Content">
    +    <f:if condition="{canListRedirects}">
    +        <f:then>
    +            <f:render section="overview" arguments="{_all}" />
    +        </f:then>
    +        <f:else>
    +            <f:be.infobox state="{f:constant(name: 'TYPO3\CMS\Fluid\ViewHelpers\Be\InfoboxViewHelper::STATE_ERROR')}"
    +                message="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:noAccessPermissions.message')}"
    +                title="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:noAccessPermissions.title')}"
    +            />
    +        </f:else>
    +    </f:if>
    +</f:section>
     
    +<f:section name="overview">
         <f:be.pageRenderer
             includeJavaScriptModules="{
                 0: '@typo3/backend/modal.js',
    @@ -50,9 +63,11 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                     <f:else>
                         <f:be.infobox state="-1" title="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_not_found.title')}">
                             <p><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_not_found.message"/></p>
    -                        <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_redirect">
    -                            <f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_create"/>
    -                        </be:link.newRecord>
    +                        <f:if condition="{canEditRedirects}">
    +                            <be:link.newRecord returnUrl="{returnUrl}" class="btn btn-primary" table="sys_redirect">
    +                                <f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:redirect_create"/>
    +                            </be:link.newRecord>
    +                        </f:if>
                         </f:be.infobox>
                     </f:else>
                 </f:if>
    @@ -153,14 +168,21 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                                         <f:else><span><core:iconForRecord table="sys_redirect" row="{redirect}" /></span></f:else>
                                     </f:if>
                                 </f:alias>
    -                            <be:link.editRecord
    -                                returnUrl="{returnUrl}"
    -                                table="sys_redirect"
    -                                uid="{redirect.uid}"
    -                                title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}: {redirect.source_path}"
    -                            >
    -                                {redirect.source_path -> f:format.crop(maxCharacters:100)}
    -                            </be:link.editRecord>
    +                            <f:if condition="{canEditRedirects}">
    +                                <f:then>
    +                                    <be:link.editRecord
    +                                        returnUrl="{returnUrl}"
    +                                        table="sys_redirect"
    +                                        uid="{redirect.uid}"
    +                                        title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}: {redirect.source_path}"
    +                                    >
    +                                        {redirect.source_path -> f:format.crop(maxCharacters:100)}
    +                                    </be:link.editRecord>
    +                                </f:then>
    +                                <f:else>
    +                                    {redirect.source_path -> f:format.crop(maxCharacters:100)}
    +                                </f:else>
    +                            </f:if>
                             </td>
                             <td>
                                 <f:link.typolink
    @@ -216,44 +238,46 @@ <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_mod
                                             </f:link.external>
                                         </f:else>
                                     </f:if>
    -                                <be:link.editRecord
    -                                    returnUrl="{returnUrl}"
    -                                    class="btn btn-default"
    -                                    table="sys_redirect"
    -                                    uid="{redirect.uid}"
    -                                    title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}"
    -                                >
    -                                    <core:icon identifier="actions-open" />
    -                                </be:link.editRecord>
    -                                <f:if condition="{redirect.disabled} == 1">
    -                                    <f:then>
    -                                        <a
    -                                            class="btn btn-default"
    -                                            href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=0', arguments:'{redirect: returnUrl}')}"
    -                                            title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')}"
    -                                        >
    -                                            <core:icon identifier="actions-edit-unhide" />
    -                                        </a>
    -                                    </f:then>
    -                                    <f:else>
    -                                        <a
    -                                            class="btn btn-default"
    -                                            href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=1', arguments:'{redirect: returnUrl}')}"
    -                                            title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide')}"
    -                                        >
    -                                            <core:icon identifier="actions-edit-hide" />
    -                                        </a>
    -                                    </f:else>
    +                                <f:if condition="{canEditRedirects}">
    +                                    <be:link.editRecord
    +                                        returnUrl="{returnUrl}"
    +                                        class="btn btn-default"
    +                                        table="sys_redirect"
    +                                        uid="{redirect.uid}"
    +                                        title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}"
    +                                    >
    +                                        <core:icon identifier="actions-open" />
    +                                    </be:link.editRecord>
    +                                    <f:if condition="{redirect.disabled} == 1">
    +                                        <f:then>
    +                                            <a
    +                                                class="btn btn-default"
    +                                                href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=0', arguments:'{redirect: returnUrl}')}"
    +                                                title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')}"
    +                                            >
    +                                                <core:icon identifier="actions-edit-unhide" />
    +                                            </a>
    +                                        </f:then>
    +                                        <f:else>
    +                                            <a
    +                                                class="btn btn-default"
    +                                                href="{be:moduleLink(route:'tce_db', query:'data[sys_redirect][{redirect.uid}][disabled]=1', arguments:'{redirect: returnUrl}')}"
    +                                                title="{f:translate(key:'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:hide')}"
    +                                            >
    +                                                <core:icon identifier="actions-edit-hide" />
    +                                            </a>
    +                                        </f:else>
    +                                    </f:if>
    +                                    <a class="btn btn-default t3js-modal-trigger"
    +                                        href="{be:moduleLink(route:'tce_db', query:'cmd[sys_redirect][{redirect.uid}][delete]=1', arguments:'{redirect: returnUrl}')}"
    +                                        title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')}"
    +                                        data-severity="warning"
    +                                        data-title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title')}"
    +                                        data-bs-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}"
    +                                        data-button-close-text="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}">
    +                                        <core:icon identifier="actions-delete" />
    +                                    </a>
                                     </f:if>
    -                                <a class="btn btn-default t3js-modal-trigger"
    -                                    href="{be:moduleLink(route:'tce_db', query:'cmd[sys_redirect][{redirect.uid}][delete]=1', arguments:'{redirect: returnUrl}')}"
    -                                    title="{f:translate(key: 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete')}"
    -                                    data-severity="warning"
    -                                    data-title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title')}"
    -                                    data-bs-content="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}"
    -                                    data-button-close-text="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}">
    -                                    <core:icon identifier="actions-delete" />
    -                                </a>
                                 </div>
                             </td>
                         </tr>
    
  • typo3/sysext/redirects/Tests/Functional/Fixtures/be_users.csv+3 2 modified
    @@ -1,4 +1,5 @@
     "be_users"
    -,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","TSconfig","lastlogin","workspace_id"
    +,"uid","pid","tstamp","username","password","admin","disable","starttime","endtime","options","crdate","workspace_perms","deleted","TSconfig","lastlogin","workspace_id","db_mountpoints"
     # The password is "password"
    -,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,,1371033743,0
    +,1,0,1366642540,"admin","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",1,0,0,0,0,1366642540,1,0,,1371033743,0,
    +,2,0,1366642540,"editor","$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1",0,0,0,0,0,1366642540,1,0,,1371033743,0,"13"
    
  • typo3/sysext/redirects/Tests/Functional/Fixtures/pages.csv+4 0 added
    @@ -0,0 +1,4 @@
    +"pages"
    +,"uid","pid","sorting","title","deleted","perms_everybody","TSconfig"
    +,13,0,128,"Root",0,15,""
    +,14,0,256,"Root 2",0,15,""
    
  • typo3/sysext/redirects/Tests/Functional/FormDataProvider/ValuePickerItemDataProviderTest.php+128 0 added
    @@ -0,0 +1,128 @@
    +<?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\Redirects\Tests\Functional\FormDataProvider;
    +
    +use PHPUnit\Framework\Attributes\Test;
    +use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
    +use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +final class ValuePickerItemDataProviderTest extends FunctionalTestCase
    +{
    +    use SiteBasedTestTrait;
    +
    +    protected const LANGUAGE_PRESETS = [
    +        'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en-US'],
    +    ];
    +
    +    protected array $coreExtensionsToLoad = [
    +        'redirects',
    +    ];
    +
    +    private array $sysRedirectResultSet = [
    +        'tableName' => 'sys_redirect',
    +        'processedTca' => [
    +            'columns' => [
    +                'source_host' => [
    +                    'config' => [
    +                        'valuePicker' => [
    +                            'items' => [],
    +                        ],
    +                    ],
    +                ],
    +            ],
    +        ],
    +    ];
    +
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/be_users.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/pages.csv');
    +
    +        $this->setUpBackendUser(1);
    +    }
    +
    +    #[Test]
    +    public function addDataDoesNothingIfNoRedirectDataGiven(): void
    +    {
    +        $result = [
    +            'tableName' => 'tt_content',
    +        ];
    +
    +        $subject = $this->get(ValuePickerItemDataProvider::class);
    +        self::assertSame($result, $subject->addData($result));
    +    }
    +
    +    #[Test]
    +    public function addDataAddsAllHostsAsKeyAndValueToRedirectValuePickerAsAdmin(): void
    +    {
    +        $this->createSites();
    +
    +        $expected = $this->sysRedirectResultSet;
    +        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    +            ['bar.test', 'bar.test'],
    +            ['foo.test', 'foo.test'],
    +        ];
    +
    +        $subject = $this->get(ValuePickerItemDataProvider::class);
    +        self::assertSame($expected, $subject->addData($this->sysRedirectResultSet));
    +    }
    +
    +    #[Test]
    +    public function addDataAddsAllAvailableHostsAsKeyAndValueToRedirectValuePickerAsNonAdmin(): void
    +    {
    +        $this->createSites();
    +        $this->setUpBackendUser(2);
    +
    +        $expected = $this->sysRedirectResultSet;
    +        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    +            ['bar.test', 'bar.test'],
    +        ];
    +
    +        $subject = $this->get(ValuePickerItemDataProvider::class);
    +        self::assertSame($expected, $subject->addData($this->sysRedirectResultSet));
    +    }
    +
    +    #[Test]
    +    public function addDataDoesNotChangeResultSetIfNoSitesAreFound(): void
    +    {
    +        $subject = $this->get(ValuePickerItemDataProvider::class);
    +        self::assertSame($this->sysRedirectResultSet, $subject->addData($this->sysRedirectResultSet));
    +    }
    +
    +    private function createSites(): void
    +    {
    +        $this->writeSiteConfiguration(
    +            'bar',
    +            $this->buildSiteConfiguration(13, 'https://bar.test/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://bar.test/'),
    +            ],
    +        );
    +
    +        $this->writeSiteConfiguration(
    +            'foo',
    +            $this->buildSiteConfiguration(14, 'https://foo.test/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://foo.test/'),
    +            ],
    +        );
    +    }
    +}
    
  • typo3/sysext/redirects/Tests/Functional/Repository/Fixtures/DemandFixture.php+29 0 added
    @@ -0,0 +1,29 @@
    +<?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\Redirects\Tests\Functional\Repository\Fixtures;
    +
    +use TYPO3\CMS\Redirects\Repository\Demand;
    +
    +class DemandFixture extends Demand
    +{
    +    public function setLimit(int $limit): self
    +    {
    +        $this->limit = $limit;
    +        return $this;
    +    }
    +}
    
  • typo3/sysext/redirects/Tests/Functional/Repository/Fixtures/RedirectRepositoryTest_redirects.csv+0 8 removed
    @@ -1,8 +0,0 @@
    -sys_redirect,,,,,,,,,,,
    -,uid,pid,createdon,deleted,disabled,source_host,source_path,target,target_statuscode,hitcount,protected
    -,1,0,2147483647,0,0,*,/foo,https://example.com/bar,301,0,1
    -,2,0,1,0,0,*,/foo,https://example.com/bar,302,10,0
    -,3,0,1,0,0,foo.com,/foo/bar,https://example.com/bar,303,0,0
    -,4,0,1,0,0,foo.com,/foo/baz,https://example.com/bar,304,0,0
    -,5,0,1,0,0,bar.com,/bar,https://example.com/bar,305,0,0
    -,6,0,2147483647,0,0,bar.com,/bar/foo,https://example.com/foo,305,0,0
    
  • typo3/sysext/redirects/Tests/Functional/Repository/Fixtures/sys_redirect.csv+8 0 added
    @@ -0,0 +1,8 @@
    +sys_redirect,,,,,,,,,,,
    +,uid,pid,createdon,deleted,disabled,source_host,source_path,target,target_statuscode,hitcount,protected,creation_type
    +,1,0,2147483647,0,0,*,/foo,https://example.com/bar,301,0,1,0
    +,2,0,1,0,0,*,/foo,t3://page?uid=1,302,10,0,0
    +,3,0,1,0,0,foo.com,/foo/bar,https://example.com/bar,303,0,0,1
    +,4,0,1,0,0,foo.com,/foo/baz,https://example.com/bar,304,0,0,0
    +,5,0,1,0,0,bar.com,/bar,https://example.com/bar,305,0,0,1
    +,6,0,2147483647,0,0,bar.com,/bar/foo,https://example.com/foo,305,0,0,0
    
  • typo3/sysext/redirects/Tests/Functional/Repository/RedirectRepositoryTest.php+160 8 modified
    @@ -20,15 +20,49 @@
     use PHPUnit\Framework\Attributes\DataProvider;
     use PHPUnit\Framework\Attributes\Test;
     use TYPO3\CMS\Core\Database\ConnectionPool;
    +use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
     use TYPO3\CMS\Core\Utility\GeneralUtility;
     use TYPO3\CMS\Redirects\Repository\Demand;
     use TYPO3\CMS\Redirects\Repository\RedirectRepository;
    +use TYPO3\CMS\Redirects\Tests\Functional\Repository\Fixtures\DemandFixture;
     use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
     
     final class RedirectRepositoryTest extends FunctionalTestCase
     {
    +    use SiteBasedTestTrait;
    +
    +    protected const LANGUAGE_PRESETS = [
    +        'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en-US'],
    +    ];
    +
         protected array $coreExtensionsToLoad = ['redirects'];
     
    +    protected function setUp(): void
    +    {
    +        parent::setUp();
    +
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/be_users.csv');
    +        $this->importCSVDataSet(dirname(__DIR__) . '/Fixtures/pages.csv');
    +
    +        $this->setUpBackendUser(1);
    +
    +        $this->writeSiteConfiguration(
    +            'bar',
    +            $this->buildSiteConfiguration(13, 'https://bar.com/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://bar.com/'),
    +            ],
    +        );
    +
    +        $this->writeSiteConfiguration(
    +            'foo',
    +            $this->buildSiteConfiguration(14, 'https://foo.com/'),
    +            [
    +                $this->buildDefaultLanguageConfiguration('EN', 'https://foo.com/'),
    +            ],
    +        );
    +    }
    +
         public static function demandProvider(): array
         {
             $allRecordCount = 6;
    @@ -83,6 +117,11 @@ public static function demandProvider(): array
                     $allRecordCount,
                     $allRecordCount - 3,
                 ],
    +            'demand with creation type "manually created"' => [
    +                self::getDemand(0, [], [], '', 1),
    +                $allRecordCount,
    +                $allRecordCount - 2,
    +            ],
             ];
         }
     
    @@ -91,10 +130,10 @@ public static function demandProvider(): array
         public function removeByDemandWorks(Demand $demand, int $redirectBeforeCleanup, int $redirectAfterCleanup): void
         {
             self::assertSame(0, $this->getRedirectCount());
    -        $this->importCSVDataSet(__DIR__ . '/Fixtures/RedirectRepositoryTest_redirects.csv');
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
     
             self::assertSame($redirectBeforeCleanup, $this->getRedirectCount());
    -        $repository = new RedirectRepository();
    +        $repository = $this->get(RedirectRepository::class);
             $repository->removeByDemand($demand);
             self::assertSame($redirectAfterCleanup, $this->getRedirectCount());
         }
    @@ -138,22 +177,133 @@ public static function countRedirectsByDemandCountsCorrectlyDataProvider(): iter
     
             yield 'demand with target' => [
                 new Demand(target: 'https://example.com/bar'),
    -            5,
    +            4,
             ];
    +        yield 'demand with creation type "manually created"' => [
    +            new Demand(creationType: 1),
    +            2,
    +        ];
    +        // Disabled in v12, because filter by protected was added in v13 with #102072
    +        //yield 'demand with protected state' => [
    +        //    new Demand(protected: 1),
    +        //    1,
    +        //];
         }
     
         #[DataProvider('countRedirectsByDemandCountsCorrectlyDataProvider')]
         #[Test]
         public function countRedirectsByDemandCountsCorrectly(Demand $demand, int $expectedCount): void
         {
    -        $this->importCSVDataSet(__DIR__ . '/Fixtures/RedirectRepositoryTest_redirects.csv');
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
     
    -        $repository = new RedirectRepository();
    -        $redirectsCount = $repository->countRedirectsByByDemand($demand);
    +        $repository = $this->get(RedirectRepository::class);
    +        $redirectsCount = $repository->countRedirectsByDemand($demand);
     
             self::assertSame($expectedCount, $redirectsCount);
         }
     
    +    public static function countRedirectsByDemandRespectsUserPermissionsDataProvider(): iterable
    +    {
    +        yield 'default demand' => [
    +            new Demand(),
    +            4,
    +        ];
    +
    +        yield 'configuration with hitCount' => [
    +            new Demand(maxHits: 2),
    +            3,
    +        ];
    +
    +        yield 'configuration with statusCode 302' => [
    +            new Demand(statusCodes: [302]),
    +            1,
    +        ];
    +
    +        yield 'demand with statusCode 302, 303' => [
    +            new Demand(statusCodes: [302, 303]),
    +            1,
    +        ];
    +
    +        yield 'demand with domain' => [
    +            new Demand(sourceHosts: ['bar.com']),
    +            2,
    +        ];
    +
    +        yield 'demand with domains' => [
    +            new Demand(sourceHosts: ['foo.com', 'bar.com']),
    +            2,
    +        ];
    +
    +        yield 'demand with path' => [
    +            new Demand(sourcePath: '/foo'),
    +            3,
    +        ];
    +
    +        yield 'demand with target' => [
    +            new Demand(target: 'https://example.com/bar'),
    +            2,
    +        ];
    +        yield 'demand with creation type "manually created"' => [
    +            new Demand(creationType: 1),
    +            1,
    +        ];
    +        // Disabled in v12, because filter by protected was added in v13 with #102072
    +        //yield 'demand with protected state' => [
    +        //    new Demand(protected: 1),
    +        //    1,
    +        //];
    +    }
    +
    +    #[DataProvider('countRedirectsByDemandRespectsUserPermissionsDataProvider')]
    +    #[Test]
    +    public function countRedirectsByDemandRespectsUserPermissions(Demand $demand, int $expectedCount): void
    +    {
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
    +
    +        $backendUser = $this->setUpBackendUser(2);
    +        $backendUser->userGroupsUID = [1];
    +        $backendUser->groupData['webmounts'] = '13';
    +
    +        $repository = $this->get(RedirectRepository::class);
    +        $redirectsCount = $repository->countRedirectsByDemand($demand);
    +
    +        self::assertSame($expectedCount, $redirectsCount);
    +    }
    +
    +    public static function filteredRedirectsArePaginatedCorrectlyDataProvider(): iterable
    +    {
    +        yield 'first page' => [
    +            (new DemandFixture(page: 1))->setLimit(2),
    +            [1, 2],
    +        ];
    +        // the second page skips uids 3 and 4, as they are not in web-mount 13
    +        yield 'second page' => [
    +            (new DemandFixture(page: 2))->setLimit(2),
    +            [5, 6],
    +        ];
    +        // the third page does not have any more redirects in web-mount 13
    +        yield 'third page' => [
    +            (new DemandFixture(page: 3))->setLimit(2),
    +            [],
    +        ];
    +    }
    +
    +    #[DataProvider('filteredRedirectsArePaginatedCorrectlyDataProvider')]
    +    #[Test]
    +    public function filteredRedirectsArePaginatedCorrectly(Demand $demand, array $expectation): void
    +    {
    +        $this->importCSVDataSet(__DIR__ . '/Fixtures/sys_redirect.csv');
    +
    +        $backendUser = $this->setUpBackendUser(2);
    +        $backendUser->userGroupsUID = [1];
    +        $backendUser->groupData['webmounts'] = '13';
    +
    +        $repository = $this->get(RedirectRepository::class);
    +        $redirects = $repository->findRedirectsByDemand($demand);
    +        $redirectUids = array_column($redirects, 'uid');
    +        self::assertSame($expectation, $redirectUids);
    +    }
    +
         private function getRedirectCount(): int
         {
             $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
    @@ -169,7 +319,8 @@ private static function getDemand(
             int $hitCount = 0,
             array $statusCodes = [],
             array $domains = [],
    -        string $path = ''
    +        string $path = '',
    +        int $creationType = -1
         ): Demand {
             return new Demand(
                 1,
    @@ -180,7 +331,8 @@ private static function getDemand(
                 '',
                 $statusCodes,
                 $hitCount,
    -            new \DateTimeImmutable('90 days ago')
    +            new \DateTimeImmutable('90 days ago'),
    +            $creationType
             );
         }
     }
    
  • typo3/sysext/redirects/Tests/Unit/FormDataProvider/ValuePickerItemDataProviderTest.php+0 85 removed
    @@ -1,85 +0,0 @@
    -<?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\Redirects\Tests\Unit\FormDataProvider;
    -
    -use PHPUnit\Framework\Attributes\Test;
    -use TYPO3\CMS\Core\Site\Entity\Site;
    -use TYPO3\CMS\Core\Site\SiteFinder;
    -use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
    -use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
    -
    -final class ValuePickerItemDataProviderTest extends UnitTestCase
    -{
    -    protected array $sysRedirectResultSet = [
    -        'tableName' => 'sys_redirect',
    -        'processedTca' => [
    -            'columns' => [
    -                'source_host' => [
    -                    'config' => [
    -                        'valuePicker' => [
    -                            'items' => [],
    -                        ],
    -                    ],
    -                ],
    -            ],
    -        ],
    -    ];
    -
    -    #[Test]
    -    public function addDataDoesNothingIfNoRedirectDataGiven(): void
    -    {
    -        $result = [
    -            'tableName' => 'tt_content',
    -        ];
    -
    -        $siteFinderMock = $this->getMockBuilder(SiteFinder::class)->disableOriginalConstructor()->getMock();
    -        $valuePickerItemDataProvider = new ValuePickerItemDataProvider($siteFinderMock);
    -        $actualResult = $valuePickerItemDataProvider->addData($result);
    -        self::assertSame($result, $actualResult);
    -    }
    -
    -    #[Test]
    -    public function addDataAddsHostsAsKeyAndValueToRedirectValuePicker(): void
    -    {
    -        // no results for now
    -        $siteFinderMock = $this->getMockBuilder(SiteFinder::class)->disableOriginalConstructor()->getMock();
    -        $siteFinderMock->expects(self::once())->method('getAllSites')->willReturn([
    -            new Site('bar', 13, ['base' => 'bar.test']),
    -            new Site('foo', 14, ['base' => 'foo.test']),
    -        ]);
    -        $valuePickerItemDataProvider = new ValuePickerItemDataProvider($siteFinderMock);
    -        $actualResult = $valuePickerItemDataProvider->addData($this->sysRedirectResultSet);
    -        $expected = $this->sysRedirectResultSet;
    -        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
    -            ['bar.test', 'bar.test'],
    -            ['foo.test', 'foo.test'],
    -        ];
    -        self::assertSame($expected, $actualResult);
    -    }
    -
    -    #[Test]
    -    public function addDataDoesNotChangeResultSetIfNoSitesAreFound(): void
    -    {
    -        $siteFinderMock = $this->getMockBuilder(SiteFinder::class)->disableOriginalConstructor()->getMock();
    -        $siteFinderMock->expects(self::once())->method('getAllSites')->willReturn([]);
    -        $valuePickerItemDataProvider = new ValuePickerItemDataProvider($siteFinderMock);
    -        $actualResult = $valuePickerItemDataProvider->addData($this->sysRedirectResultSet);
    -
    -        self::assertSame($this->sysRedirectResultSet, $actualResult);
    -    }
    -}
    

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.