diff --git a/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php b/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php index 64e1438f3aa4fa21938c62afe4979e75676957fc..c88d778f88ff3b7289ed2caf52deb2ac6db877d3 100644 --- a/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php +++ b/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php @@ -25,6 +25,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Core\RequestId; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; @@ -58,7 +59,7 @@ final readonly class ContentSecurityPolicyHeaders implements MiddlewareInterface return $response; } - $policy = $this->policyProvider->provideFor($scope, $request); + $policy = $this->policyProvider->provideFor($scope, Disposition::enforce, $request); if ($policy->isEmpty()) { return $response; } @@ -66,6 +67,9 @@ final readonly class ContentSecurityPolicyHeaders implements MiddlewareInterface if ($reportingUri !== null) { $policy = $policy->report(UriValue::fromUri($reportingUri)); } - return $response->withHeader('Content-Security-Policy', $policy->compile($this->requestId->nonce, $this->cache)); + return $response->withHeader( + Disposition::enforce->getHttpHeaderName(), + $policy->compile($this->requestId->nonce, $this->cache) + ); } } diff --git a/typo3/sysext/backend/Classes/Security/ContentSecurityPolicy/CspAjaxController.php b/typo3/sysext/backend/Classes/Security/ContentSecurityPolicy/CspAjaxController.php index b2f61c64e1be4a6625b0e09a598d0e1ae4a7466b..2f77a0a2068b38d59ebe1ec839ae944a2abda5e2 100644 --- a/typo3/sysext/backend/Classes/Security/ContentSecurityPolicy/CspAjaxController.php +++ b/typo3/sysext/backend/Classes/Security/ContentSecurityPolicy/CspAjaxController.php @@ -58,6 +58,9 @@ class CspAjaxController public function handleRequest(ServerRequestInterface $request): ResponseInterface { + if ($request->getMethod() === 'GET') { + return (new NullResponse())->withStatus(400); + } if (!$this->isSystemMaintainer()) { return (new NullResponse())->withStatus(403); } @@ -182,7 +185,8 @@ class CspAjaxController protected function dispatchInvestigateMutationsEvent(Report $report): InvestigateMutationsEvent { - $policy = $this->policyProvider->provideFor($report->scope); + // @todo for future versions, it might be considered to distinguish `enforce` and `report` in the database + $policy = $this->policyProvider->provideFor($report->scope, $report->details->resolveDisposition()); $event = new InvestigateMutationsEvent($policy, $report); $this->eventDispatcher->dispatch($event); return $event; diff --git a/typo3/sysext/backend/Classes/Security/ContentSecurityPolicy/CspModuleController.php b/typo3/sysext/backend/Classes/Security/ContentSecurityPolicy/CspModuleController.php index cd1168e3b0e09e3df9cddd85e38567cae90ad9c3..7806e9fa60e455878554485001b73b3c0390a082 100644 --- a/typo3/sysext/backend/Classes/Security/ContentSecurityPolicy/CspModuleController.php +++ b/typo3/sysext/backend/Classes/Security/ContentSecurityPolicy/CspModuleController.php @@ -62,8 +62,11 @@ class CspModuleController { return [ 'featureDisabled' => array_filter([ - 'backend' => false, - 'frontend' => !$this->features->isFeatureEnabled('security.frontend.enforceContentSecurityPolicy'), + 'backend' => [], + 'frontend' => !$this->features->isFeatureEnabled('security.frontend.enforceContentSecurityPolicy') + && !$this->features->isFeatureEnabled('security.frontend.reportContentSecurityPolicy') + ? ['enforce', 'report'] + : [], ]), 'customReporting' => array_filter([ 'BE' => $GLOBALS['TYPO3_CONF_VARS']['BE']['contentSecurityPolicyReportingUrl'] ?? '', diff --git a/typo3/sysext/backend/Resources/Private/Templates/Security/CspModule.html b/typo3/sysext/backend/Resources/Private/Templates/Security/CspModule.html index 0be0a7d1133f76848ad46e08dcfea2660ffc1014..d303e5428288baed05c99e9d4019e99ede58f480 100644 --- a/typo3/sysext/backend/Resources/Private/Templates/Security/CspModule.html +++ b/typo3/sysext/backend/Resources/Private/Templates/Security/CspModule.html @@ -22,8 +22,10 @@ <f:translate key="LLL:EXT:backend/Resources/Private/Language/Modules/content-security-policy.xlf:module.callout.featureDisabled.message" /> </p> <ul class="mb-0"> - <f:for each="{configurationStatus.featureDisabled}" as="value" key="key"> - <li><code>security.{key}.enforceContentSecurityPolicy</code></li> + <f:for each="{configurationStatus.featureDisabled}" as="dispositions" key="scope"> + <f:for each="{dispositions}" as="disposition" iteration="iteration"> + <li><code>security.{scope}.{disposition}ContentSecurityPolicy</code></li> + </f:for> </f:for> </ul> </f:be.infobox> diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Configuration/DispositionConfiguration.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Configuration/DispositionConfiguration.php new file mode 100644 index 0000000000000000000000000000000000000000..190290ecfe22f4c5b00f9cbb0a84f2b00465d7bd --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Configuration/DispositionConfiguration.php @@ -0,0 +1,54 @@ +<?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\Security\ContentSecurityPolicy\Configuration; + +/** + * Represents a `csp.yaml` site configuration section for the disposition modes `enforce` and `report. + * + * @internal + */ +readonly class DispositionConfiguration +{ + public function __construct( + public bool $inheritDefault, + public bool $includeResolutions, + public array $mutations = [], + /** @var array<string, bool> $packages */ + public array $packages = [], + ) {} + + public function resolveEffectivePackages(string ...$packageNames): array + { + if ($this->packages === []) { + return $packageNames; + } + + $effectivePackageNames = []; + if (($this->packages['*'] ?? null) === true) { + $effectivePackageNames = $packageNames; + } + + $dropPackageNames = array_filter($packageNames, fn(string $package): bool => ($this->packages[$package] ?? null) === false); + $effectivePackageNames = array_diff($effectivePackageNames, $dropPackageNames); + + $includePackageNames = array_filter($packageNames, fn(string $package): bool => ($this->packages[$package] ?? null) === true); + $effectivePackageNames = [...$effectivePackageNames, ...array_diff($includePackageNames, $effectivePackageNames)]; + + return $effectivePackageNames; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Configuration/DispositionMapFactory.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Configuration/DispositionMapFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..e5cacee56340093cf8177ba1d432e60beb73c32f --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Configuration/DispositionMapFactory.php @@ -0,0 +1,114 @@ +<?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\Security\ContentSecurityPolicy\Configuration; + +use TYPO3\CMS\Core\Configuration\Features; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition; +use TYPO3\CMS\Core\Type\Map; + +/** + * Transforms a `csp.yaml` site configuration into a configuration model. + * + * @internal + */ +readonly class DispositionMapFactory +{ + public function __construct(private Features $features) {} + + /** + * @return list<Disposition> + */ + public function resolveFallbackDispositions(): array + { + $dispositions = []; + if ($this->features->isFeatureEnabled('security.frontend.enforceContentSecurityPolicy')) { + $dispositions[] = Disposition::enforce; + } + if ($this->features->isFeatureEnabled('security.frontend.reportContentSecurityPolicy')) { + $dispositions[] = Disposition::report; + } + return $dispositions; + } + + /** + * @return Map<Disposition, DispositionConfiguration> + */ + public function buildDispositionMap(array $siteConfiguration): Map + { + $activeAssignment = (bool)($siteConfiguration['active'] ?? true); + // @todo future TYPO3 v14 should explicitly require `active: true` to get rid of the feature fallbacks + if ($activeAssignment === false) { + return new Map(); + } + + $dispositions = new Map(); + // assign site-specific dispositions + foreach (Disposition::cases() as $disposition) { + $assignment = $siteConfiguration[$disposition->value] ?? null; + if ($this->isActive($assignment)) { + $dispositions[$disposition] = $this->buildDispositionConfiguration( + $assignment, + $siteConfiguration + ); + } + } + // in case there is no site-specific configuration, use the fallbacks as defined by top-level features + if (count($dispositions) === 0) { + foreach ($this->resolveFallbackDispositions() as $fallbackDisposition) { + // skip fallbacks in case the disposition was disabled explicitly (e.g. `enforce: false`) + if (($siteConfiguration[$fallbackDisposition->value] ?? null) !== false) { + $dispositions[$fallbackDisposition] = $this->buildDispositionConfiguration( + true, + $siteConfiguration + ); + } + } + } + return $dispositions; + } + + private function isActive(mixed $assignment): bool + { + return $assignment === true || is_array($assignment); + } + + private function buildDispositionConfiguration( + true|array $assignment, + array $siteConfiguration = [] + ): DispositionConfiguration { + if ($assignment === true) { + // take from top-level configuration + // (`includeResolutions` and `packages` are ignored on purpose) + $inheritDefault = $siteConfiguration['inheritDefault'] ?? true; + $includeResolutions = true; + $mutations = $siteConfiguration['mutations'] ?? []; + $packages = []; + } else { + $inheritDefault = $assignment['inheritDefault'] ?? true; + $includeResolutions = $assignment['includeResolutions'] ?? true; + $mutations = $assignment['mutations'] ?? []; + $packages = $assignment['packages'] ?? []; + } + return new DispositionConfiguration( + (bool)$inheritDefault, + (bool)$includeResolutions, + is_array($mutations) ? $mutations : [], + is_array($packages) ? $packages : [], + ); + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Disposition.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Disposition.php new file mode 100644 index 0000000000000000000000000000000000000000..9f0f5352aa7f9f0bcf40decf200e8478bdc89d9d --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Disposition.php @@ -0,0 +1,36 @@ +<?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\Security\ContentSecurityPolicy; + +/** + * Representation of Content-Security-Policy disposition + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP#disposition + */ +enum Disposition: string +{ + case enforce = 'enforce'; + case report = 'report'; + + public function getHttpHeaderName(): string + { + return match ($this) { + self::enforce => 'Content-Security-Policy', + self::report => 'Content-Security-Policy-Report-Only', + }; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationRepository.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationRepository.php index 1466c8ff7905d65e7efdce3005b77e1d087cf49c..b48141b41d1a2328cd9a4139f805466e24d8cb5f 100644 --- a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationRepository.php +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationRepository.php @@ -18,6 +18,8 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionConfiguration; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionMapFactory; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ResolutionRepository; use TYPO3\CMS\Core\Site\Entity\Site; use TYPO3\CMS\Core\Site\SiteFinder; @@ -28,6 +30,9 @@ use TYPO3\CMS\Core\Type\Map; */ final class MutationRepository { + /** + * @var Map<Scope, Map<Disposition, Map<MutationOrigin, MutationCollection>>> + */ private ?Map $resolvedMutations; /** @@ -41,34 +46,32 @@ final class MutationRepository private readonly ModelService $modelService, private readonly ScopeRepository $scopeRepository, private readonly ResolutionRepository $resolutionRepository, + private readonly DispositionMapFactory $dispositionMapFactory, ) { $this->resolvedMutations = null; } /** - * @param bool $resolved whether to include resolved mutations (resolutions) - * @return Map<Scope, Map<MutationOrigin, MutationCollection>> + * @return Map<Scope, Map<Disposition, Map<MutationOrigin, MutationCollection>>> */ - public function findAll(bool $resolved = true): Map + public function findAll(): Map { - if ($resolved) { + if ($this->resolvedMutations === null) { $this->resolveMutations(); - return $this->resolvedMutations; } - return $this->staticMutations; + return $this->resolvedMutations; } /** - * @param bool $resolved whether to include resolved mutations (resolutions) * @return Map<MutationOrigin, MutationCollection> */ - public function findByScope(Scope $scope, bool $resolved = true): Map + public function findByScope(Scope $scope, Disposition $disposition = Disposition::enforce): Map { - if ($resolved) { + if ($this->resolvedMutations === null) { $this->resolveMutations(); - return $this->resolvedMutations[$scope] ?? new Map(); } - return $this->staticMutations[$scope] ?? new Map(); + $scope = $this->reduceScope($scope); + return $this->resolvedMutations[$scope][$disposition] ?? new Map(); } private function resolveMutations(): void @@ -76,58 +79,127 @@ final class MutationRepository if ($this->resolvedMutations !== null) { return; } - $this->resolvedMutations = clone $this->staticMutations; + + $this->resolvedMutations = new Map(); $allScopes = $this->scopeRepository->findAll(); - // fetch resolution mutations from the database + // fetch resolutions from the database & assign them later to the resolved mutations map + $resolutions = new Map(); foreach ($this->resolutionRepository->findAll() as $resolution) { // only for existing scopes (e.g. ignore scopes for sites, that are not existing anymore) if (in_array($resolution->scope, $allScopes, true)) { $mutationOrigin = new MutationOrigin(MutationOriginType::resolution, $resolution->summary); - $target = $this->provideScopeInResolvedMutations($resolution->scope); - $target[$mutationOrigin] = $resolution->mutationCollection; + $scopedTarget = $this->provideScopeInMap($resolution->scope, $resolutions); + $scopedTarget[$mutationOrigin] = $resolution->mutationCollection; + } + } + // assign generic backend and frontend scopes + foreach ([Scope::backend(), Scope::frontend()] as $scope) { + $scopedTarget = $this->provideScopeInMap($scope, $this->resolvedMutations); + $dispositions = $scope === Scope::frontend() + ? $this->dispositionMapFactory->resolveFallbackDispositions() + : [Disposition::enforce]; + foreach ($dispositions as $disposition) { + $disposedTarget = $this->provideDispositionInMap($disposition, $scopedTarget); + if (isset($this->staticMutations[$scope])) { + $disposedTarget->assign($this->staticMutations[$scope]); + } + if (isset($resolutions[$scope])) { + $disposedTarget->assign($resolutions[$scope]); + } } } - // fetch site-specific mutations + // fetch and assign site-specific mutations foreach ($this->scopeRepository->findAllFrontendSites() as $scope) { $site = $this->resolveSite($scope); - $target = $this->provideScopeInResolvedMutations($scope); - $shallInheritDefault = (bool)($site->getConfiguration()['contentSecurityPolicies']['inheritDefault'] ?? true); - if ($shallInheritDefault && $scope->isFrontendSite()) { - foreach ($this->resolvedMutations[Scope::frontend()] ?? [] as $existingOrigin => $existingCollection) { - $target[$existingOrigin] = clone $existingCollection; + $scopedTarget = $this->provideScopeInMap($scope, $this->resolvedMutations); + // fetch site-specific `enforce` and/or `report` disposition configuration + $dispositionMap = $this->dispositionMapFactory->buildDispositionMap( + $site->getConfiguration()['contentSecurityPolicies'] + ); + /** + * @var Disposition $disposition + * @var DispositionConfiguration $dispositionConfiguration + */ + foreach ($dispositionMap as $disposition => $dispositionConfiguration) { + $disposedTarget = $this->provideDispositionInMap($disposition, $scopedTarget); + $disposedTarget->assign($this->resolveStaticMutations($scope, $dispositionConfiguration)); + if ($dispositionConfiguration->includeResolutions && isset($resolutions[$scope])) { + $disposedTarget->assign($resolutions[$scope]); + } + $mutationCollection = $this->resolveFrontendSiteMutationCollection($dispositionConfiguration); + if ($mutationCollection !== null) { + $mutationOrigin = new MutationOrigin(MutationOriginType::site, $scope->siteIdentifier); + $disposedTarget[$mutationOrigin] = $mutationCollection; } } - $mutationCollection = $this->resolveFrontendSiteMutationCollection($site); - if ($mutationCollection !== null) { - $mutationOrigin = new MutationOrigin(MutationOriginType::site, $scope->siteIdentifier); - $target[$mutationOrigin] = $mutationCollection; + } + } + + /** + * Resolves site-specific static mutations, applies `inheritDefault` configuration + * and filters generic static mutations based on the `packages` configuration. + * + * @return Map<MutationOrigin, MutationCollection> + */ + private function resolveStaticMutations(Scope $scope, DispositionConfiguration $dispositionConfiguration): Map + { + $target = new Map(); + $scope = $this->reduceScope($scope); + + if ($dispositionConfiguration->inheritDefault && isset($this->staticMutations[Scope::frontend()])) { + // mutations from `ContentSecurityPolicies.php` for generic frontend scope + $target->assign($this->staticMutations[Scope::frontend()]); + } + // mutations from `ContentSecurityPolicies.php` for a specific site identifier + if (isset($this->staticMutations[$scope])) { + $target->assign($this->staticMutations[$scope]); + } + + // filter mutation origins by effective package names + $packageOrigins = array_filter( + $target->keys(), + static fn(MutationOrigin $origin) => $origin->type === MutationOriginType::package + ); + $packageNames = array_map(static fn(MutationOrigin $origin) => $origin->value, $packageOrigins); + $effectivePackageNames = $dispositionConfiguration->resolveEffectivePackages(...$packageNames); + foreach ($packageOrigins as $mutationOrigin) { + if (!in_array($mutationOrigin->value, $effectivePackageNames, true)) { + unset($target[$mutationOrigin]); } } + return $target; } - private function resolveFrontendSiteMutationCollection(Site $site): ?MutationCollection + private function resolveFrontendSiteMutationCollection(DispositionConfiguration $dispositionConfiguration): ?MutationCollection { - $mutationConfigurations = $site->getConfiguration()['contentSecurityPolicies']['mutations'] ?? []; - if (empty($mutationConfigurations) || !is_array($mutationConfigurations)) { + if ($dispositionConfiguration->mutations === []) { return null; } $mutations = array_map( fn(array $array) => $this->modelService->buildMutationFromArray($array), - $mutationConfigurations + $dispositionConfiguration->mutations ); return new MutationCollection(...$mutations); } + private function provideDispositionInMap(Disposition $disposition, Map $map): Map + { + if (!isset($map[$disposition])) { + $map[$disposition] = new Map(); + } + return $map[$disposition]; + } + /** * @return Map<MutationOrigin, MutationCollection> */ - private function provideScopeInResolvedMutations(Scope $scope): Map + private function provideScopeInMap(Scope $scope, Map $map): Map { $reducedScope = $this->reduceScope($scope); - if (!isset($this->resolvedMutations[$reducedScope])) { - $this->resolvedMutations[$reducedScope] = new Map(); + if (!isset($map[$reducedScope])) { + $map[$reducedScope] = new Map(); } - return $this->resolvedMutations[$reducedScope]; + return $map[$reducedScope]; } /** diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/PolicyProvider.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/PolicyProvider.php index f8fd9f51c9fc7547a6dbd1442f3f39e79caa9bb0..9d0285038fd8b8364cbc943fac3aab2f700e472a 100644 --- a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/PolicyProvider.php +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/PolicyProvider.php @@ -52,12 +52,15 @@ final class PolicyProvider /** * Provides the complete, dynamically mutated policy to be used in HTTP responses. */ - public function provideFor(Scope $scope, ?ServerRequestInterface $request = null): Policy - { + public function provideFor( + Scope $scope, + Disposition $disposition = Disposition::enforce, + ?ServerRequestInterface $request = null, + ): Policy { // @todo add policy cache per scope $defaultPolicy = new Policy(); $mutationCollections = iterator_to_array( - $this->mutationRepository->findByScope($scope), + $this->mutationRepository->findByScope($scope, $disposition), false ); // add temporary(!) mutations that were collected during processing this request diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php index 3b619d9cd9961522a9c90442acbd693cd88e35f7..63653184c428e126703348afbca56e0f87da89a0 100644 --- a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php @@ -17,6 +17,8 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition; + /** * @internal */ @@ -39,6 +41,11 @@ class ReportDetails extends \ArrayObject implements \JsonSerializable ); } + public function resolveDisposition(): Disposition + { + return Disposition::tryFrom($this['disposition'] ?? '') ?? Disposition::enforce; + } + protected static function toCamelCase(string $value): string { return lcfirst(str_replace('-', '', ucwords($value, '-'))); diff --git a/typo3/sysext/core/Configuration/DefaultConfiguration.php b/typo3/sysext/core/Configuration/DefaultConfiguration.php index 4a5b057b0a57b7e98aa0198a2cf3de9f62772ebb..712dc2496108a7a1d8652fd7ef35bc65cb8bba51 100644 --- a/typo3/sysext/core/Configuration/DefaultConfiguration.php +++ b/typo3/sysext/core/Configuration/DefaultConfiguration.php @@ -85,6 +85,7 @@ return [ 'security.backend.htmlSanitizeRte' => false, 'security.backend.enforceReferrer' => true, 'security.frontend.enforceContentSecurityPolicy' => false, + 'security.frontend.reportContentSecurityPolicy' => false, 'security.frontend.allowInsecureSiteResolutionByQueryParameters' => false, 'security.frontend.allowInsecureFrameOptionInShowImageController' => false, ], diff --git a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml index 9e7021d19eeb56dbc33f46d2e4019fc5d75d3b5f..462805f4966e155099e4581efd8ba24c63303d67 100644 --- a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml +++ b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml @@ -195,7 +195,10 @@ SYS: required `Referer` header. As this is a potential security risk, it is recommended to enable this option.' security.frontend.enforceContentSecurityPolicy: type: bool - description: 'If on, HTTP Content-Security-Policy header will be applied for each HTTP frontend request.' + description: 'If on, HTTP Content-Security-Policy header will be applied for each HTTP frontend request. This can be overruled using a csp.yaml site configuration.' + security.frontend.reportContentSecurityPolicy: + type: bool + description: 'If on, HTTP Content-Security-Policy-Report-Only header will be applied for each HTTP frontend request. This can be overruled using a csp.yaml site configuration.' security.frontend.allowInsecureFrameOptionInShowImageController: type: bool description: 'If on, the eID Script "tx_cms_showpic" respects the GET parameter "frame" without being signed. Should not be enabled as this allows uncontrolled resource consumption.' diff --git a/typo3/sysext/core/Documentation/Changelog/12.4.x/Important-101580-IntroduceContentSecurityPolicyReportOnlyHandling.rst b/typo3/sysext/core/Documentation/Changelog/12.4.x/Important-101580-IntroduceContentSecurityPolicyReportOnlyHandling.rst new file mode 100644 index 0000000000000000000000000000000000000000..73816552700a254fdab4f2cc923e41e1b1a501eb --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.4.x/Important-101580-IntroduceContentSecurityPolicyReportOnlyHandling.rst @@ -0,0 +1,29 @@ +.. include:: /Includes.rst.txt + +.. _important-101580-1723653576: + +=========================================================================== +Important: #101580 - Introduce Content-Security-Policy-Report-Only handling +=========================================================================== + +See :issue:`101580` + +Description +=========== + +The feature flag `security.frontend.reportContentSecurityPolicy` +(:php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.reportContentSecurityPolicy']`) +can be used to apply the `Content-Security-Policy-Report-Only` HTTP header for +frontend responses. + +When both feature flags are activated, both headers are sent. +You can deactivate one disposition in the site-specific configuration. + +This allows to test and assess the potential impact on introducing +Content-Security-Policy in the frontend - without actually blocking +any functionality. + +This behavior can be controlled on a site-specific scope as well, see +:ref:`Important: #104549 - Introduce site-specific Content-Security-Policy-Disposition <important-104549-1723461851>`. + +.. index:: Frontend, LocalConfiguration, ext:frontend diff --git a/typo3/sysext/core/Documentation/Changelog/12.4.x/Important-104549-IntroduceSiteSpecificContentSecurityPolicyDisposition.rst b/typo3/sysext/core/Documentation/Changelog/12.4.x/Important-104549-IntroduceSiteSpecificContentSecurityPolicyDisposition.rst new file mode 100644 index 0000000000000000000000000000000000000000..c4ee46c721323bb4fbc89e1bbba8f4ab7f84c9c4 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.4.x/Important-104549-IntroduceSiteSpecificContentSecurityPolicyDisposition.rst @@ -0,0 +1,135 @@ +.. include:: /Includes.rst.txt + +.. _important-104549-1723461851: + +================================================================================ +Important: #104549 - Introduce site-specific Content-Security-Policy-Disposition +================================================================================ + +See :issue:`104549` + +Description +=========== + +The feature flags :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.enforceContentSecurityPolicy']` +and :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.reportContentSecurityPolicy']` apply +Content-Security-Policy headers to any frontend site. The dedicated :file:`sites/<my-site>/csp.yaml` can now be +used as alternative to declare the desired disposition of `Content-Security-Policy` and +`Content-Security-Policy-Report-Only` individually. + +It now is also possible, to apply both `Content-Security-Policy` and `Content-Security-Policy-Report-Only` +HTTP headers at the same time with different directives for a particular site. Besides that it is possible +to disable the disposition completely for a site. + +The following new configuration schemes were introduced for :file:`sites/<my-site>/csp.yaml`: + +* `active (false)` for disabling CSP for a particular site, which overrules any other setting for `enforce` or `report` +* `enforce (bool|disposition-array)` for compiling the `Content-Security-Policy` HTTP header +* `report (bool|disposition-array)` for compiling the `Content-Security-Policy-Report-Only` HTTP header + +The `disposition-array` for `enforce` and `report` allows these properties: + +* `inheritDefault (bool)` inherits default site-unspecific frontend policy mutations (`true` per default) +* `includeResolutions (bool)` includes dynamic resolutions, as persisted in the database via backend module (`true` per default) +* `mutations (mutation-item-array)` defines additional directive mutations to be applied to the specific site +* `packages (package-item-array)` defines packages/extensions whose static CSP mutations shall be dropped or included + +Example: Disable Content-Security-Policy +---------------------------------------- + +The following example would completely disable CSP for a particular site. + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + # `active` is enabled per default if omitted + active: false + +Example: Use `report` disposition +--------------------------------- + +The following example would dispose only `Content-Security-Policy-Report-Only` +for a particular site (since the `enforce` property is not given). + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + report: + # `inheritDefault` is enabled per default if omitted + inheritDefault: true + mutations: + - mode: extend + directive: img-src + sources: + - https://*.typo3.org + +The following example is equivalent to the previous, but shows that the +legacy configuration (having `inheritDefault` and `mutations` on the top-level) +is still supported. + +The effective HTTP headers would then be resolved from the active feature flags +`security.frontend.enforceContentSecurityPolicy` and +`security.frontend.reportContentSecurityPolicy` - in case both flags are active, +both HTTP headers `Content-Security-Policy` and `Content-Security-Policy-Read-Only` +would be used. + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + # `inheritDefault` is enabled per default if omitted + inheritDefault: true + mutations: + - mode: extend + directive: img-src + sources: + - https://*.typo3.org + +Example: Use `enforce` and `report` dispositions at the same time +----------------------------------------------------------------- + +The following example would dispose `Content-Security-Policy` (`enforce`) +and `Content-Security-Policy-Report-Only` (`report`) for a particular site. + +This allows to test new CSP directives in the frontend - the example drops +the static CSP directives of the package `my-vendor/my-package` in the +enforced disposition and only applies it to the reporting disposition. + +.. code-block:: yaml + :caption: config/sites/<my-site>/csp.yaml + + enforce: + # `inheritDefault` is enabled per default if omitted + inheritDefault: true + # `includeResolutions` is enabled per default if omitted + includeResolutions: true + mutations: + - mode: extend + directive: img-src + sources: + - https://*.typo3.org + packages: + # all (`*`) packages shall be included (`true`) + '*': true + # the package `my-vendor/my-package` shall be dropped (`false`) + my-vendor/my-package: false + + report: + # `inheritDefault` is enabled per default if omitted + inheritDefault: true + # `includeResolutions` is enabled per default if omitted + includeResolutions: true + mutations: + - mode: extend + directive: img-src + sources: + - https://*.my-vendor.example.org/ + # the `packages` section can be omitted in this case, since all packages + # listed there shall be included - which is the default behavior in case + # `packages` would not be configured + packages: + # all (`*`) packages shall be included (`true`) + '*': true + # the package `my-vendor/my-package` shall be dropped (`false`) + my-vendor/my-package: true + +.. index:: Frontend, YAML, ext:frontend diff --git a/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Configuration/DispositionConfigurationTest.php b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Configuration/DispositionConfigurationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a08e666be4a2a096836cb99235ba4e45c0ba72e3 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Configuration/DispositionConfigurationTest.php @@ -0,0 +1,91 @@ +<?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\Tests\Unit\Security\ContentSecurityPolicy\Configuration; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionConfiguration; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +final class DispositionConfigurationTest extends UnitTestCase +{ + public static function effectivePackagesAreResolvedDataProvider(): \Generator + { + yield 'empty packages' => [ + 'packages' => [], + 'packageNames' => ['a/a', 'b/b', 'c/c'], + 'expectation' => ['a/a', 'b/b', 'c/c'], + ]; + yield 'missing definition for `*`' => [ + 'packages' => [ + // missing here: '*' => true, + 'b/b' => true, + ], + 'packageNames' => ['a/a', 'b/b', 'c/c'], + 'expectation' => ['b/b'], + ]; + yield 'all to be included' => [ + 'packages' => [ + '*' => true, + ], + 'packageNames' => ['a/a', 'b/b', 'c/c'], + 'expectation' => ['a/a', 'b/b', 'c/c'], + ]; + yield 'all to be included, b/b & c/c dropped' => [ + 'packages' => [ + '*' => true, + 'b/b' => false, + 'c/c' => false, + ], + 'packageNames' => ['a/a', 'b/b', 'c/c'], + 'expectation' => ['a/a'], + ]; + yield 'all to be included, ignoring configured non/existing' => [ + 'packages' => [ + '*' => true, + 'non/existing' => true, + ], + 'packageNames' => ['a/a', 'b/b', 'c/c'], + 'expectation' => ['a/a', 'b/b', 'c/c'], + ]; + yield 'all to be dropped' => [ + 'packages' => [ + '*' => false, + ], + 'packageNames' => ['a/a', 'b/b', 'c/c'], + 'expectation' => [], + ]; + yield 'all to be dropped, b/b & c/c included' => [ + 'packages' => [ + '*' => false, + 'b/b' => true, + 'c/c' => true, + ], + 'packageNames' => ['a/a', 'b/b', 'c/c'], + 'expectation' => ['b/b', 'c/c'], + ]; + } + + #[DataProvider('effectivePackagesAreResolvedDataProvider')] + #[Test] + public function effectivePackagesAreResolved(array $packages, array $packageNames, array $expectation): void + { + $subject = new DispositionConfiguration(true, true, [], $packages); + self::assertSame($expectation, $subject->resolveEffectivePackages(...$packageNames)); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Configuration/DispositionMapFactoryTest.php b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Configuration/DispositionMapFactoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0f9021d039abeb971bf6837a6b6da6e627013580 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Configuration/DispositionMapFactoryTest.php @@ -0,0 +1,281 @@ +<?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\Tests\Unit\Security\ContentSecurityPolicy\Configuration; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Configuration\Features; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionConfiguration; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionMapFactory; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +final class DispositionMapFactoryTest extends UnitTestCase +{ + public static function featuresAreReflectedInDispositionMapDataProvider(): \Generator + { + yield 'all features disabled' => [ + 'features' => [ + 'security.frontend.enforceContentSecurityPolicy' => false, + 'security.frontend.reportContentSecurityPolicy' => false, + ], + 'expectation' => [], + ]; + yield 'enforce feature enabled' => [ + 'features' => [ + 'security.frontend.enforceContentSecurityPolicy' => true, + 'security.frontend.reportContentSecurityPolicy' => false, + ], + 'expectation' => [Disposition::enforce], + ]; + yield 'report feature enabled' => [ + 'features' => [ + 'security.frontend.enforceContentSecurityPolicy' => false, + 'security.frontend.reportContentSecurityPolicy' => true, + ], + 'expectation' => [Disposition::report], + ]; + yield 'both features enabled' => [ + 'features' => [ + 'security.frontend.enforceContentSecurityPolicy' => true, + 'security.frontend.reportContentSecurityPolicy' => true, + ], + 'expectation' => [Disposition::enforce, Disposition::report], + ]; + } + + #[DataProvider('featuresAreReflectedInDispositionMapDataProvider')] + #[Test] + public function featuresAreReflectedInDispositionMap(array $features, array $expectation): void + { + $featuresMock = $this->createFeaturesMock($features); + $subject = new DispositionMapFactory($featuresMock); + $result = $subject->buildDispositionMap([]); + self::assertSame($expectation, $result->keys()); + } + + public static function configurationIsReflectedInDispositionMapDataProvider(): \Generator + { + $allFeaturesDisabled = [ + 'security.frontend.enforceContentSecurityPolicy' => false, + 'security.frontend.reportContentSecurityPolicy' => false, + ]; + $bothFeaturesEnabled = [ + 'security.frontend.enforceContentSecurityPolicy' => true, + 'security.frontend.reportContentSecurityPolicy' => true, + ]; + + yield 'all features disabled, active:false' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['active' => false], + 'expectation' => [], + ]; + yield 'both features enabled, active:false' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['active' => false], + 'expectation' => [], + ]; + // @todo `active: true` for explicitly activating CSP is not in effect yet (v14 topic) + yield 'all features disabled, active:true' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['active' => true], + 'expectation' => [], + ]; + // @todo `active: true` for explicitly activating CSP is not in effect yet (v14 topic) + yield 'both features enabled, active:true' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['active' => true], + 'expectation' => [Disposition::enforce, Disposition::report], + ]; + yield 'all features disabled, enforce:false' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['enforce' => false], + 'expectation' => [], + ]; + yield 'both features enabled, enforce:false' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['enforce' => false], + 'expectation' => [Disposition::report], + ]; + yield 'all features disabled, enforce:true' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['enforce' => true], + 'expectation' => [Disposition::enforce], + ]; + yield 'both features enabled, enforce:true' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['enforce' => true], + 'expectation' => [Disposition::enforce], + ]; + yield 'all features disabled, enforce:array' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['enforce' => []], + 'expectation' => [Disposition::enforce], + ]; + yield 'both features enabled, enforce:array' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['enforce' => []], + 'expectation' => [Disposition::enforce], + ]; + yield 'all features disabled, report:false' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['report' => false], + 'expectation' => [], + ]; + yield 'both features enabled, report:false' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['report' => false], + 'expectation' => [Disposition::enforce], + ]; + yield 'all features disabled, report:true' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['report' => true], + 'expectation' => [Disposition::report], + ]; + yield 'both features enabled, report:true' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['report' => true], + 'expectation' => [Disposition::report], + ]; + yield 'all features disabled, report:array' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['report' => []], + 'expectation' => [Disposition::report], + ]; + yield 'both features enabled, report:array' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['report' => []], + 'expectation' => [Disposition::report], + ]; + yield 'all features disabled, enforce:false & report:false' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['enforce' => false, 'report' => false], + 'expectation' => [], + ]; + yield 'both features enabled, enforce:false & report:false' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['enforce' => false, 'report' => false], + 'expectation' => [], + ]; + yield 'all features disabled, enforce:true & report:true' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['enforce' => true, 'report' => true], + 'expectation' => [Disposition::enforce, Disposition::report], + ]; + yield 'both features enabled, enforce:true & report:true' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['enforce' => true, 'report' => true], + 'expectation' => [Disposition::enforce, Disposition::report], + ]; + yield 'all features disabled, enforce:array & report:array' => [ + 'features' => $allFeaturesDisabled, + 'configuration' => ['enforce' => [], 'report' => []], + 'expectation' => [Disposition::enforce, Disposition::report], + ]; + yield 'both features enabled, enforce:array & report:array' => [ + 'features' => $bothFeaturesEnabled, + 'configuration' => ['enforce' => [], 'report' => []], + 'expectation' => [Disposition::enforce, Disposition::report], + ]; + } + + #[DataProvider('configurationIsReflectedInDispositionMapDataProvider')] + #[Test] + public function configurationIsReflectedInDispositionMap(array $features, array $configuration, array $expectation): void + { + $featuresMock = $this->createFeaturesMock($features); + $subject = new DispositionMapFactory($featuresMock); + $result = $subject->buildDispositionMap($configuration); + self::assertSame($expectation, $result->keys()); + } + + #[Test] + public function enforceConfigurationIsReflectedInEnforceDispositionConfiguration(): void + { + // note: `security.frontend.enforceContentSecurityPolicy` is enabled per default for this test + $featuresMock = $this->createFeaturesMock(); + $subject = new DispositionMapFactory($featuresMock); + + $enforceConfiguration = [ + 'inheritDefault' => false, + 'includeResolutions' => false, + 'mutations' => [ + [ + 'mode' => 'extend', + 'directive' => Directive::ImgSrc, + 'sources' => [], + ], + ], + 'packages' => [ + 'my-vendor/my-package' => true, + ], + ]; + $result = $subject->buildDispositionMap(['enforce' => $enforceConfiguration]); + + self::assertInstanceOf(DispositionConfiguration::class, $result[Disposition::enforce]); + self::assertSame($enforceConfiguration['inheritDefault'], $result[Disposition::enforce]->inheritDefault); + self::assertSame($enforceConfiguration['includeResolutions'], $result[Disposition::enforce]->includeResolutions); + self::assertSame($enforceConfiguration['mutations'], $result[Disposition::enforce]->mutations); + self::assertSame($enforceConfiguration['packages'], $result[Disposition::enforce]->packages); + } + + #[Test] + public function legacyTopLevelConfigurationIsReflectedInEnforceDispositionConfiguration(): void + { + // note: `security.frontend.enforceContentSecurityPolicy` is enabled per default for this test + $featuresMock = $this->createFeaturesMock(); + $subject = new DispositionMapFactory($featuresMock); + + $topLevelConfiguration = [ + 'inheritDefault' => false, + // `includeResolutions` will be ignored in top-level configuration + 'includeResolutions' => false, + 'mutations' => [ + [ + 'mode' => 'extend', + 'directive' => Directive::ImgSrc, + 'sources' => [], + ], + ], + // `packages` will be ignored in top-level configuration + 'packages' => [ + 'my-vendor/my-package' => true, + ], + ]; + $result = $subject->buildDispositionMap($topLevelConfiguration); + + self::assertInstanceOf(DispositionConfiguration::class, $result[Disposition::enforce]); + self::assertSame($topLevelConfiguration['inheritDefault'], $result[Disposition::enforce]->inheritDefault); + // `includeResolutions` is ignored in top-level configuration + self::assertTrue($result[Disposition::enforce]->includeResolutions); + self::assertSame($topLevelConfiguration['mutations'], $result[Disposition::enforce]->mutations); + // `packages` are ignored in top-level configuration + self::assertEmpty($result[Disposition::enforce]->packages); + } + + private function createFeaturesMock(?array $features = null): Features + { + $features ??= ['security.frontend.enforceContentSecurityPolicy' => true]; + $featuresMock = $this->createMock(Features::class); + $featuresMock->method('isFeatureEnabled')->willReturnCallback( + static fn(string $featureName) => !empty($features[$featureName]) + ); + return $featuresMock; + } +} diff --git a/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php b/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php index c80aed7fbb1ec2c02a0607015fdac207dd4499b7..329b3da7e22915ff8ba97e2dad74f39ca4fc10b3 100644 --- a/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php +++ b/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php @@ -24,11 +24,12 @@ use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; -use TYPO3\CMS\Core\Configuration\Features; use TYPO3\CMS\Core\Core\RequestId; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\DispositionMapFactory; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; +use TYPO3\CMS\Core\Site\Entity\Site; /** * Adds Content-Security-Policy headers to response. @@ -38,25 +39,28 @@ use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; final readonly class ContentSecurityPolicyHeaders implements MiddlewareInterface { public function __construct( - private Features $features, private RequestId $requestId, private LoggerInterface $logger, #[Autowire(service: 'cache.assets')] private FrontendInterface $cache, private PolicyProvider $policyProvider, + private DispositionMapFactory $dispositionMapFactory, ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + $site = $request->getAttribute('site'); + $dispositionMap = $this->dispositionMapFactory->buildDispositionMap( + $site instanceof Site ? $site->getConfiguration()['contentSecurityPolicies'] : [] + ); // return early in case CSP shall not be used - if (!$this->features->isFeatureEnabled('security.frontend.enforceContentSecurityPolicy')) { + if ($dispositionMap->keys() === []) { return $handler->handle($request); } // make sure, the nonce value is set before processing the remaining middlewares $request = $request->withAttribute('nonce', $this->requestId->nonce); $response = $handler->handle($request); - $site = $request->getAttribute('site'); $scope = Scope::frontendSite($site); if ($response->hasHeader('Content-Security-Policy') || $response->hasHeader('Content-Security-Policy-Report-Only')) { $this->logger->info('Content-Security-Policy not enforced due to existence of custom header', [ @@ -66,14 +70,20 @@ final readonly class ContentSecurityPolicyHeaders implements MiddlewareInterface return $response; } - $policy = $this->policyProvider->provideFor($scope, $request); - if ($policy->isEmpty()) { - return $response; - } - $reportingUri = $this->policyProvider->getReportingUrlFor($scope, $request); - if ($reportingUri !== null) { - $policy = $policy->report(UriValue::fromUri($reportingUri)); + foreach ($dispositionMap->keys() as $disposition) { + $policy = $this->policyProvider->provideFor($scope, $disposition, $request); + if ($policy->isEmpty()) { + continue; + } + $reportingUri = $this->policyProvider->getReportingUrlFor($scope, $request); + if ($reportingUri !== null) { + $policy = $policy->report(UriValue::fromUri($reportingUri)); + } + $response = $response->withHeader( + $disposition->getHttpHeaderName(), + $policy->compile($this->requestId->nonce, $this->cache) + ); } - return $response->withHeader('Content-Security-Policy', $policy->compile($this->requestId->nonce, $this->cache)); + return $response; } } diff --git a/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/ContentSecurityPolicyMutationsProvider.php b/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/ContentSecurityPolicyMutationsProvider.php index 19897d88511c611e5b9e9f053b55ec8bc7845bed..537206e5d095539e35d0b818f180de4b5884c42f 100644 --- a/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/ContentSecurityPolicyMutationsProvider.php +++ b/typo3/sysext/lowlevel/Classes/ConfigurationModuleProvider/ContentSecurityPolicyMutationsProvider.php @@ -18,6 +18,7 @@ declare(strict_types=1); namespace TYPO3\CMS\Lowlevel\ConfigurationModuleProvider; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Disposition; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ModelService; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationOrigin; @@ -42,40 +43,48 @@ class ContentSecurityPolicyMutationsProvider extends AbstractProvider $data = []; /** * @var Scope $scope - * @var Map<MutationOrigin, MutationCollection> $scopeDetails + * @var Map<Disposition, Map<MutationOrigin, MutationCollection>> $scopeDetails */ foreach ($this->mutationRepository->findAll() as $scope => $scopeDetails) { $policy = new Policy(); $scopeValue = (string)$scope; $data[$scopeValue] = []; - /** - * @var MutationOrigin $mutationOrigin - * @var MutationCollection $mutationCollection - */ - foreach ($scopeDetails as $mutationOrigin => $mutationCollection) { - $policy->mutate($mutationCollection); - $mutationOriginValue = sprintf( - "%s '%s'", - $mutationOrigin->type->value, - $mutationOrigin->value - ); - foreach ($mutationCollection->mutations as $mutation) { - $sourceValues = array_map( - // like `ModelService::compileSources()`, but for a single item & without a nonce - fn(SourceInterface $source) => $source instanceof SourceValueInterface - ? $source->compile() - : $this->modelService->serializeSource($source), - $mutation->sources - ); - $data[$scopeValue][$mutationOriginValue][] = sprintf( - '%s: %s %s', - $mutation->mode->value, - $mutation->directive->value, - implode(' ', $sourceValues) + + foreach ($scopeDetails as $disposition => $dispositionDetails) { + $data[$scopeValue][$disposition->value] = []; + + /** + * @var MutationOrigin $mutationOrigin + * @var MutationCollection $mutationCollection + */ + foreach ($dispositionDetails as $mutationOrigin => $mutationCollection) { + $policy->mutate($mutationCollection); + $mutationOriginValue = sprintf( + "%s '%s'", + $mutationOrigin->type->value, + $mutationOrigin->value ); + foreach ($mutationCollection->mutations as $mutation) { + $sourceValues = array_map( + // like `ModelService::compileSources()`, but for a single item & without a nonce + fn(SourceInterface $source) => $source instanceof SourceValueInterface + ? $source->compile() + : $this->modelService->serializeSource($source), + $mutation->sources + ); + $data[$scopeValue][$disposition->value][$mutationOriginValue][] = sprintf( + '%s: %s %s', + $mutation->mode->value, + $mutation->directive->value, + implode(' ', $sourceValues) + ); + } } + $data[$scopeValue][$disposition->value] = [ + '@policy' => $policy->compile($nonce), + ...$data[$scopeValue][$disposition->value], + ]; } - $data[$scopeValue] = ['@policy' => $policy->compile($nonce), ...$data[$scopeValue]]; } ArrayUtility::naturalKeySortRecursive($data); return $data;