diff --git a/typo3/sysext/core/Documentation/Changelog/9.5.x/Feature-91354-IntegrateServerResponseSecurityChecks.rst b/typo3/sysext/core/Documentation/Changelog/9.5.x/Feature-91354-IntegrateServerResponseSecurityChecks.rst new file mode 100644 index 0000000000000000000000000000000000000000..022479ba94604ee29a115fdc87ca534e4784d0b3 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/9.5.x/Feature-91354-IntegrateServerResponseSecurityChecks.rst @@ -0,0 +1,29 @@ +.. include:: ../../Includes.txt + +=========================================================== +Feature: #91354 - Integrate server response security checks +=========================================================== + +See :issue:`91354` + +Description +=========== + +In order to evaluate potential server misconfigurations and to reduce +the potential of security implications in general, a new HTTP response +check is integrated to "Environment Status" and the "Security" section +in the reports module. + + +Impact +====== + +It is evaluated whether non-standard file extensions lead to unexpected +handling on the server-side, such as `test.php.wrong` being evaluated +as PHP or `test.html.wrong` being served with `text/html` content type. + +Details are explained in `TYPO3 Security Guidelines for Administrators`_. + +.. _TYPO3 Security Guidelines for Administrators: https://docs.typo3.org/m/typo3/reference-coreapi/10.4/en-us/Security/GuidelinesAdministrators/Index.html#file-extension-handling + +.. index:: ext:install diff --git a/typo3/sysext/install/Classes/Controller/EnvironmentController.php b/typo3/sysext/install/Classes/Controller/EnvironmentController.php index 364228dc80668cd004cb61edc7981f419fac5566..5d9c5fb8529ebc6c1a1b49700e9059b733027552 100644 --- a/typo3/sysext/install/Classes/Controller/EnvironmentController.php +++ b/typo3/sysext/install/Classes/Controller/EnvironmentController.php @@ -42,6 +42,7 @@ use TYPO3\CMS\Install\FolderStructure\DefaultPermissionsCheck; use TYPO3\CMS\Install\Service\LateBootService; use TYPO3\CMS\Install\SystemEnvironment\Check; use TYPO3\CMS\Install\SystemEnvironment\DatabaseCheck; +use TYPO3\CMS\Install\SystemEnvironment\ServerResponse\ServerResponseCheck; use TYPO3\CMS\Install\SystemEnvironment\SetupCheck; /** @@ -135,6 +136,10 @@ class EnvironmentController extends AbstractController foreach ($databaseMessages as $message) { $messageQueue->enqueue($message); } + $serverResponseMessages = (new ServerResponseCheck(false))->getStatus(); + foreach ($serverResponseMessages as $message) { + $messageQueue->enqueue($message); + } return new JsonResponse([ 'success' => true, 'status' => [ diff --git a/typo3/sysext/install/Classes/Report/SecurityStatusReport.php b/typo3/sysext/install/Classes/Report/SecurityStatusReport.php index a25471906fca085419ef49a3b6f5e6be8ea793d7..802a4a5ba1f57f951e46ff8d10a237544e259e11 100644 --- a/typo3/sysext/install/Classes/Report/SecurityStatusReport.php +++ b/typo3/sysext/install/Classes/Report/SecurityStatusReport.php @@ -22,6 +22,7 @@ use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory; use TYPO3\CMS\Core\Localization\LanguageService; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Install\Service\EnableFileService; +use TYPO3\CMS\Install\SystemEnvironment\ServerResponse\ServerResponseCheck; use TYPO3\CMS\Reports\Status; use TYPO3\CMS\Reports\StatusProviderInterface; @@ -41,7 +42,8 @@ class SecurityStatusReport implements StatusProviderInterface $this->executeAdminCommand(); return [ 'installToolPassword' => $this->getInstallToolPasswordStatus(), - 'installToolProtection' => $this->getInstallToolProtectionStatus() + 'installToolProtection' => $this->getInstallToolProtectionStatus(), + 'serverResponseStatus' => GeneralUtility::makeInstance(ServerResponseCheck::class)->asStatus(), ]; } diff --git a/typo3/sysext/install/Classes/SystemEnvironment/ServerResponse/FileDeclaration.php b/typo3/sysext/install/Classes/SystemEnvironment/ServerResponse/FileDeclaration.php new file mode 100644 index 0000000000000000000000000000000000000000..3156c4b1b0ad472b8ab68e75a83a04096c50ef3b --- /dev/null +++ b/typo3/sysext/install/Classes/SystemEnvironment/ServerResponse/FileDeclaration.php @@ -0,0 +1,214 @@ +<?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\Install\SystemEnvironment\ServerResponse; + +use Psr\Http\Message\ResponseInterface; + +/** + * Declares contents on server response expectations on a static file. + * + * @internal should only be used from within TYPO3 Core + */ +class FileDeclaration +{ + public const MISMATCH_EXPECTED_CONTENT_TYPE = 'expectedContentType'; + public const MISMATCH_UNEXPECTED_CONTENT_TYPE = 'unexpectedContentType'; + public const MISMATCH_EXPECTED_CONTENT = 'expectedContent'; + public const MISMATCH_UNEXPECTED_CONTENT = 'unexpectedContent'; + public const FLAG_BUILD_HTML = 1; + public const FLAG_BUILD_PHP = 2; + public const FLAG_BUILD_SVG = 4; + public const FLAG_BUILD_HTML_DOCUMENT = 64; + public const FLAG_BUILD_SVG_DOCUMENT = 128; + + /** + * @var string + */ + protected $fileName; + + /** + * @var bool + */ + protected $fail; + + /** + * @var string|null + */ + protected $expectedContentType; + + /** + * @var string|null + */ + protected $unexpectedContentType; + + /** + * @var string|null + */ + protected $expectedContent; + + /** + * @var string|null + */ + protected $unexpectedContent; + + /** + * @var int + */ + protected $buildFlags = self::FLAG_BUILD_HTML | self::FLAG_BUILD_HTML_DOCUMENT; + + public function __construct(string $fileName, bool $fail = false) + { + $this->fileName = $fileName; + $this->fail = $fail; + } + + public function buildContent(): string + { + $content = ''; + if ($this->buildFlags & self::FLAG_BUILD_HTML) { + $content .= '<div>HTML content</div>'; + } + if ($this->buildFlags & self::FLAG_BUILD_PHP) { + // base64 encoded representation of 'PHP content' + $content .= '<div><?php echo base64_decode(\'UEhQIGNvbnRlbnQ=\');?></div>'; + } + if ($this->buildFlags & self::FLAG_BUILD_SVG) { + $content .= '<text id="test" x="0" y="0">SVG content</text>'; + } + if ($this->buildFlags & self::FLAG_BUILD_SVG_DOCUMENT) { + return sprintf( + '<svg xmlns="http://www.w3.org/2000/svg">%s</svg>', + $content + ); + } + return sprintf( + '<!DOCTYPE html><html lang="en"><body>%s</body></html>', + $content + ); + } + + public function matches(ResponseInterface $response): bool + { + return $this->getMismatches($response) === []; + } + + public function getMismatches(ResponseInterface $response): array + { + $mismatches = []; + $body = (string)$response->getBody(); + $contentType = $response->getHeaderLine('content-type'); + if ($this->expectedContent !== null && strpos($body, $this->expectedContent) === false) { + $mismatches[] = self::MISMATCH_EXPECTED_CONTENT; + } + if ($this->unexpectedContent !== null && strpos($body, $this->unexpectedContent) !== false) { + $mismatches[] = self::MISMATCH_UNEXPECTED_CONTENT; + } + if ($this->expectedContentType !== null + && strpos($contentType . ';', $this->expectedContentType . ';') !== 0) { + $mismatches[] = self::MISMATCH_EXPECTED_CONTENT_TYPE; + } + if ($this->unexpectedContentType !== null + && strpos($contentType . ';', $this->unexpectedContentType . ';') === 0) { + $mismatches[] = self::MISMATCH_UNEXPECTED_CONTENT_TYPE; + } + return $mismatches; + } + + public function withExpectedContentType(string $contentType): self + { + $target = clone $this; + $target->expectedContentType = $contentType; + return $target; + } + + public function withUnexpectedContentType(string $contentType): self + { + $target = clone $this; + $target->unexpectedContentType = $contentType; + return $target; + } + + public function withExpectedContent(string $content): self + { + $target = clone $this; + $target->expectedContent = $content; + return $target; + } + + public function withUnexpectedContent(string $content): self + { + $target = clone $this; + $target->unexpectedContent = $content; + return $target; + } + + public function withBuildFlags(int $buildFlags): self + { + $target = clone $this; + $target->buildFlags = $buildFlags; + return $target; + } + + /** + * @return string + */ + public function getFileName(): string + { + return $this->fileName; + } + + /** + * @return bool + */ + public function shallFail(): bool + { + return $this->fail; + } + + /** + * @return string|null + */ + public function getExpectedContentType(): ?string + { + return $this->expectedContentType; + } + + /** + * @return string|null + */ + public function getUnexpectedContentType(): ?string + { + return $this->unexpectedContentType; + } + + /** + * @return string|null + */ + public function getExpectedContent(): ?string + { + return $this->expectedContent; + } + + /** + * @return string|null + */ + public function getUnexpectedContent(): ?string + { + return $this->unexpectedContent; + } +} diff --git a/typo3/sysext/install/Classes/SystemEnvironment/ServerResponse/ServerResponseCheck.php b/typo3/sysext/install/Classes/SystemEnvironment/ServerResponse/ServerResponseCheck.php new file mode 100644 index 0000000000000000000000000000000000000000..bf824bb6a43edfe3804f6318126199c99078397f --- /dev/null +++ b/typo3/sysext/install/Classes/SystemEnvironment/ServerResponse/ServerResponseCheck.php @@ -0,0 +1,313 @@ +<?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\Install\SystemEnvironment\ServerResponse; + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\BadResponseException; +use function GuzzleHttp\Promise\settle; +use Psr\Http\Message\ResponseInterface; +use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageQueue; +use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Utility\PathUtility; +use TYPO3\CMS\Install\SystemEnvironment\CheckInterface; +use TYPO3\CMS\Reports\Status; + +/** + * Checks how use web server is interpreting static files concerning + * their `content-type` and evaluated content in HTTP responses. + * + * @internal should only be used from within TYPO3 Core + */ +class ServerResponseCheck implements CheckInterface +{ + /** + * @var bool + */ + protected $useMarkup; + + /** + * @var FlashMessageQueue + */ + protected $messageQueue; + + /** + * @var string + */ + protected $filePath; + + /** + * @var string + */ + protected $baseUrl; + + /** + * @var FileDeclaration[] + */ + protected $fileDeclarations; + + public function __construct(bool $useMarkup = true) + { + $this->useMarkup = $useMarkup; + + $fileName = bin2hex(random_bytes(4)); + $folderName = bin2hex(random_bytes(4)); + $this->filePath = Environment::getPublicPath() + . sprintf('/typo3temp/assets/%s.tmp/', $folderName); + $this->baseUrl = GeneralUtility::getIndpEnv('TYPO3_REQUEST_HOST') + . PathUtility::getAbsoluteWebPath($this->filePath); + $this->fileDeclarations = $this->initializeFileDeclarations($fileName); + } + + public function asStatus(): Status + { + $messageQueue = $this->getStatus(); + $messages = []; + foreach ($messageQueue->getAllMessages() as $flashMessage) { + $messages[] = $flashMessage->getMessage(); + } + if ($messageQueue->getAllMessages(FlashMessage::ERROR) !== []) { + $title = 'Potential vulnerabilities'; + $severity = Status::ERROR; + } elseif ($messageQueue->getAllMessages(FlashMessage::WARNING) !== []) { + $title = 'Warnings'; + $severity = Status::WARNING; + } + return new Status( + 'Server Response on static files', + $title ?? 'OK', + $this->wrapList($messages), + $severity ?? Status::OK + ); + } + + public function getStatus(): FlashMessageQueue + { + $messageQueue = new FlashMessageQueue('install-server-response-check'); + if (PHP_SAPI === 'cli-server') { + $messageQueue->addMessage( + new FlashMessage( + 'Skipped for PHP_SAPI=cli-server', + 'Checks skipped', + FlashMessage::WARNING + ) + ); + return $messageQueue; + } + try { + $this->buildFileDeclarations(); + $this->processFileDeclarations($messageQueue); + $this->finishMessageQueue($messageQueue); + } finally { + $this->purgeFileDeclarations(); + } + return $messageQueue; + } + + protected function initializeFileDeclarations(string $fileName): array + { + return [ + (new FileDeclaration($fileName . '.html')) + ->withExpectedContentType('text/html') + ->withExpectedContent('HTML content'), + (new FileDeclaration($fileName . '.wrong')) + ->withUnexpectedContentType('text/html') + ->withExpectedContent('HTML content'), + (new FileDeclaration($fileName . '.html.wrong')) + ->withUnexpectedContentType('text/html') + ->withExpectedContent('HTML content'), + (new FileDeclaration($fileName . '.1.svg.wrong')) + ->withBuildFlags(FileDeclaration::FLAG_BUILD_SVG | FileDeclaration::FLAG_BUILD_SVG_DOCUMENT) + ->withUnexpectedContentType('image/svg+xml') + ->withExpectedContent('SVG content'), + (new FileDeclaration($fileName . '.2.svg.wrong')) + ->withBuildFlags(FileDeclaration::FLAG_BUILD_SVG | FileDeclaration::FLAG_BUILD_SVG_DOCUMENT) + ->withUnexpectedContentType('image/svg') + ->withExpectedContent('SVG content'), + (new FileDeclaration($fileName . '.php.wrong', true)) + ->withBuildFlags(FileDeclaration::FLAG_BUILD_PHP | FileDeclaration::FLAG_BUILD_HTML_DOCUMENT) + ->withUnexpectedContent('PHP content'), + (new FileDeclaration($fileName . '.html.txt')) + ->withExpectedContentType('text/plain') + ->withUnexpectedContentType('text/html') + ->withExpectedContent('HTML content'), + (new FileDeclaration($fileName . '.php.txt', true)) + ->withBuildFlags(FileDeclaration::FLAG_BUILD_PHP | FileDeclaration::FLAG_BUILD_HTML_DOCUMENT) + ->withUnexpectedContent('PHP content'), + ]; + } + + protected function buildFileDeclarations(): void + { + if (!is_dir($this->filePath)) { + GeneralUtility::mkdir_deep($this->filePath); + } + foreach ($this->fileDeclarations as $fileDeclaration) { + file_put_contents( + $this->filePath . $fileDeclaration->getFileName(), + $fileDeclaration->buildContent() + ); + } + } + + protected function purgeFileDeclarations(): void + { + GeneralUtility::rmdir($this->filePath, true); + } + + protected function processFileDeclarations(FlashMessageQueue $messageQueue): void + { + $promises = []; + $client = new Client(['base_uri' => $this->baseUrl]); + foreach ($this->fileDeclarations as $fileDeclaration) { + $promises[] = $client->requestAsync('GET', $fileDeclaration->getFileName()); + } + foreach (settle($promises)->wait() as $index => $response) { + $fileDeclaration = $this->fileDeclarations[$index]; + if (($response['reason'] ?? null) instanceof BadResponseException) { + $messageQueue->addMessage( + new FlashMessage( + sprintf( + '(%d): %s', + $response['reason']->getCode(), + $response['reason']->getRequest()->getUri() + ), + 'HTTP warning', + FlashMessage::WARNING + ) + ); + continue; + } + if (!($response['value'] ?? null) instanceof ResponseInterface || $fileDeclaration->matches($response['value'])) { + continue; + } + $messageQueue->addMessage( + new FlashMessage( + $this->createMismatchMessage($fileDeclaration, $response['value']), + 'Unexpected server response', + $fileDeclaration->shallFail() ? FlashMessage::ERROR : FlashMessage::WARNING + ) + ); + } + } + + protected function finishMessageQueue(FlashMessageQueue $messageQueue): void + { + if ($messageQueue->getAllMessages(FlashMessage::WARNING) !== [] + || $messageQueue->getAllMessages(FlashMessage::ERROR) !== []) { + return; + } + $messageQueue->addMessage( + new FlashMessage( + sprintf('All %d files processed correctly', count($this->fileDeclarations)), + 'Expected server response', + FlashMessage::OK + ) + ); + } + + protected function createMismatchMessage(FileDeclaration $fileDeclaration, ResponseInterface $response): string + { + $messageParts = []; + $mismatches = $fileDeclaration->getMismatches($response); + if (in_array(FileDeclaration::MISMATCH_UNEXPECTED_CONTENT_TYPE, $mismatches, true)) { + $messageParts[] = sprintf( + 'unexpected content-type %s', + $this->wrapValue( + $fileDeclaration->getUnexpectedContentType(), + '<code>', + '</code>' + ) + ); + } + if (in_array(FileDeclaration::MISMATCH_EXPECTED_CONTENT_TYPE, $mismatches, true)) { + $messageParts[] = sprintf( + 'content-type mismatch %s, got %s', + $this->wrapValue( + $fileDeclaration->getExpectedContent(), + '<code>', + '</code>' + ), + $this->wrapValue( + $response->getHeaderLine('content-type'), + '<code>', + '</code>' + ) + ); + } + if (in_array(FileDeclaration::MISMATCH_UNEXPECTED_CONTENT, $mismatches, true)) { + $messageParts[] = sprintf( + 'unexpected content %s', + $this->wrapValue( + $fileDeclaration->getUnexpectedContent(), + '<code>', + '</code>' + ) + ); + } + if (in_array(FileDeclaration::MISMATCH_EXPECTED_CONTENT, $mismatches, true)) { + $messageParts[] = sprintf( + 'content mismatch %s', + $this->wrapValue( + $fileDeclaration->getExpectedContent(), + '<code>', + '</code>' + ) + ); + } + return $this->wrapList( + $messageParts, + $this->baseUrl . $fileDeclaration->getFileName() + ); + } + + protected function wrapList(array $items, string $label = ''): string + { + if ($this->useMarkup) { + return sprintf( + '%s<ul>%s</ul>', + $label, + implode('', $this->wrapItems($items, '<li>', '</li>')) + ); + } + return sprintf( + '%s%s', + $label ? $label . ': ' : '', + implode(', ', $items) + ); + } + + protected function wrapItems(array $items, string $before, string $after): array + { + return array_map( + function (string $item) use ($before, $after): string { + return $before . $item . $after; + }, + array_filter($items) + ); + } + + protected function wrapValue(string $value, string $before, string $after): string + { + if ($this->useMarkup) { + return $before . htmlspecialchars($value) . $after; + } + return $value; + } +}