diff --git a/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php b/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php new file mode 100644 index 0000000000000000000000000000000000000000..319d9cea44bb8f16296edf29e9f41fe345459e92 --- /dev/null +++ b/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php @@ -0,0 +1,69 @@ +<?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\Backend\Middleware; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\LoggerInterface; +use TYPO3\CMS\Core\Configuration\Features; +use TYPO3\CMS\Core\Core\RequestId; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; + +/** + * Adds Content-Security-Policy headers to response. + * + * @internal + */ +final class ContentSecurityPolicyHeaders implements MiddlewareInterface +{ + public function __construct( + private readonly Features $features, + private readonly RequestId $requestId, + private readonly LoggerInterface $logger, + private readonly PolicyProvider $policyProvider, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $request = $request->withAttribute('nonce', $this->requestId->nonce); + $response = $handler->handle($request); + + if (!$this->features->isFeatureEnabled('security.backend.enforceContentSecurityPolicy')) { + return $response; + } + + $scope = Scope::backend(); + 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', [ + 'scope' => (string)$scope, + 'uri' => (string)$request->getUri(), + ]); + return $response; + } + + $policy = $this->policyProvider->provideFor($scope); + if ($policy->isEmpty()) { + return $response; + } + return $response->withHeader('Content-Security-Policy', (string)$policy); + } +} diff --git a/typo3/sysext/backend/Classes/Template/PageRendererBackendSetupTrait.php b/typo3/sysext/backend/Classes/Template/PageRendererBackendSetupTrait.php index 7d5f6873a49edd222af8d6974a6a7e8e1b2643dc..22d0328ab125367b34302a2be7420d6e6ad2f385 100644 --- a/typo3/sysext/backend/Classes/Template/PageRendererBackendSetupTrait.php +++ b/typo3/sysext/backend/Classes/Template/PageRendererBackendSetupTrait.php @@ -55,6 +55,10 @@ trait PageRendererBackendSetupTrait $pageRenderer->setLanguage($languageService->lang); $pageRenderer->setMetaTag('name', 'viewport', 'width=device-width, initial-scale=1'); $pageRenderer->setFavIcon($this->getBackendFavicon($extensionConfiguration, $request)); + $nonce = $request->getAttribute('nonce'); + if ($nonce !== null) { + $pageRenderer->setNonce($nonce); + } $this->loadStylesheets($pageRenderer); } diff --git a/typo3/sysext/backend/Configuration/ContentSecurityPolicies.php b/typo3/sysext/backend/Configuration/ContentSecurityPolicies.php new file mode 100644 index 0000000000000000000000000000000000000000..3a57b2ed4be8326789595e27326294e7bfea0f60 --- /dev/null +++ b/typo3/sysext/backend/Configuration/ContentSecurityPolicies.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace TYPO3\CMS\Backend; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme; +use TYPO3\CMS\Core\Type\Map; + +/** + * Provides a simple basic Content-Security-Policy for the generic backend scope. + */ +return Map::fromEntries([ + Scope::backend(), + new MutationCollection( + new Mutation(MutationMode::Extend, Directive::DefaultSrc, SourceKeyword::self), + // script-src 'nonce-...' required for importmaps + new Mutation(MutationMode::Extend, Directive::ScriptSrc, SourceKeyword::nonceProxy), + // `style-src 'unsafe-inline'` required for lit in safari and firefox to allow inline <style> tags + // (for browsers that do not support https://caniuse.com/mdn-api_shadowroot_adoptedstylesheets) + new Mutation(MutationMode::Extend, Directive::StyleSrc, SourceKeyword::unsafeInline), + // `style-src-attr 'unsafe-inline'` required for remaining inline styles, which is okay for color & dimension + // (e.g. `<div style="color: #000">` - but NOT having the possibility to use any other assets/files/URIs) + new Mutation(MutationMode::Set, Directive::StyleSrcAttr, SourceKeyword::unsafeInline), + // allow `data:` images + new Mutation(MutationMode::Extend, Directive::ImgSrc, SourceScheme::data), + // muuri.js is creating workers from `blob:` (?!?) + new Mutation(MutationMode::Set, Directive::WorkerSrc, SourceKeyword::self, SourceScheme::blob), + // `frame-src blob:` required for es-module-shims blob: URLs + new Mutation(MutationMode::Extend, Directive::FrameSrc, SourceScheme::blob), + ), +]); diff --git a/typo3/sysext/backend/Configuration/RequestMiddlewares.php b/typo3/sysext/backend/Configuration/RequestMiddlewares.php index 86b5a6d1a26e8f177fe9c578627af28938b94235..b2d44396c7359e43c98585cf47fd3586890bfea9 100644 --- a/typo3/sysext/backend/Configuration/RequestMiddlewares.php +++ b/typo3/sysext/backend/Configuration/RequestMiddlewares.php @@ -78,10 +78,17 @@ return [ ], ], /** internal: do not use or reference this middleware in your own code */ + 'typo3/cms-backend/csp-headers' => [ + 'target' => \TYPO3\CMS\Backend\Middleware\ContentSecurityPolicyHeaders::class, + 'after' => [ + 'typo3/cms-backend/output-compression', + ], + ], + /** internal: do not use or reference this middleware in your own code */ 'typo3/cms-backend/response-headers' => [ 'target' => \TYPO3\CMS\Backend\Middleware\AdditionalResponseHeaders::class, 'after' => [ - 'typo3/cms-backend/output-compression', + 'typo3/cms-backend/csp-headers', ], ], /** internal: do not use or reference this middleware in your own code */ diff --git a/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php b/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php index 4513b2d57a12e331ae289b05812254cba2c48229..3e7733a62eae394637624f691a731bf243f3d76d 100644 --- a/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php +++ b/typo3/sysext/core/Classes/Configuration/SiteConfiguration.php @@ -47,18 +47,14 @@ class SiteConfiguration implements SingletonInterface { protected PhpFrontend $cache; - /** - * @var string - */ - protected $configPath; + protected string $configPath; /** * Config yaml file name. * * @internal - * @var string */ - protected $configFileName = 'config.yaml'; + protected string $configFileName = 'config.yaml'; /** * YAML file name with all settings. @@ -67,13 +63,19 @@ class SiteConfiguration implements SingletonInterface */ protected string $settingsFileName = 'settings.yaml'; + /** + * YAML file name with all settings related to Content-Security-Policies. + * + * @internal + */ + protected string $contentSecurityFileName = 'csp.yaml'; + /** * Identifier to store all configuration data in cache_core cache. * * @internal - * @var string */ - protected $cacheIdentifier = 'sites-configuration'; + protected string $cacheIdentifier = 'sites-configuration'; /** * Cache stores all configuration as Site objects, as long as they haven't been changed. @@ -150,9 +152,11 @@ class SiteConfiguration implements SingletonInterface // cast $identifier to string, as the identifier can potentially only consist of (int) digit numbers $identifier = (string)$identifier; $siteSettings = $this->getSiteSettings($identifier, $configuration); + $configuration['contentSecurityPolicies'] = $this->getContentSecurityPolicies($identifier); + $rootPageId = (int)($configuration['rootPageId'] ?? 0); if ($rootPageId > 0) { - $sites[$identifier] = GeneralUtility::makeInstance(Site::class, $identifier, $rootPageId, $configuration, $siteSettings); + $sites[$identifier] = new Site($identifier, $rootPageId, $configuration, $siteSettings); } } $this->firstLevelCache = $sites; @@ -245,6 +249,16 @@ class SiteConfiguration implements SingletonInterface return new SiteSettings($settings); } + protected function getContentSecurityPolicies(string $siteIdentifier): array + { + $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->contentSecurityFileName; + if (file_exists($fileName)) { + $loader = GeneralUtility::makeInstance(YamlFileLoader::class); + return $loader->load(GeneralUtility::fixWindowsFilePath($fileName), YamlFileLoader::PROCESS_IMPORTS); + } + return []; + } + public function writeSettings(string $siteIdentifier, array $settings): void { $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName; diff --git a/typo3/sysext/core/Classes/Core/RequestId.php b/typo3/sysext/core/Classes/Core/RequestId.php index 2b56d0839e555cf70768df8d3e3da7d1b125d0fe..f7b428b1addea97d42e4dc960c3cffeb90bc0d5c 100644 --- a/typo3/sysext/core/Classes/Core/RequestId.php +++ b/typo3/sysext/core/Classes/Core/RequestId.php @@ -17,22 +17,28 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Core; -use TYPO3\CMS\Core\Utility\StringUtility; +use TYPO3\CMS\Core\Security\Nonce; /** * @internal */ final class RequestId { - private string $requestId; + public readonly string $long; + public readonly string $short; + public readonly int $microtime; + public readonly Nonce $nonce; public function __construct() { - $this->requestId = substr(md5(StringUtility::getUniqueId()), 0, 13); + $this->long = bin2hex(random_bytes(20)); + $this->short = substr($this->long, 0, 13); + $this->microtime = (int)(microtime(true) * 1000000); + $this->nonce = Nonce::create(); } public function __toString(): string { - return $this->requestId; + return $this->short; } } diff --git a/typo3/sysext/core/Classes/Http/Security/ReferrerEnforcer.php b/typo3/sysext/core/Classes/Http/Security/ReferrerEnforcer.php index 363e83bf2ff00c364e6f9a109167206a5efdfef9..ee2cde27d5920ea19e3b1780ac08dcdce6b161c2 100644 --- a/typo3/sysext/core/Classes/Http/Security/ReferrerEnforcer.php +++ b/typo3/sysext/core/Classes/Http/Security/ReferrerEnforcer.php @@ -21,6 +21,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use TYPO3\CMS\Core\Http\HtmlResponse; use TYPO3\CMS\Core\Http\NormalizedParams; +use TYPO3\CMS\Core\Security\Nonce; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\PathUtility; @@ -64,6 +65,7 @@ class ReferrerEnforcer } $flags = $options['flags'] ?? []; $expiration = $options['expiration'] ?? 5; + $nonce = $this->request->getAttribute('nonce'); // referrer is missing and route requested to refresh // (created HTML refresh to enforce having referrer) if (($this->request->getQueryParams()['referrer-refresh'] ?? 0) <= time() @@ -82,16 +84,20 @@ class ReferrerEnforcer $scriptUri = $this->resolveAbsoluteWebPath( 'EXT:core/Resources/Public/JavaScript/ReferrerRefresh.js' ); + $attributes = ['src' => $scriptUri]; + if ($nonce instanceof Nonce) { + $attributes['nonce'] = $nonce->b64; + } // simulating navigate event by clicking anchor link // since meta-refresh won't change `document.referrer` in e.g. Firefox return new HtmlResponse(sprintf( '<html>' . '<head><link rel="icon" href="data:image/svg+xml,"></head>' - . '<body><a href="%1$s" id="referrer-refresh"> </a>' - . '<script src="%2$s"></script></body>' + . '<body><a href="%s" id="referrer-refresh"> </a>' + . '<script %s></script></body>' . '</html>', htmlspecialchars((string)$refreshUri), - htmlspecialchars($scriptUri) + GeneralUtility::implodeAttributes($attributes, true) )); } $subject = $options['subject'] ?? ''; diff --git a/typo3/sysext/core/Classes/Package/AbstractServiceProvider.php b/typo3/sysext/core/Classes/Package/AbstractServiceProvider.php index ca2effb8b5dee44b65d71eedaf460575986e9c6a..c9a9f8b1ff980cad7660e2c029b09e85c958f982 100644 --- a/typo3/sysext/core/Classes/Package/AbstractServiceProvider.php +++ b/typo3/sysext/core/Classes/Package/AbstractServiceProvider.php @@ -22,6 +22,11 @@ use Psr\Container\ContainerInterface; use Psr\Log\LoggerAwareInterface; use TYPO3\CMS\Core\DependencyInjection\ServiceProviderInterface; use TYPO3\CMS\Core\Log\LogManager; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationOrigin; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationOriginType; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; +use TYPO3\CMS\Core\Type\Map; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -53,6 +58,7 @@ abstract class AbstractServiceProvider implements ServiceProviderInterface // @deprecated since v12, will be removed with v13 together with class PageTsConfigLoader. 'globalPageTsConfig' => [ static::class, 'configureGlobalPageTsConfig' ], 'backend.modules' => [ static::class, 'configureBackendModules' ], + 'content.security.policies' => [static::class, 'configureContentSecurityPolicies'], 'icons' => [ static::class, 'configureIcons' ], ]; } @@ -155,6 +161,29 @@ abstract class AbstractServiceProvider implements ServiceProviderInterface return $modules; } + /** + * @param Map<Scope, Map<MutationOrigin, MutationCollection>> $mutations + * @return Map<Scope, Map<MutationOrigin, MutationCollection>> + */ + public static function configureContentSecurityPolicies(ContainerInterface $container, Map $mutations, string $path = null, string $packageName = null): Map + { + $path = $path ?? static::getPackagePath(); + $packageName = $packageName ?? static::getPackageName(); + $fileName = $path . 'Configuration/ContentSecurityPolicies.php'; + if (file_exists($fileName)) { + /** @var Map<Scope, MutationCollection> $mutationsInPackage */ + $mutationsInPackage = require $fileName; + foreach ($mutationsInPackage as $scope => $mutation) { + if (!isset($mutations[$scope])) { + $mutations[$scope] = new Map(); + } + $origin = new MutationOrigin(MutationOriginType::package, $packageName); + $mutations[$scope][$origin] = $mutation; + } + } + return $mutations; + } + public static function configureIcons(ContainerInterface $container, ArrayObject $icons, string $path = null): ArrayObject { $path = $path ?? static::getPackagePath(); diff --git a/typo3/sysext/core/Classes/Page/ImportMap.php b/typo3/sysext/core/Classes/Page/ImportMap.php index 28a5a5059d7268bf304dfe06b6ab6ff52fca6d39..47ed7319051ffcba687d4562b83fb715c277a108 100644 --- a/typo3/sysext/core/Classes/Page/ImportMap.php +++ b/typo3/sysext/core/Classes/Page/ImportMap.php @@ -114,7 +114,7 @@ class ImportMap public function render( string $urlPrefix, - string $nonce, + ?string $nonce, bool $includePolyfill = true ): string { if (count($this->extensionsToLoad) === 0 || count($this->getImportMaps()) === 0) { @@ -128,7 +128,8 @@ class ImportMap $importMap, JSON_FORCE_OBJECT | JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_THROW_ON_ERROR ); - $html[] = sprintf('<script nonce="%s" type="importmap">%s</script>', $nonce, $json); + $nonceAttr = $nonce !== null ? ' nonce="' . htmlspecialchars($nonce) . '"' : ''; + $html[] = sprintf('<script type="importmap"%s>%s</script>', $nonceAttr, $json); if ($includePolyfill) { $importmapPolyfill = $urlPrefix . PathUtility::getPublicResourceWebPath( @@ -137,8 +138,9 @@ class ImportMap ); $html[] = sprintf( - '<script src="%s"></script>', - htmlspecialchars($importmapPolyfill) + '<script src="%s"%s></script>', + htmlspecialchars($importmapPolyfill), + $nonceAttr ); } diff --git a/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php b/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php index af85896cf34533f20f4fb61a9ae098c68bf36aab..b19ec26c3551423f9f94d2f62a192e2f6d15bb81 100644 --- a/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php +++ b/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php @@ -27,7 +27,7 @@ class JavaScriptRenderer protected ImportMap $importMap; protected int $javaScriptModuleInstructionFlags = 0; - public static function create(string $uri = null): self + public static function create(?string $uri = null): self { $uri ??= PathUtility::getAbsoluteWebPath( GeneralUtility::getFileAbsFileName('EXT:core/Resources/Public/JavaScript/java-script-item-handler.js') @@ -107,18 +107,25 @@ class JavaScriptRenderer return $this->items->toArray(); } - public function render(): string + public function render(?string $nonce = null): string { if ($this->isEmpty()) { return ''; } - return $this->createScriptElement([ + $attributes = [ 'src' => $this->handlerUri, 'async' => 'async', - ], $this->jsonEncode($this->toArray())); + ]; + if ($nonce !== null) { + $attributes['nonce'] = $nonce; + } + return $this->createScriptElement( + $attributes, + $this->jsonEncode($this->toArray()) + ); } - public function renderImportMap(string $sitePath, string $nonce): string + public function renderImportMap(string $sitePath, ?string $nonce = null): string { if (!$this->isEmpty()) { $this->importMap->includeImportsFor('@typo3/core/java-script-item-handler.js'); diff --git a/typo3/sysext/core/Classes/Page/PageRenderer.php b/typo3/sysext/core/Classes/Page/PageRenderer.php index 6c78c115fd69f5d84c6238aa4323a387700635d3..70b708bfc1a90af2350b617a0363d1380a21aad3 100644 --- a/typo3/sysext/core/Classes/Page/PageRenderer.php +++ b/typo3/sysext/core/Classes/Page/PageRenderer.php @@ -32,6 +32,7 @@ use TYPO3\CMS\Core\Package\PackageInterface; use TYPO3\CMS\Core\Package\PackageManager; use TYPO3\CMS\Core\Resource\RelativeCssPathFixer; use TYPO3\CMS\Core\Resource\ResourceCompressor; +use TYPO3\CMS\Core\Security\Nonce; use TYPO3\CMS\Core\Service\MarkerBasedTemplateService; use TYPO3\CMS\Core\SingletonInterface; use TYPO3\CMS\Core\Type\DocType; @@ -327,6 +328,7 @@ class PageRenderer implements SingletonInterface protected $endingSlash = ''; protected JavaScriptRenderer $javaScriptRenderer; + protected ?Nonce $nonce = null; protected DocType $docType = DocType::html5; public function __construct( @@ -362,6 +364,7 @@ class PageRenderer implements SingletonInterface case 'languageServiceFactory': case 'responseFactory': case 'streamFactory': + case 'nonce': break; case 'metaTagRegistry': $this->metaTagRegistry->updateState($value); @@ -393,6 +396,7 @@ class PageRenderer implements SingletonInterface case 'languageServiceFactory': case 'responseFactory': case 'streamFactory': + case 'nonce': break; case 'metaTagRegistry': $state[$var] = $this->metaTagRegistry->getState(); @@ -832,6 +836,11 @@ class PageRenderer implements SingletonInterface return $this->renderXhtml; } + public function setNonce(Nonce $nonce): void + { + $this->nonce = $nonce; + } + public function setDocType(DocType $docType): void { $this->docType = $docType; @@ -2109,9 +2118,7 @@ class PageRenderer implements SingletonInterface $out .= $this->javaScriptRenderer->renderImportMap( // @todo hookup with PSR-7 request/response and GeneralUtility::getIndpEnv('TYPO3_SITE_PATH'), - // @todo add CSP Management API for nonces - // (currently static for preparatory assertions in Acceptance Testing) - 'rAnd0m' + $this->nonce?->b64 ); // Include RequireJS @@ -2146,7 +2153,7 @@ class PageRenderer implements SingletonInterface ); } } - $out .= $this->javaScriptRenderer->render(); + $out .= $this->javaScriptRenderer->render($this->nonce?->b64); return $out; } @@ -2298,6 +2305,9 @@ class PageRenderer implements SingletonInterface if ($properties['title'] ?? false) { $tagAttributes['title'] = $properties['title']; } + if ($properties['nonce'] ?? $this->nonce?->b64) { + $tagAttributes['nonce'] = $properties['nonce'] ?? $this->nonce?->b64; + } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); $tag = '<link ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . $this->endingSlash . '>'; } @@ -2363,6 +2373,9 @@ class PageRenderer implements SingletonInterface if ($properties['crossorigin'] ?? false) { $tagAttributes['crossorigin'] = $properties['crossorigin']; } + if ($properties['nonce'] ?? $this->nonce?->b64) { + $tagAttributes['nonce'] = $properties['nonce'] ?? $this->nonce?->b64; + } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); $tag = '<script ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '></script>'; if ($properties['allWrap'] ?? false) { @@ -2421,6 +2434,9 @@ class PageRenderer implements SingletonInterface if ($properties['crossorigin'] ?? false) { $tagAttributes['crossorigin'] = $properties['crossorigin']; } + if ($properties['nonce'] ?? $this->nonce?->b64) { + $tagAttributes['nonce'] = $properties['nonce'] ?? $this->nonce?->b64; + } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); $tag = '<script ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '></script>'; if ($properties['allWrap'] ?? false) { @@ -2449,7 +2465,7 @@ class PageRenderer implements SingletonInterface } /** - * Render inline JavaScript + * Render inline JavaScript (must not apply `nonce="..."` if defined). * * @return array|string[] jsInline and jsFooterInline string */ @@ -2858,6 +2874,9 @@ class PageRenderer implements SingletonInterface if ($properties['title'] ?? false) { $tagAttributes['title'] = $properties['title']; } + if ($properties['nonce'] ?? $this->nonce?->b64) { + $tagAttributes['nonce'] = $properties['nonce'] ?? $this->nonce?->b64; + } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); return '<style ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '>' . LF . '/*<![CDATA[*/' . LF . '<!-- ' . LF diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Directive.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Directive.php new file mode 100644 index 0000000000000000000000000000000000000000..4584b6ae4b6619e453af08231ffbf86776e63e53 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Directive.php @@ -0,0 +1,102 @@ +<?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 directives + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#directives + */ +enum Directive: string +{ + case DefaultSrc = 'default-src'; + case BaseUri = 'base-uri'; + case ChildSrc = 'child-src'; + case ConnectSrc = 'connect-src'; + case FontSrc = 'font-src'; + case FormAction = 'form-action'; + case FrameAncestors = 'frame-ancestors'; + case FrameSrc = 'frame-src'; + case ImgSrc = 'img-src'; + case ManifestSrc = 'manifest-src'; + case MediaSrc = 'media-src'; + case ObjectSrc = 'object-src'; + // @deprecated (used for Safari, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/plugin-types) + case PluginTypes = 'plugin-types'; + // @deprecated (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri) + case ReportUri = 'report-uri'; + case Sandbox = 'sandbox'; + case ScriptSrc = 'script-src'; + case ScriptSrcAttr = 'script-src-attr'; + case ScriptSrcElem = 'script-src-elem'; + case StrictDynamic = 'strict-dynamic'; + case StyleSrc = 'style-src'; + case StyleSrcAttr = 'style-src-attr'; + case StyleSrcElem = 'style-src-elem'; + case UnsafeHashes = 'unsafe-hashes'; + case UpgradeInsecureRequests = 'upgrade-insecure-requests'; + case WorkerSrc = 'worker-src'; + + /** + * @return list<self> + */ + public function getAncestors(): array + { + $ancestors = self::ancestorMap()[$this] ?? []; + if ($this !== self::DefaultSrc) { + $ancestors[] = self::DefaultSrc; + } + return $ancestors; + } + + public function isReasonable(): bool + { + return !in_array($this, self::reasonableItems(), true); + } + + /** + * @return \WeakMap<self, list<self>> + */ + private static function ancestorMap(): \WeakMap + { + /** @var \WeakMap<self, list<self>> $map temporary, internal \WeakMap */ + $map = new \WeakMap(); + $map[self::ScriptSrcAttr] = [self::ScriptSrc]; + $map[self::ScriptSrcElem] = [self::ScriptSrc]; + $map[self::StyleSrcAttr] = [self::StyleSrc]; + $map[self::StyleSrcElem] = [self::StyleSrc]; + $map[self::FrameSrc] = [self::ChildSrc]; + $map[self::WorkerSrc] = [self::ChildSrc, self::ScriptSrc]; + return $map; + } + + /** + * @return list<self> + */ + private static function reasonableItems(): array + { + return [ + self::ConnectSrc, + self::FontSrc, + self::FrameSrc, + self::ImgSrc, + self::MediaSrc, + self::ScriptSrcElem, + self::StyleSrcElem, + ]; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Event/PolicyMutatedEvent.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Event/PolicyMutatedEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..a54474117001a88cbf3b7192a456738f1152fb93 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Event/PolicyMutatedEvent.php @@ -0,0 +1,76 @@ +<?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\Event; + +use Psr\EventDispatcher\StoppableEventInterface; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; + +final class PolicyMutatedEvent implements StoppableEventInterface +{ + private bool $stopPropagation = false; + private Policy $currentPolicy; + /** + * @var list<MutationCollection> + */ + private array $mutationCollections; + + public function __construct( + public readonly Scope $scope, + public readonly Policy $defaultPolicy, + Policy $currentPolicy, + MutationCollection ...$mutationCollections + ) { + $this->currentPolicy = $currentPolicy; + $this->mutationCollections = $mutationCollections; + } + + public function isPropagationStopped(): bool + { + return $this->stopPropagation; + } + + public function stopPropagation(): void + { + $this->stopPropagation = true; + } + + public function getCurrentPolicy(): Policy + { + return $this->currentPolicy; + } + + public function setCurrentPolicy(Policy $currentPolicy): void + { + $this->currentPolicy = $currentPolicy; + } + + /** + * @return list<MutationCollection> + */ + public function getMutationCollections(): array + { + return $this->mutationCollections; + } + + public function setMutationCollections(MutationCollection ...$mutationCollections): void + { + $this->mutationCollections = $mutationCollections; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/ModelService.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/ModelService.php new file mode 100644 index 0000000000000000000000000000000000000000..875fb8dc8335bf9750924550b820eaed2a1b9378 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/ModelService.php @@ -0,0 +1,117 @@ +<?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; + +use TYPO3\CMS\Core\Security\Nonce; + +/** + * Helpers for working with Content-Security-Policy models. + * + * @internal + */ +class ModelService +{ + public function buildMutationFromArray(array $array): Mutation + { + return new Mutation( + MutationMode::tryFrom($array['mode'] ?? ''), + Directive::tryFrom($array['directive'] ?? ''), + ...$this->buildSourcesFromItems(...($array['sources'] ?? [])) + ); + } + + public function buildSourcesFromItems(string ...$items): array + { + $sources = []; + foreach ($items as $item) { + $source = $this->buildSourceFromString($item); + if ($source === null) { + throw new \InvalidArgumentException( + sprintf('Could not convert source item "%s"', $item), + 1677261214 + ); + } + $sources[] = $source; + } + return $sources; + } + + public function buildSourceFromString(string $string): null|SourceKeyword|SourceScheme|UriValue|RawValue + { + if (str_starts_with($string, "'nonce-") && $string[-1] === "'") { + // use a proxy instead of a real Nonce instance + return SourceKeyword::nonceProxy; + } + if ($string[0] === "'" && $string[-1] === "'") { + return SourceKeyword::tryFrom(substr($string, 1, -1)); + } + if ($string[-1] === ':') { + return SourceScheme::tryFrom(substr($string, 0, -1)); + } + try { + return new UriValue($string); + } catch (\InvalidArgumentException) { + // no handling here + } + return new RawValue($string); + } + + public function serializeSources(SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$sources): array + { + $serialized = []; + foreach ($sources as $source) { + if ($source instanceof SourceKeyword && $source->vetoes()) { + $serialized = []; + } + $serialized[] = $this->serializeSource($source); + } + return $serialized; + } + + public function compileSources(Nonce $nonce, SourceCollection $collection): array + { + $compiled = []; + foreach ($collection->sources as $source) { + if ($source instanceof SourceKeyword && $source->vetoes()) { + $compiled = []; + } + $compiled[] = $this->serializeSource($source, $nonce); + } + return $compiled; + } + + /** + * @param Nonce|null $nonce used to substitute `SourceKeyword::nonceProxy` items during compilation + */ + public function serializeSource(SourceKeyword|SourceScheme|Nonce|UriValue|RawValue $source, Nonce $nonce = null): string + { + if ($source instanceof Nonce) { + return "'nonce-" . $source->b64 . "'"; + } + if ($source === SourceKeyword::nonceProxy && $nonce !== null) { + return "'nonce-" . $nonce->b64 . "'"; + } + if ($source instanceof SourceKeyword) { + return "'" . $source->value . "'"; + } + if ($source instanceof SourceScheme) { + return $source->value . ':'; + } + return (string)$source; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Mutation.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Mutation.php new file mode 100644 index 0000000000000000000000000000000000000000..933d13e43ec67d6e9e0ac6c7b48214928dc3f921 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Mutation.php @@ -0,0 +1,56 @@ +<?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; + +use TYPO3\CMS\Core\Security\Nonce; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Representation of a Content-Security-Policy mutation, changing an existing policy directive. + */ +class Mutation implements \JsonSerializable +{ + /** + * @var list<SourceKeyword|SourceScheme|Nonce|UriValue|RawValue> + */ + public readonly array $sources; + + public function __construct( + public readonly MutationMode $mode, + public readonly Directive $directive, + SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$sources + ) { + if ($sources !== [] && $this->mode === MutationMode::Remove) { + throw new \LogicException( + 'Cannot remove and declare sources at the same time', + 1677244893 + ); + } + $this->sources = $sources; + } + + public function jsonSerialize(): array + { + $service = GeneralUtility::makeInstance(ModelService::class); + return [ + 'mode' => $this->mode, + 'directive' => $this->directive, + 'sources' => $service->serializeSources(...$this->sources), + ]; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationCollection.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationCollection.php new file mode 100644 index 0000000000000000000000000000000000000000..26f8fe0acda1e97269b2a2b8cb74952d698d0e8b --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationCollection.php @@ -0,0 +1,41 @@ +<?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; + +/** + * A collection of mutations (sic!). + */ +final class MutationCollection implements \JsonSerializable +{ + /** + * @var list<Mutation> + */ + public readonly array $mutations; + + public function __construct(Mutation ...$mutations) + { + $this->mutations = $mutations; + } + + public function jsonSerialize(): array + { + return [ + 'mutations' => $this->mutations, + ]; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationMode.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationMode.php new file mode 100644 index 0000000000000000000000000000000000000000..6c3595a759f552ef390d5b9f4f1d2f56055190a8 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationMode.php @@ -0,0 +1,28 @@ +<?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; + +/** + * The mode used in mutations (sic!). + */ +enum MutationMode: string +{ + case Set = 'set'; + case Extend = 'extend'; + case Remove = 'remove'; +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationOrigin.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationOrigin.php new file mode 100644 index 0000000000000000000000000000000000000000..86b22632e52d860bbdb08e4c35fbc52a8d8f65f9 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationOrigin.php @@ -0,0 +1,31 @@ +<?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 a mutation origin, to keep track of resolutions + * to the Content-Security-Policy and how to revert again later. + */ +class MutationOrigin +{ + public function __construct( + public readonly MutationOriginType $type, + public readonly string $value + ) { + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationOriginType.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationOriginType.php new file mode 100644 index 0000000000000000000000000000000000000000..bfdc1eface9064260d45e45d14e9360f5c45bacc --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/MutationOriginType.php @@ -0,0 +1,24 @@ +<?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; + +enum MutationOriginType: string +{ + case package = 'package'; + case resolution = 'resolution'; +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Policy.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Policy.php new file mode 100644 index 0000000000000000000000000000000000000000..08da78073ebd9b560d293ff10af2b1c550221966 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Policy.php @@ -0,0 +1,233 @@ +<?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; + +use TYPO3\CMS\Core\Security\Nonce; +use TYPO3\CMS\Core\Type\Map; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Representation of the whole Content-Security-Policy + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + * + * @internal This implementation still might be adjusted + */ +class Policy implements \Stringable +{ + /** + * @var Map<Directive, SourceCollection> + */ + protected Map $directives; + + /** + * @param Nonce $nonce used to substitute `SourceKeyword::nonceProxy` items during compilation + * @param SourceCollection|SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$sources (optional) default-src sources + */ + public function __construct( + protected readonly Nonce $nonce, + SourceCollection|SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$sources + ) { + $this->directives = new Map(); + $collection = $this->asMergedSourceCollection(...$sources); + if (!$collection->isEmpty()) { + $this->directives[Directive::DefaultSrc] = $collection; + } + } + + public function __toString(): string + { + return $this->compile(); + } + + public function isEmpty(): bool + { + return count($this->directives) === 0; + } + + /** + * Applies mutations/changes to the current policy. + */ + public function mutate(MutationCollection|Mutation ...$mutations): self + { + $self = $this; + foreach ($mutations as $mutation) { + if ($mutation instanceof MutationCollection) { + $self = $self->mutate(...$mutation->mutations); + } elseif ($mutation->mode === MutationMode::Set) { + $self = $self->set($mutation->directive, ...$mutation->sources); + } elseif ($mutation->mode === MutationMode::Extend) { + $self = $self->extend($mutation->directive, ...$mutation->sources); + } elseif ($mutation->mode === MutationMode::Remove) { + $self = $self->remove($mutation->directive); + } + } + return $self; + } + + /** + * Sets (overrides) the 'default-src' directive, which is also the fall-back for other more specific directives. + */ + public function default(SourceCollection|SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$sources): self + { + return $this->set(Directive::DefaultSrc, ...$sources); + } + + /** + * Extends a specific directive, either by appending sources or by inheriting from an ancestor directive. + */ + public function extend( + Directive $directive, + SourceCollection|SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$sources + ): self { + $collection = $this->asMergedSourceCollection(...$sources); + if ($collection->isEmpty()) { + return $this; + } + foreach ($directive->getAncestors() as $ancestorDirective) { + if (isset($this->directives[$ancestorDirective])) { + $ancestorCollection = $this->directives[$ancestorDirective]; + break; + } + } + $targetCollection = $this->asMergedSourceCollection(...array_filter([ + $ancestorCollection ?? null, + $this->directives[$directive] ?? null, + $collection, + ])); + return $this->set($directive, $targetCollection); + } + + /** + * Sets (overrides) a specific directive. + */ + public function set( + Directive $directive, + SourceCollection|SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$sources + ): self { + $collection = $this->asMergedSourceCollection(...$sources); + if ($collection->isEmpty()) { + return $this; + } + $target = clone $this; + $target->directives[$directive] = $collection; + return $target; + } + + /** + * Removes a specific directive. + */ + public function remove(Directive $directive): self + { + if (!isset($this->directives[$directive])) { + return $this; + } + $target = clone $this; + unset($target->directives[$directive]); + return $target; + } + + /** + * Sets the 'report-uri' directive and appends 'report-sample' to existing & applicable directives. + */ + public function report(UriValue $reportUri): self + { + $target = $this->set(Directive::ReportUri, $reportUri); + $reportSample = SourceKeyword::reportSample; + foreach ($target->directives as $directive => $collection) { + if ($reportSample->isApplicable($directive)) { + $target->directives[$directive] = $collection->with($reportSample); + } + } + return $target; + } + + /** + * Prepares the policy for finally being serialized and issued as HTTP header. + * This step aims to optimize several combinations, or adjusts directives when 'strict-dynamic' is used. + */ + public function prepare(): self + { + $purged = false; + $directives = clone $this->directives; + $comparator = [$this, 'compareSources']; + foreach ($directives as $directive => $collection) { + foreach ($directive->getAncestors() as $ancestorDirective) { + $ancestorCollection = $directives[$ancestorDirective] ?? null; + if ($ancestorCollection !== null + && array_udiff($collection->sources, $ancestorCollection->sources, $comparator) === [] + && array_udiff($ancestorCollection->sources, $collection->sources, $comparator) === [] + ) { + $purged = true; + unset($directives[$directive]); + continue 2; + } + } + } + foreach ($directives as $directive => $collection) { + // applies implicit changes to sources in case 'strict-dynamic' is used + if ($collection->contains(SourceKeyword::strictDynamic)) { + $directives[$directive] = SourceKeyword::strictDynamic->applySourceImplications($collection) ?? $collection; + } + } + if (!$purged) { + return $this; + } + $target = clone $this; + $target->directives = $directives; + return $target; + } + + /** + * Compiles this policy and returns the serialized representation to be used as HTTP header value. + */ + public function compile(): string + { + $policyParts = []; + $service = GeneralUtility::makeInstance(ModelService::class); + foreach ($this->prepare()->directives as $directive => $collection) { + $directiveParts = $service->compileSources($this->nonce, $collection); + if ($directiveParts !== []) { + array_unshift($directiveParts, $directive->value); + $policyParts[] = implode(' ', $directiveParts); + } + } + return implode('; ', $policyParts); + } + + protected function compareSources( + SourceKeyword|SourceScheme|Nonce|UriValue|RawValue $a, + SourceKeyword|SourceScheme|Nonce|UriValue|RawValue $b + ): int { + $service = GeneralUtility::makeInstance(ModelService::class); + return $service->serializeSource($a) <=> $service->serializeSource($b); + } + + protected function asMergedSourceCollection(SourceCollection|SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$subjects): SourceCollection + { + $collections = array_filter($subjects, static fn ($source) => $source instanceof SourceCollection); + $sources = array_filter($subjects, static fn ($source) => !$source instanceof SourceCollection); + if ($sources !== []) { + $collections[] = new SourceCollection(...$sources); + } + $target = new SourceCollection(); + foreach ($collections as $collection) { + $target = $target->merge($collection); + } + return $target; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/PolicyProvider.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/PolicyProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..cad76e1778bd59c65d42e16e962a31448f2645fb --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/PolicyProvider.php @@ -0,0 +1,117 @@ +<?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; + +use Psr\EventDispatcher\EventDispatcherInterface; +use TYPO3\CMS\Core\Core\RequestId; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyMutatedEvent; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\SiteFinder; +use TYPO3\CMS\Core\Type\Map; + +/** + * Provide a Content-Security-Policy representation for a given scope (e.g. backend, frontend, frontend.my-site). + * + * @internal + */ +final class PolicyProvider +{ + /** + * @param Map<Scope, Map<MutationOrigin, MutationCollection>> $mutations + */ + public function __construct( + private readonly Map $mutations, + private readonly RequestId $requestId, + private readonly SiteFinder $siteFinder, + private readonly ModelService $modelService, + private readonly EventDispatcherInterface $eventDispatcher, + ) { + } + + /** + * Provides the complete, dynamically mutated policy to be used in HTTP responses. + */ + public function provideFor(Scope $scope): Policy + { + // @todo add policy cache per scope + $defaultPolicy = $this->provideDefaultFor($scope); + $mutationCollections = []; + $currentPolicy = $defaultPolicy->mutate(...$mutationCollections); + $event = new PolicyMutatedEvent($scope, $defaultPolicy, $currentPolicy, ...$mutationCollections); + $this->eventDispatcher->dispatch($event); + return $event->getCurrentPolicy(); + } + + /** + * Provides the base policy which contains only static mutations - either from + * `Configuration/ContentSecurityPolicies.php of each extension, or from the + * corresponding `contentSecurityPolicies.mutations` section of a frontend + * site definition. + */ + public function provideDefaultFor(Scope $scope): Policy + { + // @todo add runtime policy cache per scope + $isFrontendSite = $scope->siteIdentifier !== null && $scope->type->isFrontend(); + if ($isFrontendSite && $this->shallInheritDefault($scope)) { + $policy = $this->provideFor(Scope::frontend()); + } else { + $policy = new Policy($this->requestId->nonce); + } + // apply static mutations (from DI, declared in `Configuration/ContentSecurityPolicies.php`) + foreach ($this->mutations[$scope] ?? [] as $mutationCollection) { + $policy = $policy->mutate($mutationCollection); + } + // apply site mutations (declared in corresponding site configuration's csp.yaml file) + if ($isFrontendSite) { + foreach ($this->resolveSiteMutations($scope) as $mutation) { + $policy = $policy->mutate($mutation); + } + } + return $policy; + } + + /** + * Whether to inherit default (site-unspecific) frontend policy mutations. + */ + private function shallInheritDefault(Scope $scope): bool + { + $site = $this->resolveSite($scope); + return (bool)($site->getConfiguration()['contentSecurityPolicies']['inheritDefault'] ?? true); + } + + /** + * @return list<Mutation> + */ + private function resolveSiteMutations(Scope $scope): array + { + $site = $this->resolveSite($scope); + $mutationConfigurations = $site->getConfiguration()['contentSecurityPolicies']['mutations'] ?? []; + if (empty($mutationConfigurations) || !is_array($mutationConfigurations)) { + return []; + } + return array_map( + fn (array $array) => $this->modelService->buildMutationFromArray($array), + $mutationConfigurations + ); + } + + private function resolveSite(Scope $scope): Site + { + return $scope->site ?? $this->siteFinder->getSiteByIdentifier($scope->siteIdentifier); + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/RawValue.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/RawValue.php new file mode 100644 index 0000000000000000000000000000000000000000..216e2dcfa65eaa11e225c3dcb2e7de55f8f6d6af --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/RawValue.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 a plain, raw string value that does not have + * a particular meaning in the terms of Content-Security-Policy. + * + * @internal Might be changed or removed at a later time + */ +class RawValue implements \Stringable +{ + public function __construct(public readonly string $value) + { + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Scope.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Scope.php new file mode 100644 index 0000000000000000000000000000000000000000..8a840f1c42dbb39cdc49c639d1aabda8b54d163f --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Scope.php @@ -0,0 +1,121 @@ +<?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; + +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Representation of a specific application type scope (backend, frontend), + * which can optionally be enriched by site-related details. + */ +final class Scope implements \Stringable, \JsonSerializable +{ + /** + * @var array<string, self> + */ + private static array $singletons = []; + + public static function backend(): self + { + return self::asSingleton(new self(ApplicationType::BACKEND)); + } + + public static function frontend(): self + { + return self::asSingleton(new self(ApplicationType::FRONTEND)); + } + + public static function frontendSite(?SiteInterface $site): self + { + // PHPStan fails, see https://github.com/phpstan/phpstan/issues/8464 + // @phpstan-ignore-next-line + if (!$site instanceof Site || is_subclass_of($site, Site::class)) { + return self::frontend(); + } + return self::asSingleton(new self(ApplicationType::FRONTEND, $site->getIdentifier(), $site)); + } + + public static function frontendSiteIdentifier(string $siteIdentifier): self + { + return self::asSingleton(new self(ApplicationType::FRONTEND, $siteIdentifier)); + } + + public static function from(string $value): self + { + $parts = GeneralUtility::trimExplode('.', $value, true); + $type = ApplicationType::tryFrom($parts[0] ?? ''); + $siteIdentifier = $parts[1] ?? null; + if ($type === null) { + throw new \LogicException( + sprintf('Could not resolve application type from "%s"', $value), + 1677424928 + ); + } + return self::asSingleton(new self($type, $siteIdentifier)); + } + + public static function reset(): void + { + self::$singletons = []; + } + + public static function tryFrom(string $value): ?self + { + try { + return self::from($value); + } catch (\LogicException) { + return null; + } + } + + private static function asSingleton(self $self): self + { + $id = (string)$self; + if (!isset(self::$singletons[$id])) { + self::$singletons[$id] = $self; + } + return self::$singletons[$id]; + } + + /** + * Use static functions to create singleton instances. + */ + private function __construct( + public readonly ApplicationType $type, + public readonly ?string $siteIdentifier = null, + public readonly ?Site $site = null, + ) { + } + + public function __toString(): string + { + $value = $this->type->value; + if ($this->siteIdentifier !== null) { + $value .= '.' . $this->siteIdentifier; + } + return $value; + } + + public function jsonSerialize(): string + { + return (string)$this; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/SourceCollection.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/SourceCollection.php new file mode 100644 index 0000000000000000000000000000000000000000..368a975d5828aa37a05ccce12f262c6046b1a4da --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/SourceCollection.php @@ -0,0 +1,126 @@ +<?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; + +use TYPO3\CMS\Core\Security\Nonce; + +/** + * A collection of sources (sic!). + * @internal This implementation still might be adjusted + */ +final class SourceCollection +{ + /** + * @var list<SourceKeyword|SourceScheme|Nonce|UriValue|RawValue> + */ + public readonly array $sources; + + public function __construct(SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$sources) + { + $this->sources = $sources; + } + + public function isEmpty(): bool + { + return $this->sources === []; + } + + public function merge(self $other): self + { + return $this->with(...$other->sources); + } + + public function with(SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$subjects): self + { + $subjects = array_filter( + $subjects, + fn ($subject) => !in_array($subject, $this->sources, true) + ); + if ($subjects === []) { + return $this; + } + return new self(...array_merge($this->sources, $subjects)); + } + + public function without(SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$subjects): self + { + $sources = array_filter( + $this->sources, + fn ($source) => !in_array($source, $subjects, true) + ); + if (count($this->sources) === count($sources)) { + return $this; + } + return new self(...$sources); + } + + /** + * @param class-string ...$subjectTypes + */ + public function withoutTypes(string ...$subjectTypes): self + { + $sources = array_filter( + $this->sources, + fn ($source) => !$this->isSourceOfTypes($source, ...$subjectTypes) + ); + if (count($this->sources) === count($sources)) { + return $this; + } + return new self(...$sources); + } + + /** + * Determines whether at least one source matches. + */ + public function contains(SourceKeyword|SourceScheme|Nonce|UriValue|RawValue ...$subjects): bool + { + foreach ($this->sources as $source) { + if (in_array($source, $subjects, true)) { + return true; + } + } + return false; + } + + /** + * Determines whether at least one type matches. + * @param class-string ...$subjectTypes + */ + public function containsTypes(string ...$subjectTypes): bool + { + foreach ($this->sources as $source) { + if ($this->isSourceOfTypes($source, ...$subjectTypes)) { + return true; + } + } + return false; + } + + /** + * @param class-string ...$types + */ + private function isSourceOfTypes(SourceKeyword|SourceScheme|Nonce|UriValue|RawValue $source, string ...$types): bool + { + foreach ($types as $type) { + if (is_a($source, $type)) { + return true; + } + } + return false; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/SourceKeyword.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/SourceKeyword.php new file mode 100644 index 0000000000000000000000000000000000000000..251600ce374377bd8eeb051d9dbfd6782eb73e01 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/SourceKeyword.php @@ -0,0 +1,73 @@ +<?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; + +use TYPO3\CMS\Core\Security\Nonce; + +/** + * Representation of Content-Security-Policy source keywords + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources + */ +enum SourceKeyword: string +{ + case none = 'none'; + case self = 'self'; + case unsafeInline = 'unsafe-inline'; + case unsafeEval = 'unsafe-eval'; + case wasmUnsafeEval = 'wasm-unsafe-eval'; + case reportSample = 'report-sample'; + case strictDynamic = 'strict-dynamic'; + // nonce proxy is substituted when compiling the whole policy + // (this value does NOT exist in the CSP definition, it's specific to TYPO3 only) + case nonceProxy = 'nonce-proxy'; + + public function vetoes(): bool + { + return $this === self::none; + } + + public function isApplicable(Directive $directive): bool + { + // temporary, internal \WeakMap + $onlyApplicableTo = new \WeakMap(); + $onlyApplicableTo[self::reportSample] = [ + Directive::ScriptSrc, Directive::ScriptSrcAttr, Directive::ScriptSrcElem, + Directive::StyleSrc, Directive::StyleSrcAttr, Directive::StyleSrcElem, + ]; + return !isset($onlyApplicableTo[$this]) || in_array($directive, $onlyApplicableTo[$this], true); + } + + public function applySourceImplications(SourceCollection $sources): ?SourceCollection + { + if ($this !== self::strictDynamic) { + return null; + } + // adjust existing directives when using 'strict-dynamic' (e.g. for Google Maps) + // see https://www.w3.org/TR/CSP3/#strict-dynamic-usage + $newSources = $sources + ->without(SourceKeyword::self, SourceKeyword::unsafeInline) + ->withoutTypes(UriValue::class, SourceScheme::class); + if (!$sources->contains(self::nonceProxy) && !$sources->containsTypes(Nonce::class)) { + $newSources = $newSources->with(self::nonceProxy); + } + if ($sources === $newSources) { + return null; + } + return $newSources; + } +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/SourceScheme.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/SourceScheme.php new file mode 100644 index 0000000000000000000000000000000000000000..b188d55cd47bee6836e605f1b93f6cd627379a62 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/SourceScheme.php @@ -0,0 +1,30 @@ +<?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 source schemes + * see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources + */ +enum SourceScheme: string +{ + case blob = 'blob'; + case data = 'data'; + case http = 'http'; + case https = 'https'; +} diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/UriValue.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/UriValue.php new file mode 100644 index 0000000000000000000000000000000000000000..0d23a08e57e5317a3a8db13dfc6534d35b3773d2 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/UriValue.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\Security\ContentSecurityPolicy; + +use Psr\Http\Message\UriInterface; +use TYPO3\CMS\Core\Http\Uri; + +/** + * Bridge to UriInterface to be used in Content-Security-Policy models, + * which e.g. supports wildcard domains, like `*.typo3.org` or `https://*.typo3.org`. + */ +final class UriValue extends Uri implements \Stringable +{ + private string $domainName = ''; + + public static function fromUri(UriInterface $other): self + { + return new self((string)$other); + } + + public function __toString(): string + { + if ($this->domainName !== '') { + return $this->domainName; + } + return parent::__toString(); + } + + public function getDomainName(): string + { + return $this->domainName; + } + + protected function parseUri($uri): void + { + parent::parseUri($uri); + // ignore fragments per default + $this->fragment = ''; + // handle domain names that were recognized as paths + if ($this->canBeParsedAsWildcardDomainName()) { + $this->domainName = '*.' . substr($this->path, 4); + } elseif ($this->canBeParsedAsDomainName()) { + $this->domainName = $this->path; + } + } + + private function canBeParsedAsDomainName(): bool + { + return $this->path !== '' + && $this->scheme === '' + && $this->host === '' + && $this->query === '' + && $this->userInfo === '' + && $this->validateDomainName($this->path); + } + + private function canBeParsedAsWildcardDomainName(): bool + { + if ($this->path === '' + || $this->scheme !== '' + || $this->host !== '' + || $this->query !== '' + || $this->userInfo !== '' + || stripos($this->path, '%2A') !== 0 + ) { + return false; + } + $possibleDomainName = substr($this->path, 4); + return $this->validateDomainName($possibleDomainName); + } + + private function validateDomainName(string $value): bool + { + return filter_var($value, FILTER_VALIDATE_DOMAIN) !== false; + } +} diff --git a/typo3/sysext/core/Classes/ServiceProvider.php b/typo3/sysext/core/Classes/ServiceProvider.php index 1fe8de2cb2e2a529da20d4e464b65a00ca0d5e8b..09ca60852b34501193317655478246dd8df95869 100644 --- a/typo3/sysext/core/Classes/ServiceProvider.php +++ b/typo3/sysext/core/Classes/ServiceProvider.php @@ -30,6 +30,7 @@ use TYPO3\CMS\Core\DependencyInjection\ContainerBuilder; use TYPO3\CMS\Core\Imaging\IconRegistry; use TYPO3\CMS\Core\Package\AbstractServiceProvider; use TYPO3\CMS\Core\Package\PackageManager; +use TYPO3\CMS\Core\Type\Map; use TYPO3\CMS\Core\TypoScript\Tokenizer\LossyTokenizer; /** @@ -105,6 +106,7 @@ class ServiceProvider extends AbstractServiceProvider 'globalPageTsConfig' => [ static::class, 'getGlobalPageTsConfig' ], 'icons' => [ static::class, 'getIcons' ], 'middlewares' => [ static::class, 'getMiddlewares' ], + 'content.security.policies' => [ static::class, 'getContentSecurityPolicies' ], ]; } @@ -568,6 +570,11 @@ class ServiceProvider extends AbstractServiceProvider return new ArrayObject(); } + public static function getContentSecurityPolicies(ContainerInterface $container): Map + { + return new Map(); + } + public static function provideFallbackEventDispatcher( ContainerInterface $container, EventDispatcherInterface $eventDispatcher = null diff --git a/typo3/sysext/core/Classes/Utility/DebugUtility.php b/typo3/sysext/core/Classes/Utility/DebugUtility.php index 46e5dd6d6eaf27adc0404b12ac1935998e0b3e23..560482de678a6bc8a51739bcd87017752e4ad92b 100644 --- a/typo3/sysext/core/Classes/Utility/DebugUtility.php +++ b/typo3/sysext/core/Classes/Utility/DebugUtility.php @@ -18,6 +18,7 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Utility; use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Core\RequestId; use TYPO3\CMS\Extbase\Utility\DebuggerUtility; /** @@ -95,7 +96,7 @@ class DebugUtility } })(); '; - echo GeneralUtility::wrapJS($script); + echo GeneralUtility::wrapJS($script, ['nonce' => self::resolveNonceValue()]); } /** @@ -220,4 +221,9 @@ class DebugUtility { static::$ansiColorUsage = $ansiColorUsage; } + + protected static function resolveNonceValue(): string + { + return GeneralUtility::makeInstance(RequestId::class)->nonce->b64; + } } diff --git a/typo3/sysext/core/Classes/Utility/GeneralUtility.php b/typo3/sysext/core/Classes/Utility/GeneralUtility.php index c9a4380fd3898b45fc7f19e7612ac886247eab58..bab6bf923b6623decdda94ed3be3640744cfb809 100644 --- a/typo3/sysext/core/Classes/Utility/GeneralUtility.php +++ b/typo3/sysext/core/Classes/Utility/GeneralUtility.php @@ -1131,9 +1131,10 @@ class GeneralUtility * This is nice for indenting JS code with PHP code on the same level. * * @param string $string JavaScript code + * @param array<string, string> $attributes (optional) script tag HTML attributes * @return string The wrapped JS code, ready to put into a XHTML page */ - public static function wrapJS($string) + public static function wrapJS(string $string, array $attributes = []) { if (trim($string)) { // remove nl from the beginning @@ -1143,7 +1144,7 @@ class GeneralUtility if (preg_match('/^(\\t+)/', $string, $match)) { $string = str_replace($match[1], "\t", $string); } - return '<script> + return '<script ' . GeneralUtility::implodeAttributes($attributes, true) . '> /*<![CDATA[*/ ' . $string . ' /*]]>*/ diff --git a/typo3/sysext/core/Configuration/DefaultConfiguration.php b/typo3/sysext/core/Configuration/DefaultConfiguration.php index 0e6a4e8620f31a7af6fef62e8bea9af5c03bea34..ed54d2a618c6c6480d2d6988b8dc0ede846c6868 100644 --- a/typo3/sysext/core/Configuration/DefaultConfiguration.php +++ b/typo3/sysext/core/Configuration/DefaultConfiguration.php @@ -74,6 +74,8 @@ return [ 'redirects.hitCount' => false, 'security.backend.htmlSanitizeRte' => false, 'security.backend.enforceReferrer' => true, + 'security.backend.enforceContentSecurityPolicy' => false, + 'security.frontend.enforceContentSecurityPolicy' => false, ], 'createGroup' => '', 'sitename' => 'TYPO3', @@ -1349,9 +1351,6 @@ return [ 'strictTransportSecurity' => 'Strict-Transport-Security: max-age=31536000', 'avoidMimeTypeSniffing' => 'X-Content-Type-Options: nosniff', 'referrerPolicy' => 'Referrer-Policy: strict-origin-when-cross-origin', - // 'csp-report' => "Content-Security-Policy-Report-Only: default-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data:", - // @todo later™: muuri.js is creating workers from `blob:` (?!?), <style> tags declare inline styles (?!?) - // 'csp-report' => "Content-Security-Policy-Report-Only: default-src 'self'; style-src-attr 'unsafe-inline'; style-src-elem 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self' blob:;", ], ], ], diff --git a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml index 73913570e58d10959d6dde0ff50832f4d77a60fd..c9c6202f542b446aec5e3d495f892437556bb0fb 100644 --- a/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml +++ b/typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml @@ -204,6 +204,12 @@ SYS: description: 'If on, HTTP referrer headers are enforced for backend and install tool requests to mitigate potential same-site request forgery attacks. The behavior can be disabled in case HTTP proxies filter required `Referer` header. As this is a potential security risk, it is recommended to enable this option.' + security.backend.enforceContentSecurityPolicy: + type: bool + description: 'If on, HTTP Content-Security-Policy header will be applied for each HTTP backend request.' + security.frontend.enforceContentSecurityPolicy: + type: bool + description: 'If on, HTTP Content-Security-Policy header will be applied for each HTTP frontend request.' availablePasswordHashAlgorithms: type: array description: 'A list of available password hash mechanisms. Extensions may register additional mechanisms here. This is usually not extended in system/settings.php.' diff --git a/typo3/sysext/core/Configuration/FactoryConfiguration.php b/typo3/sysext/core/Configuration/FactoryConfiguration.php index 740e548a468924f350dbb0f0f58fc012f41cc112..e38327553df20237f5f679bc28ae405f820f0772 100644 --- a/typo3/sysext/core/Configuration/FactoryConfiguration.php +++ b/typo3/sysext/core/Configuration/FactoryConfiguration.php @@ -23,5 +23,8 @@ return [ 'SYS' => [ 'sitename' => 'New TYPO3 site', 'UTF8filesystem' => true, + 'features' => [ + 'security.backend.enforceContentSecurityPolicy' => true, + ], ], ]; diff --git a/typo3/sysext/core/Configuration/Services.yaml b/typo3/sysext/core/Configuration/Services.yaml index 3340bdb6cdbaa24bbe5aca78f3cf13f76e452f73..4ad0ffbe08705691430d4ccabcd85a99545c4072 100644 --- a/typo3/sysext/core/Configuration/Services.yaml +++ b/typo3/sysext/core/Configuration/Services.yaml @@ -521,6 +521,13 @@ services: alias: TYPO3\CMS\Core\Mail\Mailer public: true + # Content-Security-Policy Handlers + + TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider: + public: true + arguments: + $mutations: '@content.security.policies' + # External dependencies GuzzleHttp\Client: diff --git a/typo3/sysext/core/Documentation/Changelog/12.3/Feature-99499-IntroduceContent-Security-PolicyHandling.rst b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-99499-IntroduceContent-Security-PolicyHandling.rst new file mode 100644 index 0000000000000000000000000000000000000000..9d6c7ef745615561bc123637dc3130373a34ba26 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-99499-IntroduceContent-Security-PolicyHandling.rst @@ -0,0 +1,160 @@ +.. include:: /Includes.rst.txt + +.. _feature-99499-1677703100: + +============================================================ +Feature: #99499 - Introduce Content-Security-Policy Handling +============================================================ + +See :issue:`99499` + +Description +=========== + +Corresponding representation of the W3C standard of +`Content-Security-Policy (CSP) <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy>` +has been introduced to TYPO3. Content-Security-Policy declarations can either be provided by using the +the general builder pattern of :php:`\TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy`, extension +specific mutations (changes to the general policy) via :file:`Configuration/ContentSecurityPolicies.php` +located in corresponding extension directories, or YAML path :yaml:`contentSecurityPolicies.mutations` for +site-specific declarations in the website frontend. + +The PSR-15 middlewares :php:`ContentSecurityPolicyHeaders` are applying `Content-Security-Policy` HTTP headers +to each response in the frontend and backend scope. In case other components have already added either the +header `Content-Security-Policy` or `Content-Security-Policy-Report-Only`, those existing headers will be +kept without any modification - these events will be logged with an `info` severity. + +To delegate CSP handling to TYPO3, the scope-specific feature flags need to be enabled: + +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.backend.enforceContentSecurityPolicy']` +* :php:`$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.enforceContentSecurityPolicy']` + +For new installations `security.backend.enforceContentSecurityPolicy` is enabled via factory default settings. + +Impact +====== + +Introducing CSP to TYPO3 aims to reduces the risk of being affected by Cross-Site-Scripting +due to the lack of proper encoding of user-submitted content in corresponding outputs. + +Configuration +============= + +`Policy` builder approach +------------------------- + +.. code-block:: php + + <?php + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; + use TYPO3\CMS\Core\Security\Nonce; + + $nonce = Nonce::create(); + $policy = (new Policy($nonce)) + // results in `default-src 'self'` + ->default(SourceKeyword::self) + // extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + // results in `img-src 'self' data: https://*.typo3.org` + ->extend(Directive::ImgSrc, SourceScheme::data, new UriValue('https://*.typo3.org')) + // extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + // results in `script-src 'self' 'nonce-[random]'` ('nonce-proxy' is substituted when compiling the policy) + ->extend(Directive::ScriptSrc, SourceKeyword::nonceProxy) + // sets (overrides) the directive, thus ignores 'self' of the 'default-src' directive + // results in `worker-src blob:` + ->set(Directive::WorkerSrc, SourceScheme::blob); + header('Content-Security-Policy: ' . (string)$policy); + +The result of the compiled and serialized result as HTTP header would look similar to this +(the following sections are using the same example, but utilize different techniques for the declarations). + +.. code-block:: + + Content-Security-Policy: default-src 'self'; + img-src 'self' data: https://*.typo3.org; script-src 'self' 'nonce-[random]'; + worker-src blob: + +Extension specific +------------------ + +Having a file :file:`Configuration/ContentSecurityPolicies.php` in the base directory +of any extension declared, will automatically provide and apply corresponding settings. + +.. code-block:: php + + <?php + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme; + use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; + use TYPO3\CMS\Core\Type\Map; + + return Map::fromEntries([ + // provide declarations for the backend + Scope::backend(), + // NOTICE: When using `MutationMode::Set` existing declarations will be overridden + new MutationCollection( + // results in `default-src 'self'` + new Mutation(MutationMode::Set, Directive::DefaultSrc, SourceKeyword::self), + // extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + // results in `img-src 'self' data: https://*.typo3.org` + new Mutation(MutationMode::Extend, Directive::ImgSrc, SourceScheme::data, new UriValue('https://*.typo3.org')), + // extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + // results in `script-src 'self' 'nonce-[random]'` ('nonce-proxy' is substituted when compiling the policy) + new Mutation(MutationMode::Extend, Directive::ScriptSrc, SourceKeyword::nonceProxy), + // sets (overrides) the directive, thus ignores 'self' of the 'default-src' directive + // results in `worker-src blob:` + new Mutation(MutationMode::Set, Directive::WorkerSrc, SourceScheme::blob), + ), + ]); + +Site-specific (frontend) +------------------------ + +In the frontend, the dedicated :file:`sites/my-site/csp.yaml` can be used to declare CSP for a specific site as well. + +.. code-block:: yaml + + # inherits default site-unspecific frontend policy mutations (enabled per default) + inheritDefault: true + mutations: + # results in `default-src 'self'` + - mode: set + directive: 'default-src' + sources: + - "'self'" + # extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + # results in `img-src 'self' data: https://*.typo3.org` + - mode: extend + directive: 'img-src' + sources: + - 'data:' + - 'https://*.typo3.org' + # extends the ancestor directive ('default-src'), thus reuses 'self' and adds additional sources + # results in `script-src 'self' 'nonce-[random]'` ('nonce-proxy' is substituted when compiling the policy) + - mode: extend + directive: 'script-src' + sources: + - "'nonce-proxy'" + # results in `worker-src blob:` + - mode: set + directive: 'worker-src' + sources: + - 'blob:' + +PSR-14 Events +============= + +The :php:`\TYPO3\CMS\Core\Security\ContentSecurityPolicy\Event\PolicyMutatedEvent` will +be dispatched once all mutations have been applied to the current policy object, just +before the corresponding HTTP header is added to the HTTP response object. +This allows individual adjustments for custom implementations. + +.. index:: Backend, Fluid, Frontend, LocalConfiguration, PHP-API, ext:core diff --git a/typo3/sysext/core/Tests/Acceptance/Support/Extension/ApplicationEnvironment.php b/typo3/sysext/core/Tests/Acceptance/Support/Extension/ApplicationEnvironment.php index 4f1cc1013b214c64123060f83c0f6339bd2a84bb..9051d1172960c3adfa18229f372dab82e189609b 100644 --- a/typo3/sysext/core/Tests/Acceptance/Support/Extension/ApplicationEnvironment.php +++ b/typo3/sysext/core/Tests/Acceptance/Support/Extension/ApplicationEnvironment.php @@ -79,19 +79,9 @@ final class ApplicationEnvironment extends BackendEnvironment 'MAIL' => [ 'transport' => NullTransport::class, ], - 'BE' => [ - 'HTTP' => [ - 'Response' => [ - 'Headers' => [ - // Notes: - // * `script-src 'nonce-rAnd0m'` required for importmap - // todo: this needs to be a proper random value, requires API. - // * `frame-src blob:` required for es-module-shims blob: URLs - // * `style-src 'unsafe-inline'` required for lit in safari and firefox to allow inline <style> tags - // (for browsers that do not support https://caniuse.com/mdn-api_shadowroot_adoptedstylesheets) - 'csp-report' => "Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-rAnd0m'; style-src 'self' 'unsafe-inline'; style-src-attr 'unsafe-inline'; style-src-elem 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self' blob:; frame-src 'self' blob:;", - ], - ], + 'SYS' => [ + 'features' => [ + 'security.backend.enforceContentSecurityPolicy' => true, ], ], ], diff --git a/typo3/sysext/core/Tests/Unit/Http/Security/ReferrerEnforcerTest.php b/typo3/sysext/core/Tests/Unit/Http/Security/ReferrerEnforcerTest.php index 5c2fcc55bc8247c97260300e7f934129246c677b..0202da58f6b978ebae291959153a5c1cc75d4468 100644 --- a/typo3/sysext/core/Tests/Unit/Http/Security/ReferrerEnforcerTest.php +++ b/typo3/sysext/core/Tests/Unit/Http/Security/ReferrerEnforcerTest.php @@ -23,6 +23,7 @@ use TYPO3\CMS\Core\Http\Security\InvalidReferrerException; use TYPO3\CMS\Core\Http\Security\MissingReferrerException; use TYPO3\CMS\Core\Http\Security\ReferrerEnforcer; use TYPO3\CMS\Core\Http\Uri; +use TYPO3\CMS\Core\Security\Nonce; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; class ReferrerEnforcerTest extends UnitTestCase @@ -177,7 +178,25 @@ class ReferrerEnforcerTest extends UnitTestCase $subject->handle(); } - private function buildSubject(string $requestUri, string $referrer): ReferrerEnforcer + /** + * @test + */ + public function nonceIsAppliedToResponse(): void + { + $nonce = Nonce::create(); + $subject = $this->buildSubject( + 'https://example.org/typo3/login', + '', + $nonce + ); + $response = $subject->handle(['flags' => ['refresh-always']]); + self::assertStringContainsString( + 'nonce="' . htmlspecialchars($nonce->b64) . '">', + (string)$response->getBody() + ); + } + + private function buildSubject(string $requestUri, string $referrer, Nonce $nonce = null): ReferrerEnforcer { $requestUriInstance = new Uri($requestUri); $host = sprintf( @@ -192,11 +211,20 @@ class ReferrerEnforcerTest extends UnitTestCase $normalizedParams->method('getRequestHost')->willReturn($host); $normalizedParams->method('getRequestDir')->willReturn($dir); $request = $this->createMock(ServerRequestInterface::class); - $request->method('getAttribute')->with('normalizedParams')->willReturn($normalizedParams); + $request->method('getAttribute')->willReturnCallback(static fn (string $name) => match ($name) { + 'normalizedParams' => $normalizedParams, + 'nonce' => $nonce, + default => null, + }); $request->method('getServerParams')->willReturn(['HTTP_REFERER' => $referrer]); $request->method('getUri')->willReturn($requestUriInstance); $request->method('getQueryParams')->willReturn($queryParams); - return new ReferrerEnforcer($request); + $mock = $this->getMockBuilder(ReferrerEnforcer::class) + ->onlyMethods(['resolveAbsoluteWebPath']) + ->setConstructorArgs([$request]) + ->getMock(); + $mock->method('resolveAbsoluteWebPath')->willReturnCallback(static fn (string $path) => '/' . $path); + return $mock; } } diff --git a/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/PolicyTest.php b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/PolicyTest.php new file mode 100644 index 0000000000000000000000000000000000000000..aa843b10cdd46cc4e3940944737e03d3c7944444 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/PolicyTest.php @@ -0,0 +1,145 @@ +<?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; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme; +use TYPO3\CMS\Core\Security\Nonce; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class PolicyTest extends UnitTestCase +{ + private Nonce $nonce; + + protected function setUp(): void + { + parent::setUp(); + $this->nonce = Nonce::create(); + } + + /** + * @test + */ + public function constructorSetsdefaultDirective(): void + { + $policy = (new Policy($this->nonce, SourceKeyword::self)); + self::assertSame("default-src 'self'", (string)$policy); + } + + /** + * @test + */ + public function defaultDirectiveIsModified(): void + { + $policy = (new Policy($this->nonce, SourceKeyword::self)) + ->default(SourceKeyword::none); + self::assertSame("default-src 'none'", (string)$policy); + } + + /** + * @test + */ + public function defaultDirectiveConsidersVeto(): void + { + $policy = (new Policy($this->nonce, SourceKeyword::self)) + ->default(SourceKeyword::unsafeEval, SourceKeyword::none); + self::assertSame("default-src 'none'", (string)$policy); + } + + /** + * @test + */ + public function newDirectiveExtendsDefault(): void + { + $policy = (new Policy($this->nonce, SourceKeyword::self)) + ->extend(Directive::ScriptSrc, SourceKeyword::unsafeInline); + self::assertSame("default-src 'self'; script-src 'self' 'unsafe-inline'", (string)$policy); + } + + /** + * @test + */ + public function newDirectiveDoesNotExtendDefault(): void + { + $policy = (new Policy($this->nonce, SourceKeyword::self)) + ->set(Directive::ScriptSrc, SourceKeyword::unsafeInline); + self::assertSame("default-src 'self'; script-src 'unsafe-inline'", (string)$policy); + } + + /** + * @test + */ + public function sourceSchemeIsCompiled(): void + { + $policy = (new Policy($this->nonce, SourceKeyword::self, SourceScheme::blob)); + self::assertSame("default-src 'self' blob:", (string)$policy); + } + + /** + * @test + */ + public function nonceProxyIsCompiled(): void + { + $policy = (new Policy($this->nonce, SourceKeyword::self, SourceKeyword::nonceProxy)); + self::assertSame("default-src 'self' 'nonce-{$this->nonce->b64}'", (string)$policy); + } + + /** + * @test + */ + public function directiveIsRemoved(): void + { + $policy = (new Policy($this->nonce, SourceKeyword::self)) + ->remove(Directive::DefaultSrc); + self::assertSame('', (string)$policy); + } + + /** + * @test + */ + public function superfluousDirectivesArePurged(): void + { + $policy = (new Policy($this->nonce, SourceKeyword::self, SourceScheme::data)) + ->set(Directive::ScriptSrc, SourceKeyword::self, SourceScheme::data); + self::assertSame("default-src 'self' data:", (string)$policy); + } + + /** + * @test + */ + public function backendPolicyIsCompiled(): void + { + $nonce = Nonce::create(); + $policy = (new Policy($this->nonce)) + ->default(SourceKeyword::self) + ->extend(Directive::ScriptSrc, $nonce) + ->extend(Directive::StyleSrc, SourceKeyword::unsafeInline) + ->set(Directive::StyleSrcAttr, SourceKeyword::unsafeInline) + ->extend(Directive::ImgSrc, SourceScheme::data) + ->set(Directive::WorkerSrc, SourceKeyword::self, SourceScheme::blob) + ->extend(Directive::FrameSrc, SourceScheme::blob); + self::assertSame( + "default-src 'self'; script-src 'self' 'nonce-{$nonce->b64}'; " + . "style-src 'self' 'unsafe-inline'; style-src-attr 'unsafe-inline'; " + . "img-src 'self' data:; worker-src 'self' blob:; frame-src 'self' blob:", + (string)$policy + ); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/ScopeTest.php b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/ScopeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7a108851f64f30f911827917e4b6d6278f5c582a --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/ScopeTest.php @@ -0,0 +1,105 @@ +<?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; + +use TYPO3\CMS\Core\Http\ApplicationType; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; +use TYPO3\CMS\Core\Site\Entity\NullSite; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteInterface; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class ScopeTest extends UnitTestCase +{ + protected function tearDown(): void + { + parent::tearDown(); + Scope::reset(); + } + + /** + * @test + */ + public function backendSingletonIsCreated(): void + { + $scope = Scope::backend(); + self::assertSame($scope, Scope::backend()); + } + + /** + * @test + */ + public function frontendSingletonIsCreated(): void + { + $scope = Scope::frontend(); + self::assertSame($scope, Scope::frontend()); + } + + /** + * @test + */ + public function frontendSiteIsCreated(): void + { + $site = new Site('my-site', 1, []); + $scope = Scope::frontendSite($site); + self::assertSame($scope, Scope::frontendSite($site)); + } + + public static function frontendSingletonIsUsedForInvalidSiteDataProvider(): \Generator + { + yield [null]; + yield [new NullSite()]; + } + + /** + * @test + * @dataProvider frontendSingletonIsUsedForInvalidSiteDataProvider + */ + public function frontendSingletonIsUsedForInvalidSite(?SiteInterface $site): void + { + self::assertSame(Scope::frontend(), Scope::frontendSite($site)); + } + + /** + * @test + */ + public function frontendSiteIdentifierSingletonIsCreated(): void + { + $scope = Scope::frontendSiteIdentifier('my-site'); + self::assertSame($scope, Scope::frontendSiteIdentifier('my-site')); + } + + /** + * @test + */ + public function scopeIsCreatedFromString(): void + { + self::assertSame( + Scope::frontend(), + Scope::from(ApplicationType::FRONTEND->value) + ); + self::assertSame( + Scope::backend(), + Scope::from(ApplicationType::BACKEND->value) + ); + self::assertSame( + Scope::frontendSiteIdentifier('my-site'), + Scope::from(ApplicationType::FRONTEND->value . '.my-site') + ); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/UriValueTest.php b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/UriValueTest.php new file mode 100644 index 0000000000000000000000000000000000000000..384cd9ef68778cee9f8caebe591d66ace9d63658 --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/UriValueTest.php @@ -0,0 +1,59 @@ +<?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; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +class UriValueTest extends UnitTestCase +{ + public static function uriIsParsedAndSerializedDataProvider(): \Generator + { + yield ['https://www.typo3.org/uri/path.html?key=value#fragment', 'https://www.typo3.org/uri/path.html?key=value']; + yield ['//www.typo3.org/uri/path.html?key=value#fragment', '//www.typo3.org/uri/path.html?key=value']; + yield ['https://www.typo3.org#fragment', 'https://www.typo3.org']; + yield ['//www.typo3.org#fragment', '//www.typo3.org']; + yield ['https://*.typo3.org#fragment', 'https://*.typo3.org']; + yield ['//*.typo3.org#fragment', '//*.typo3.org']; + yield ['www.typo3.org#fragment', 'www.typo3.org']; + yield ['*.typo3.org#fragment', '*.typo3.org']; + + yield ['https://www.typo3.org/uri/path.html?key=value']; + yield ['https://www.typo3.org']; + yield ['https://*.typo3.org']; + yield ['//www.typo3.org/uri/path.html?key=value']; + yield ['//www.typo3.org']; + yield ['www.typo3.org']; + yield ['*.typo3.org']; + + // expected behavior, falls back to upstream parser´ + // (since e.g. query-param is given, which is not expected here in the scope of CSP with `UriValue`) + yield ['www.typo3.org?key=value', '/www.typo3.org?key=value']; + yield ['*.typo3.org?key=value', '/%2A.typo3.org?key=value']; + } + + /** + * @test + * @dataProvider uriIsParsedAndSerializedDataProvider + */ + public function uriIsParsedAndSerialized(string $value, string $expectation = null): void + { + $uri = new UriValue($value); + self::assertSame($expectation ?? $value, (string)$uri); + } +} diff --git a/typo3/sysext/extbase/Classes/Utility/DebuggerUtility.php b/typo3/sysext/extbase/Classes/Utility/DebuggerUtility.php index ab6e3d5c9b94318ff172e4f15f8322c0fad4898a..2346f035a057f8deb6fee5b83ea778e06ed3ff40 100644 --- a/typo3/sysext/extbase/Classes/Utility/DebuggerUtility.php +++ b/typo3/sysext/extbase/Classes/Utility/DebuggerUtility.php @@ -17,7 +17,9 @@ declare(strict_types=1); namespace TYPO3\CMS\Extbase\Utility; +use TYPO3\CMS\Core\Core\RequestId; use TYPO3\CMS\Core\SingletonInterface; +use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\DomainObject\AbstractEntity; use TYPO3\CMS\Extbase\DomainObject\AbstractValueObject; use TYPO3\CMS\Extbase\DomainObject\DomainObjectInterface; @@ -285,7 +287,7 @@ class DebuggerUtility $dump .= '<span class="extbase-debug-filtered">filtered</span>'; } } elseif (self::$renderedObjects->contains($object) && !$plainText) { - $dump = '<a href="javascript:;" onclick="document.location.hash=\'#' . spl_object_hash($object) . '\';" class="extbase-debug-seeabove">' . $dump . '<span class="extbase-debug-filtered">see above</span></a>'; + $dump = '<a href="#' . spl_object_hash($object) . '" class="extbase-debug-seeabove">' . $dump . '<span class="extbase-debug-filtered">see above</span></a>'; } elseif ($level >= self::$maxDepth && !$object instanceof \DateTimeInterface) { if ($plainText) { $dump .= ' ' . self::ansiEscapeWrap('max depth', '47;30', $ansiColors); @@ -521,8 +523,11 @@ class DebuggerUtility self::clearState(); $css = ''; if (!$plainText && self::$stylesheetEchoed === false) { + $attributes = GeneralUtility::implodeAttributes([ + 'nonce' => self::resolveNonceValue(), + ], true); $css = ' - <style> + <style ' . $attributes . '> .extbase-debugger-tree{position:relative} .extbase-debugger-tree input{position:absolute !important;float: none !important;top:0;left:0;height:14px;width:14px;margin:0 !important;cursor:pointer;opacity:0;z-index:2} .extbase-debugger-tree input~.extbase-debug-content{display:none} @@ -572,4 +577,9 @@ class DebuggerUtility return ''; } + + protected static function resolveNonceValue(): string + { + return GeneralUtility::makeInstance(RequestId::class)->nonce->b64; + } } diff --git a/typo3/sysext/extbase/Tests/Unit/Utility/DebuggerUtilityTest.php b/typo3/sysext/extbase/Tests/Unit/Utility/DebuggerUtilityTest.php index f6fdaeff6be1b1120ef08280cb58eefc7a00a896..98c22326176186ddde12fccb3afd2e2325fe3b68 100644 --- a/typo3/sysext/extbase/Tests/Unit/Utility/DebuggerUtilityTest.php +++ b/typo3/sysext/extbase/Tests/Unit/Utility/DebuggerUtilityTest.php @@ -19,6 +19,7 @@ namespace TYPO3\CMS\Extbase\Tests\Unit\Utility; use TYPO3\CMS\Extbase\Persistence\ObjectStorage; use TYPO3\CMS\Extbase\Tests\Fixture\DummyClass; +use TYPO3\CMS\Extbase\Tests\Unit\Utility\Fixtures\DebuggerUtilityAccessibleProxy; use TYPO3\CMS\Extbase\Utility\DebuggerUtility; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; @@ -201,4 +202,18 @@ class DebuggerUtilityTest extends UnitTestCase $result = DebuggerUtility::var_dump($class, null, 8, true, false, true); self::assertStringContainsString('test => protected uninitialized', $result); } + + /** + * @test + */ + public function varDumpUsesNonceValue(): void + { + DebuggerUtilityAccessibleProxy::setStylesheetEchoed(false); + $class = new class () { + protected \stdClass $test; + }; + $result = DebuggerUtilityAccessibleProxy::var_dump($class, null, 8, false, false, true); + self::assertTrue(DebuggerUtilityAccessibleProxy::getStylesheetEchoed()); + self::assertMatchesRegularExpression('#<style nonce="[^"]+">[^>]+</style>#m', $result); + } } diff --git a/typo3/sysext/extbase/Tests/Unit/Utility/Fixtures/DebuggerUtilityAccessibleProxy.php b/typo3/sysext/extbase/Tests/Unit/Utility/Fixtures/DebuggerUtilityAccessibleProxy.php new file mode 100644 index 0000000000000000000000000000000000000000..6eec2ab302b5e96d7c0ebe858d5cac4ab422d0f5 --- /dev/null +++ b/typo3/sysext/extbase/Tests/Unit/Utility/Fixtures/DebuggerUtilityAccessibleProxy.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\Extbase\Tests\Unit\Utility\Fixtures; + +use TYPO3\CMS\Extbase\Utility\DebuggerUtility; + +/** + * Accessible proxy with getters/setters for protected static properties. + */ +class DebuggerUtilityAccessibleProxy extends DebuggerUtility +{ + public static function getStylesheetEchoed(): bool + { + return self::$stylesheetEchoed; + } + + public static function setStylesheetEchoed(bool $stylesheetEchoed): void + { + self::$stylesheetEchoed = $stylesheetEchoed; + } +} diff --git a/typo3/sysext/filelist/Configuration/ContentSecurityPolicies.php b/typo3/sysext/filelist/Configuration/ContentSecurityPolicies.php new file mode 100644 index 0000000000000000000000000000000000000000..69d84790f0df525a5a3e1272aa9efdb43ddd0863 --- /dev/null +++ b/typo3/sysext/filelist/Configuration/ContentSecurityPolicies.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +namespace TYPO3\CMS\Backend; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; +use TYPO3\CMS\Core\Type\Map; + +/** + * Allows to fetch media assets from YouTube and Vimeo and their associated CDNs, + * to be embedded in an `<iframe>` of the corresponding info modal in the file list + * backend module. + */ +return Map::fromEntries([ + Scope::backend(), + new MutationCollection( + new Mutation( + MutationMode::Extend, + Directive::FrameSrc, + new UriValue('*.youtube-nocookie.com'), + new UriValue('*.youtube.com'), + new UriValue('*.vimeo.com') + ), + // @todo this still shows violations like the following when opened in the info modal + // > Refused to load the image 'https://i.ytimg.com/mqdefault.jpg' because it violates the + // > following Content Security Policy directive: "img-src 'self' 'self' 'self' data: *.i.ytimg.com + // + no problem when directly playing video via `/typo3/record/info` in new tab (not in modal) + // + fine in Safari/macOS, fails in Chrome/macOS, ... + new Mutation( + MutationMode::Extend, + Directive::ImgSrc, + new UriValue('*.ytimg.com'), + new UriValue('*.vimeocdn.com') + ), + ), +]); diff --git a/typo3/sysext/fluid/Classes/ViewHelpers/Security/NonceViewHelper.php b/typo3/sysext/fluid/Classes/ViewHelpers/Security/NonceViewHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..6488e8e1c930cdfd054dd96ed3cab8c2d5130b04 --- /dev/null +++ b/typo3/sysext/fluid/Classes/ViewHelpers/Security/NonceViewHelper.php @@ -0,0 +1,52 @@ +<?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\Fluid\ViewHelpers\Security; + +use TYPO3\CMS\Core\Core\RequestId; +use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper; + +/** + * This ViewHelper resolves the `nonce` attribute from the global server request object, + * or from the `PolicyProvider` service as a fall-back value. + * + * Examples + * ======== + * + * Basic usage + * ----------- + * + * :: + * + * <script nonce="{f:security.nonce()}">const inline = 'script';</script> + */ +final class NonceViewHelper extends AbstractViewHelper +{ + public function __construct(private readonly RequestId $requestId) + { + } + + public function initializeArguments(): void + { + parent::initializeArguments(); + } + + public function render(): string + { + return $this->requestId->nonce->b64; + } +} diff --git a/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php b/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php new file mode 100644 index 0000000000000000000000000000000000000000..e4ba86b19a568f0fd753db337a01f2c711025e86 --- /dev/null +++ b/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php @@ -0,0 +1,70 @@ +<?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\Frontend\Middleware; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\LoggerInterface; +use TYPO3\CMS\Core\Configuration\Features; +use TYPO3\CMS\Core\Core\RequestId; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; + +/** + * Adds Content-Security-Policy headers to response. + * + * @internal + */ +final class ContentSecurityPolicyHeaders implements MiddlewareInterface +{ + public function __construct( + private readonly Features $features, + private readonly RequestId $requestId, + private readonly LoggerInterface $logger, + private readonly PolicyProvider $policyProvider, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $request = $request->withAttribute('nonce', $this->requestId->nonce); + $response = $handler->handle($request); + + if (!$this->features->isFeatureEnabled('security.frontend.enforceContentSecurityPolicy')) { + return $response; + } + + $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', [ + 'scope' => (string)$scope, + 'uri' => (string)$request->getUri(), + ]); + return $response; + } + + $policy = $this->policyProvider->provideFor($scope); + if ($policy->isEmpty()) { + return $response; + } + return $response->withHeader('Content-Security-Policy', (string)$policy); + } +} diff --git a/typo3/sysext/frontend/Configuration/ContentSecurityPolicies.php b/typo3/sysext/frontend/Configuration/ContentSecurityPolicies.php new file mode 100644 index 0000000000000000000000000000000000000000..059e3d3a7b6d57c75a0782ca2379f26d4a9b3325 --- /dev/null +++ b/typo3/sysext/frontend/Configuration/ContentSecurityPolicies.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace TYPO3\CMS\Backend; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationMode; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme; +use TYPO3\CMS\Core\Type\Map; + +/** + * Provides a simple basic Content-Security-Policy for the generic frontend scope. + */ +return Map::fromEntries([ + Scope::frontend(), + new MutationCollection( + new Mutation(MutationMode::Extend, Directive::DefaultSrc, SourceKeyword::self), + new Mutation(MutationMode::Extend, Directive::ScriptSrc, SourceKeyword::nonceProxy), + // `style-src-attr 'unsafe-inline'` required for remaining inline styles, which is okay for color & dimension + // (e.g. `<div style="color: #000">` - but NOT having the possibility to use any other assets/files/URIs) + new Mutation(MutationMode::Set, Directive::StyleSrcAttr, SourceKeyword::unsafeInline), + // allow `data:` images + new Mutation(MutationMode::Extend, Directive::ImgSrc, SourceScheme::data), + ), +]); diff --git a/typo3/sysext/frontend/Configuration/RequestMiddlewares.php b/typo3/sysext/frontend/Configuration/RequestMiddlewares.php index ee1a0a972e12ab6e53885ee2280c08e20c1b0cea..47f4d4f58214ed1ac60d0d27a1d010f51b3173e5 100644 --- a/typo3/sysext/frontend/Configuration/RequestMiddlewares.php +++ b/typo3/sysext/frontend/Configuration/RequestMiddlewares.php @@ -171,10 +171,18 @@ return [ ], ], /** internal: do not use or reference this middleware in your own code */ + 'typo3/cms-frontend/csp-headers' => [ + 'target' => \TYPO3\CMS\Frontend\Middleware\ContentSecurityPolicyHeaders::class, + 'after' => [ + 'typo3/cms-frontend/prepare-tsfe-rendering', + ], + ], + /** internal: do not use or reference this middleware in your own code */ 'typo3/cms-core/response-propagation' => [ 'target' => \TYPO3\CMS\Core\Middleware\ResponsePropagation::class, 'after' => [ 'typo3/cms-frontend/output-compression', + 'typo3/cms-frontend/csp-headers', ], ], ], diff --git a/typo3/sysext/install/Classes/Controller/ControllerTrait.php b/typo3/sysext/install/Classes/Controller/ControllerTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..5e73b469e063a7ee1d255d7eb54210f4e2fb3eeb --- /dev/null +++ b/typo3/sysext/install/Classes/Controller/ControllerTrait.php @@ -0,0 +1,49 @@ +<?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\Controller; + +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Policy; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceKeyword; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme; +use TYPO3\CMS\Core\Security\Nonce; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal This class is a specific implementation and is not considered part of the Public TYPO3 API. + */ +trait ControllerTrait +{ + /** + * Using fixed Content-Security-Policy for Admin Tool (extensions and database might not be available) + */ + protected function createContentSecurityPolicy(Nonce $nonce): Policy + { + return GeneralUtility::makeInstance(Policy::class, $nonce) + ->default(SourceKeyword::self) + // script-src 'nonce-...' required for importmaps + ->extend(Directive::ScriptSrc, $nonce) + // `style-src 'unsafe-inline'` required for lit in safari and firefox to allow inline <style> tags + // (for browsers that do not support https://caniuse.com/mdn-api_shadowroot_adoptedstylesheets) + ->extend(Directive::StyleSrc, SourceKeyword::unsafeInline) + ->set(Directive::StyleSrcAttr, SourceKeyword::unsafeInline) + ->extend(Directive::ImgSrc, SourceScheme::data) + // `frame-src blob:` required for es-module-shims blob: URLs + ->extend(Directive::FrameSrc, SourceScheme::blob); + } +} diff --git a/typo3/sysext/install/Classes/Controller/InstallerController.php b/typo3/sysext/install/Classes/Controller/InstallerController.php index cf0cfae9aa82a45362ca9f4c09538377c465d947..cfcee098a7bad5faf355756d5bd751a14207db93 100644 --- a/typo3/sysext/install/Classes/Controller/InstallerController.php +++ b/typo3/sysext/install/Classes/Controller/InstallerController.php @@ -37,6 +37,7 @@ use TYPO3\CMS\Core\Messaging\FlashMessageQueue; use TYPO3\CMS\Core\Middleware\VerifyHostHeader; use TYPO3\CMS\Core\Package\FailsafePackageManager; use TYPO3\CMS\Core\Page\ImportMap; +use TYPO3\CMS\Core\Security\Nonce; use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\View\FluidViewAdapter; @@ -64,6 +65,8 @@ use TYPO3Fluid\Fluid\View\TemplateView as FluidTemplateView; */ final class InstallerController { + use ControllerTrait; + public function __construct( private readonly LateBootService $lateBootService, private readonly SilentConfigurationUpgradeService $silentConfigurationUpgradeService, @@ -97,12 +100,14 @@ final class InstallerController $view = $this->initializeView(); $view->assign('bust', $bust); $view->assign('initModule', $initModule); - $view->assign('importmap', $importMap->render($sitePath, 'rAnd0m')); + $nonce = Nonce::create(); + $view->assign('importmap', $importMap->render($sitePath, $nonce->b64)); return new HtmlResponse( $view->render('Installer/Init'), 200, [ + 'Content-Security-Policy' => (string)$this->createContentSecurityPolicy($nonce), 'Cache-Control' => 'no-cache, must-revalidate', 'Pragma' => 'no-cache', ] diff --git a/typo3/sysext/install/Classes/Controller/LayoutController.php b/typo3/sysext/install/Classes/Controller/LayoutController.php index 2fe416bad9f5385292fa876e6be5d81ecc28a39e..a2aecf8ecd63c35385373e617aaeff17e52c8b56 100644 --- a/typo3/sysext/install/Classes/Controller/LayoutController.php +++ b/typo3/sysext/install/Classes/Controller/LayoutController.php @@ -26,6 +26,7 @@ use TYPO3\CMS\Core\Http\JsonResponse; use TYPO3\CMS\Core\Information\Typo3Version; use TYPO3\CMS\Core\Package\FailsafePackageManager; use TYPO3\CMS\Core\Page\ImportMap; +use TYPO3\CMS\Core\Security\Nonce; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Install\Service\Exception\ConfigurationChangedException; use TYPO3\CMS\Install\Service\Exception\TemplateFileChangedException; @@ -41,6 +42,8 @@ use TYPO3\CMS\Install\Service\SilentTemplateFileUpgradeService; */ class LayoutController extends AbstractController { + use ControllerTrait; + private FailsafePackageManager $packageManager; private SilentConfigurationUpgradeService $silentConfigurationUpgradeService; private SilentTemplateFileUpgradeService $silentTemplateFileUpgradeService; @@ -76,18 +79,20 @@ class LayoutController extends AbstractController $initModule = $sitePath . $importMap->resolveImport('@typo3/install/init-install.js'); $view = $this->initializeView($request); + $nonce = Nonce::create(); $view->assignMultiple([ // time is used as cache bust for js and css resources 'bust' => $bust, 'siteName' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'], 'initModule' => $initModule, - 'importmap' => $importMap->render($sitePath, 'rAnd0m'), + 'importmap' => $importMap->render($sitePath, $nonce->b64), ]); return new HtmlResponse( $view->render('Layout/Init'), 200, [ 'Cache-Control' => 'no-cache, must-revalidate', + 'Content-Security-Policy' => (string)$this->createContentSecurityPolicy($nonce), 'Pragma' => 'no-cache', ] );