Files

379 lines
15 KiB
PHP

<?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 GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Promise\Utils;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Crypto\Random;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
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
{
protected const WRAP_FLAT = 1;
protected const WRAP_NESTED = 2;
/**
* @var FlashMessageQueue
*/
protected $messageQueue;
/**
* @var FileLocation
*/
protected $assetLocation;
/**
* @var FileLocation
*/
protected $fileadminLocation;
/**
* @var FileDeclaration[]
*/
protected $fileDeclarations;
public function __construct(
protected readonly UriBuilder $uriBuilder,
protected readonly bool $useMarkup = true,
) {
$fileName = bin2hex(random_bytes(4));
$folderName = bin2hex(random_bytes(4));
$this->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(
'<p><a href="%s" rel="noreferrer" target="_blank">%s</a></p>',
'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(), '<code>', '</code>')
);
},
$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<ul>%s</ul>',
$label,
implode('', $this->wrapItems($items, '<li>', '</li>'))
);
}
return sprintf(
'<p>%s%s</p>',
$label,
implode('', $this->wrapItems($items, '<br>', ''))
);
}
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;
}
}