* @internal */ final class ResourceConsistencyService { /** * Exception items, which shall not be validated. * These are usually set by internal components (e.g. `ext:impexp`). * * @var ExceptionItemCollection */ private array $exceptionItems = []; public function __construct( private readonly Random $random, private readonly Features $features, private readonly MimeTypeDetector $mimeTypeDetector, ) {} public function addExceptionItem(ResourceStorage $storage, string|FileInterface $resource, string $targetFileName): void { $identifier = $this->random->generateRandomHexString(40); $this->exceptionItems[$identifier] = $this->createExceptionItem($storage, $resource, $targetFileName); } public function removeException(string $identifier): void { unset($this->exceptionItems[$identifier]); } /** * @param FileInterface|string $resource holding the contents * @param string $targetFileName (optional) target file name to be used as the identifier * @throws ResultException */ public function validate(ResourceStorage $storage, string|FileInterface $resource, string $targetFileName = ''): void { if (!$this->shallValidate($storage, $resource, $targetFileName)) { return; } if ($targetFileName !== '') { $fileExtension = pathinfo($targetFileName, PATHINFO_EXTENSION); } if ($resource instanceof FileInterface) { $mimeType = $resource->getMimeType(); $fileSize = $resource->getSize(); $fileExtension ??= $resource->getExtension(); } else { $fileInfo = new FileInfo($resource); $mimeType = (string)$fileInfo->getMimeType($targetFileName); $fileSize = $fileInfo->isReadable() ? $fileInfo->getSize() : 0; $fileExtension ??= $fileInfo->getExtension(); } $isEmptyFile = $fileSize === 0; $messages = []; // skip mime-type checks for empty files if (!$isEmptyFile && !$this->areFileExtensionAndMimeTypeConsistent($fileExtension, $mimeType)) { $expectedTypes = $this->mimeTypeDetector->getMimeTypesForFileExtension($fileExtension); if ($expectedTypes === []) { $listOfExpectedTypes = 'N/A'; } else { $listOfExpectedTypes = implode(', ', $expectedTypes); } $arguments = [$mimeType, $fileExtension, $listOfExpectedTypes]; $messages[] = new ResultMessage( sprintf('Mime-type "%s" not allowed for file extension "%s" (expected: %s).', ...$arguments), new LabelBag( 'LLL:EXT:core/Resources/Private/Language/fileMessages.xlf:FileUtility.MimeTypeNotAllowedForFileExtensionWithExpectation', ...$arguments ) ); } if (!$this->isFileExtensionAllowed($fileExtension)) { $arguments = [$fileExtension]; $messages[] = new ResultMessage( sprintf('File extension "%s" is not in the list of allowed values.', ...$arguments), new LabelBag( 'LLL:EXT:core/Resources/Private/Language/fileMessages.xlf:FileUtility.FileExtensionIsNotAllowed', ...$arguments ) ); } if ($messages !== []) { throw new ResultException('Resource consistency check failed', 1747230949, ...$messages); } } private function areFileExtensionAndMimeTypeConsistent(string $fileExtension, string $mimeType): bool { if (!$this->features->isFeatureEnabled('security.system.enforceFileExtensionMimeTypeConsistency')) { return true; } $fileExtension = mb_strtolower($fileExtension); $assumedMimesTypeOfFileExtension = $this->mimeTypeDetector->getMimeTypesForFileExtension($fileExtension); // pass, in case no assumed mime-type was found (e.g., for individual file extension) return $assumedMimesTypeOfFileExtension === [] || ($mimeType !== '' && in_array($mimeType, $assumedMimesTypeOfFileExtension, true)); } private function isFileExtensionAllowed(string $fileExtension): bool { if (!$this->features->isFeatureEnabled('security.system.enforceAllowedFileExtensions')) { return true; } $fileExtension = mb_strtolower($fileExtension); return in_array($fileExtension, $this->getAllowedFileExtensions(), true); } private function getAllowedFileExtensions(): array { $allowedFileExtensions = GeneralUtility::trimExplode( ',', $GLOBALS['TYPO3_CONF_VARS']['SYS']['textfile_ext'] . ',' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['mediafile_ext'] . ',' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['miscfile_ext'], true ); return array_map(mb_strtolower(...), $allowedFileExtensions); } private function shallValidate(ResourceStorage $storage, string|FileInterface $resource, string $targetFileName): bool { $needle = $this->createExceptionItem($storage, $resource, $targetFileName); $exceptionItems = array_filter( $this->exceptionItems, fn(array $exception): bool => $this->exceptionItemsMatch($exception, $needle), ); if ($exceptionItems === []) { return true; } foreach (array_keys($exceptionItems) as $identifier) { $this->removeException($identifier); } return false; } /** * @return ExceptionItem */ private function createExceptionItem(ResourceStorage $storage, string|FileInterface $resource, string $targetFileName): array { return [ 'storage' => $storage, 'resource' => $resource, 'targetFileName' => $targetFileName, ]; } private function exceptionItemsMatch(array $left, array $right): bool { foreach ($right as $key => $value) { if ($value !== ($left[$key] ?? null)) { return false; } } return true; } }