assetLocation = new FileLocation(sprintf('/typo3temp/assets/%s.tmp/', $folderName)); $fileadminDir = rtrim($GLOBALS['TYPO3_CONF_VARS']['BE']['fileadminDir'] ?? 'fileadmin', '/'); $this->fileadminLocation = new FileLocation(sprintf('/%s/%s.tmp/', $fileadminDir, $folderName)); $this->fileDeclarations = $this->initializeFileDeclarations($fileName); } public function asStatus(ServerRequestInterface $request): Status { $messageQueue = $this->getStatus($request); $messages = []; foreach ($messageQueue->getAllMessages() as $flashMessage) { $messages[] = $flashMessage->getMessage(); } $detailsLink = sprintf( '

%s

', 'https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/9.5.x/Feature-91354-IntegrateServerResponseSecurityChecks.html', 'Please see documentation for further details...' ); if ($messageQueue->getAllMessages(ContextualFeedbackSeverity::ERROR) !== []) { $title = 'Potential vulnerabilities'; $label = $detailsLink; $severity = ContextualFeedbackSeverity::ERROR; } elseif ($messageQueue->getAllMessages(ContextualFeedbackSeverity::WARNING) !== []) { $title = 'Warnings'; $label = $detailsLink; $severity = ContextualFeedbackSeverity::WARNING; } return new Status( 'Server Response', $title ?? 'OK', $this->wrapList($messages, $label ?? '', self::WRAP_NESTED), $severity ?? ContextualFeedbackSeverity::OK ); } public function getStatus(?ServerRequestInterface $request = null): FlashMessageQueue { if ($request === null) { throw new \RuntimeException('ServerResponseCheck requires a request', 1775761298); } $messageQueue = new FlashMessageQueue('install-server-response-check'); if (PHP_SAPI === 'cli-server') { $messageQueue->addMessage( new FlashMessage( 'Skipped for PHP_SAPI=cli-server', 'Checks skipped', ContextualFeedbackSeverity::WARNING ) ); return $messageQueue; } try { $this->buildFileDeclarations(); $this->processHostCheck($messageQueue); $this->processFileDeclarations($messageQueue, $request); $this->finishMessageQueue($messageQueue); } finally { $this->purgeFileDeclarations(); } return $messageQueue; } protected function initializeFileDeclarations(string $fileName): array { $cspClosure = function (FileDeclaration $fileDeclaration, ResponseInterface $response): ?StatusMessage { $cspHeader = new ContentSecurityPolicyHeader( $response->getHeaderLine('content-security-policy') ); if ($cspHeader->isEmpty()) { return new StatusMessage( 'missing Content-Security-Policy for this location' ); } if (!$cspHeader->mitigatesCrossSiteScripting($fileDeclaration->getFileName())) { return new StatusMessage( 'weak Content-Security-Policy for this location "%s"', $response->getHeaderLine('content-security-policy') ); } return null; }; return [ (new FileDeclaration($this->assetLocation, $fileName . '.html')) ->withExpectedContentType('text/html') ->withExpectedContent('HTML content'), (new FileDeclaration($this->assetLocation, $fileName . '.wrong')) ->withUnexpectedContentType('text/html') ->withExpectedContent('HTML content'), (new FileDeclaration($this->assetLocation, $fileName . '.html.wrong')) ->withUnexpectedContentType('text/html') ->withExpectedContent('HTML content'), (new FileDeclaration($this->assetLocation, $fileName . '.1.svg.wrong')) ->withBuildFlags(FileDeclaration::FLAG_BUILD_SVG | FileDeclaration::FLAG_BUILD_SVG_DOCUMENT) ->withUnexpectedContentType('image/svg+xml') ->withExpectedContent('SVG content'), (new FileDeclaration($this->assetLocation, $fileName . '.2.svg.wrong')) ->withBuildFlags(FileDeclaration::FLAG_BUILD_SVG | FileDeclaration::FLAG_BUILD_SVG_DOCUMENT) ->withUnexpectedContentType('image/svg') ->withExpectedContent('SVG content'), (new FileDeclaration($this->assetLocation, $fileName . '.php.wrong', true)) ->withBuildFlags(FileDeclaration::FLAG_BUILD_PHP | FileDeclaration::FLAG_BUILD_HTML_DOCUMENT) ->withUnexpectedContent('PHP content'), (new FileDeclaration($this->assetLocation, $fileName . '.html.txt')) ->withExpectedContentType('text/plain') ->withUnexpectedContentType('text/html') ->withExpectedContent('HTML content'), (new FileDeclaration($this->assetLocation, $fileName . '.php.txt', true)) ->withBuildFlags(FileDeclaration::FLAG_BUILD_PHP | FileDeclaration::FLAG_BUILD_HTML_DOCUMENT) ->withUnexpectedContent('PHP content'), (new FileDeclaration($this->fileadminLocation, $fileName . '.html')) ->withBuildFlags(FileDeclaration::FLAG_BUILD_HTML_DOCUMENT) ->withHandler($cspClosure), (new FileDeclaration($this->fileadminLocation, $fileName . '.svg')) ->withBuildFlags(FileDeclaration::FLAG_BUILD_SVG | FileDeclaration::FLAG_BUILD_SVG_DOCUMENT) ->withHandler($cspClosure), ]; } protected function buildFileDeclarations(): void { foreach ($this->fileDeclarations as $fileDeclaration) { $filePath = $fileDeclaration->getFileLocation()->getFilePath(); if (!is_dir($filePath)) { GeneralUtility::mkdir_deep($filePath); } GeneralUtility::writeFile( $filePath . $fileDeclaration->getFileName(), $fileDeclaration->buildContent(), true ); } } protected function purgeFileDeclarations(): void { GeneralUtility::rmdir($this->assetLocation->getFilePath(), true); GeneralUtility::rmdir($this->fileadminLocation->getFilePath(), true); } protected function processHostCheck(FlashMessageQueue $messageQueue): void { $random = GeneralUtility::makeInstance(Random::class); $randomHost = $random->generateRandomHexString(10) . '.random.example.org'; $time = (string)time(); $hashService = GeneralUtility::makeInstance(HashService::class); $url = $this->uriBuilder->buildUriFromRoute( 'install.server-response-check.host', ['src-time' => $time, 'src-hash' => $hashService->hmac($time, 'server-response-check')], UriBuilder::ABSOLUTE_URL ); try { $client = new Client(['timeout' => 10]); $response = $client->request('GET', (string)$url, [ 'headers' => ['Host' => $randomHost], 'allow_redirects' => false, 'verify' => false, ]); } catch (TransferException $exception) { // it is expected that the previous request fails return; } // in case we end up here, the server processed an HTTP request with invalid HTTP host header $messageParts = []; $locationHeader = $response->getHeaderLine('location'); if (!empty($locationHeader) && (new Uri($locationHeader))->getHost() === $randomHost) { $messageParts[] = sprintf('HTTP Location header contained unexpected "%s"', $randomHost); } $data = json_decode((string)$response->getBody(), true); $serverHttpHost = $data['server.HTTP_HOST'] ?? null; $serverServerName = $data['server.SERVER_NAME'] ?? null; if ($serverHttpHost === $randomHost) { $messageParts[] = sprintf('HTTP_HOST contained unexpected "%s"', $randomHost); } if ($serverServerName === $randomHost) { $messageParts[] = sprintf('SERVER_NAME contained unexpected "%s"', $randomHost); } if ($messageParts !== []) { $messageQueue->addMessage( new FlashMessage( $this->wrapList($messageParts, (string)$url, self::WRAP_FLAT), 'Unexpected server response', ContextualFeedbackSeverity::ERROR ) ); } } protected function processFileDeclarations(FlashMessageQueue $messageQueue, ServerRequestInterface $request): void { $promises = []; $client = new Client(['timeout' => 10]); foreach ($this->fileDeclarations as $fileDeclaration) { $promises[] = $client->requestAsync('GET', $fileDeclaration->getUrl($request)); } foreach (Utils::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', ContextualFeedbackSeverity::WARNING ) ); continue; } if (!($response['value'] ?? null) instanceof ResponseInterface || $fileDeclaration->matches($response['value'])) { continue; } $messageQueue->addMessage( new FlashMessage( $this->createMismatchMessage($fileDeclaration, $response['value'], $request), 'Unexpected server response', $fileDeclaration->shallFail() ? ContextualFeedbackSeverity::ERROR : ContextualFeedbackSeverity::WARNING ) ); } } protected function finishMessageQueue(FlashMessageQueue $messageQueue): void { if ($messageQueue->getAllMessages(ContextualFeedbackSeverity::WARNING) !== [] || $messageQueue->getAllMessages(ContextualFeedbackSeverity::ERROR) !== []) { return; } $messageQueue->addMessage( new FlashMessage( sprintf('All %d files processed correctly', count($this->fileDeclarations)), 'Expected server response', ContextualFeedbackSeverity::OK ) ); } protected function createMismatchMessage(FileDeclaration $fileDeclaration, ResponseInterface $response, ServerRequestInterface $request): string { $messageParts = array_map( function (StatusMessage $mismatch): string { return vsprintf( $mismatch->getMessage(), $this->wrapValues($mismatch->getValues(), '', '') ); }, $fileDeclaration->getMismatches($response) ); return $this->wrapList($messageParts, $fileDeclaration->getUrl($request), self::WRAP_FLAT); } protected function wrapList(array $items, string $label, int $style): string { if (!$this->useMarkup) { return sprintf( '%s%s', $label ? $label . ': ' : '', implode(', ', $items) ); } if ($style === self::WRAP_NESTED) { return sprintf( '%s', $label, implode('', $this->wrapItems($items, '
  • ', '
  • ')) ); } return sprintf( '

    %s%s

    ', $label, implode('', $this->wrapItems($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 wrapValues(array $values, string $before, string $after): array { return array_map( function (string $value) use ($before, $after): string { return $this->wrapValue($value, $before, $after); }, array_filter($values) ); } protected function wrapValue(string $value, string $before, string $after): string { if ($this->useMarkup) { return $before . htmlspecialchars($value) . $after; } return $value; } }