TYPO3 CMS Allows Broken Access Control in Redirects Module
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.
| Package | Affected versions | Patched versions |
|---|---|---|
typo3/cms-redirectsPackagist | >= 14.0.0, < 14.0.2 | 14.0.2 |
typo3/cms-redirectsPackagist | >= 13.0.0, < 13.4.23 | 13.4.23 |
typo3/cms-redirectsPackagist | >= 12.0.0, < 12.4.41 | 12.4.41 |
typo3/cms-redirectsPackagist | >= 11.0.0, < 11.5.49 | 11.5.49 |
typo3/cms-redirectsPackagist | >= 10.0.0, < 10.4.55 | 10.4.55 |
Affected products
1Patches
38a46abd8993e[SECURITY] Prevent unauthorized access to resources in redirects module
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
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
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- github.com/TYPO3/typo3/commit/8a46abd8993e3a5a31a834dcd6c8f91adef57ce4ghsapatchWEB
- github.com/TYPO3/typo3/commit/bac370df5c1c3fcf5ebc1c030fbd2bec86d6a686ghsapatchWEB
- github.com/TYPO3/typo3/commit/fbbae3b9a40d0420207ef7af990cdf1ac0612c0bghsapatchWEB
- github.com/advisories/GHSA-6c46-p6j5-3f49ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-59021ghsaADVISORY
- typo3.org/security/advisory/typo3-core-sa-2026-002ghsavendor-advisoryWEB
- github.com/TYPO3/typo3/security/advisories/GHSA-6c46-p6j5-3f49ghsaWEB
News mentions
0No linked articles in our index yet.