Files
cms-core/Classes/Middleware/AbstractContentSecurityPolicyReporter.php
T

185 lines
7.0 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\Core\Middleware;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\CspConfigurationFactory;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionConfiguration;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\BeforePersistingReportEvent;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportDetails;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportRepository;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportStatus;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
use TYPO3\CMS\Core\Utility\IpAnonymizationUtility;
/**
* @internal
*/
abstract class AbstractContentSecurityPolicyReporter implements MiddlewareInterface
{
protected const URI_KEYS = ['document-uri', 'report-uri', 'blocked-uri', 'referrer'];
public function __construct(
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly PolicyProvider $policyProvider,
protected readonly CspConfigurationFactory $cspConfigurationFactory,
protected readonly ReportRepository $reportRepository,
protected readonly HashService $hashService,
) {}
protected function persistCspReport(Scope $scope, ServerRequestInterface $request): void
{
$payload = (string)$request->getBody();
if (!$this->isJson($payload)) {
return;
}
$normalizedParams = $request->getAttribute('normalizedParams');
$meta = [
'addr' => IpAnonymizationUtility::anonymizeIp($normalizedParams->getRemoteAddress()),
'agent' => $normalizedParams->getHttpUserAgent(),
];
// skip potential externally injected violation reports
$requestTime = $this->getRequestQueryParam($request, 'requestTime');
$requestHash = $this->getRequestQueryParam($request, 'requestHash');
if ($requestTime === null
|| $requestHash === null
|| !$this->hashService->validateHmac($requestTime, self::class, $requestHash)
) {
return;
}
$originalDetails = json_decode($payload, true)['csp-report'] ?? [];
$originalDetails = $this->anonymizeDetails($originalDetails);
$details = new ReportDetails($originalDetails);
$summary = $this->generateReportSummary($scope, $details);
$report = new Report(
$scope,
ReportStatus::New,
(int)$requestTime,
$meta,
$details,
$summary
);
$event = $this->eventDispatcher->dispatch(
new BeforePersistingReportEvent($report, $request)
);
if ($event->report !== null) {
$this->reportRepository->add($event->report);
}
}
protected function generateReportSummary(Scope $scope, ReportDetails $details): string
{
return $this->hashService->hmac(
json_encode([
$scope,
$details['effective-directive'] ?? null,
$details['blocked-uri'] ?? null,
$details['script-sample'] ?? null,
]),
self::class,
);
}
protected function anonymizeDetails(array $details): array
{
foreach (self::URI_KEYS as $uriKey) {
if (!isset($details[$uriKey])) {
continue;
}
$details[$uriKey] = $this->anonymizeUri($details[$uriKey]);
}
return $details;
}
protected function anonymizeUri(string $value): string
{
try {
$uri = Uri::fromAnyScheme($value);
} catch (\Throwable) {
return '';
}
if ($uri->getQuery() === '') {
return $value;
}
parse_str($uri->getQuery(), $query);
// strip CSRF token (might be a different usage as well)
unset($query['token']);
return (string)$uri->withQuery(http_build_query($query, '', '&', PHP_QUERY_RFC3986));
}
/**
* Determines, whether current request URI starts with local reporting URI,
* (e.g. `https://ip12.anyhost.it:8443/en/@http-reporting?csp=report`).
*/
protected function targetsCspReportUri(Scope $scope, ServerRequestInterface $request): bool
{
$normalizedParams = $request->getAttribute('normalizedParams');
$reportingUriBase = $this->policyProvider->getDefaultReportingUriBase($scope, $request, false);
return str_starts_with($normalizedParams?->getRequestUri() ?? '', (string)$reportingUriBase);
}
/**
* Determines, whether the request is eligible to be handled by the local reporting URI
* (`targetsCspReportUri()` must have been called before).
*/
protected function isCspReport(
Scope $scope,
ServerRequestInterface $request,
?DispositionConfiguration $dispositionConfiguration = null,
): bool {
// @todo
// + verify current session
// + invoke rate limiter
// + check additional scope (snippet enrichment)
$reportingUrl = $this->policyProvider->getReportingUrlFor($scope, $request, $dispositionConfiguration);
$reportingUriBase = $this->policyProvider->getDefaultReportingUriBase($scope, $request);
$contentTypeHeader = $request->getHeaderLine('content-type');
return
// stop, if reporting was explicitly disabled in `contentSecurityPolicyReportingUrl`
$reportingUrl !== null
// stop, if different reporting URI was configured in `contentSecurityPolicyReportingUrl`
&& str_starts_with((string)$reportingUrl, (string)$reportingUriBase)
// stop, if request is not `POST` or `Content-Type` is not `application/csp-report`
&& $request->getMethod() === 'POST' && $contentTypeHeader === 'application/csp-report';
}
protected function getRequestQueryParam(ServerRequestInterface $request, string $name): ?string
{
$value = $request->getQueryParams()[$name] ?? null;
return is_string($value) ? $value : null;
}
protected function isJson(string $value): bool
{
try {
json_decode($value, false, 16, JSON_THROW_ON_ERROR);
return true;
} catch (\JsonException) {
return false;
}
}
}