diff --git a/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php b/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php index 4b36a5160aa07fdecbf9f1ad289889fc75640289..04969027c4c1d08ec5b0016bc4c0487aa2bf9e93 100644 --- a/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php +++ b/typo3/sysext/backend/Classes/Middleware/ContentSecurityPolicyHeaders.php @@ -24,6 +24,7 @@ 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\Domain\ConsumableString; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; @@ -45,7 +46,7 @@ final class ContentSecurityPolicyHeaders implements MiddlewareInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $request = $request->withAttribute('nonce', $this->requestId->nonce); + $request = $request->withAttribute('nonce', new ConsumableString($this->requestId->nonce->b64)); $response = $handler->handle($request); if (!$this->features->isFeatureEnabled('security.backend.enforceContentSecurityPolicy')) { diff --git a/typo3/sysext/core/Classes/Domain/ConsumableString.php b/typo3/sysext/core/Classes/Domain/ConsumableString.php new file mode 100644 index 0000000000000000000000000000000000000000..9d83798cecea3799a9ab8ca38c0f367e2d47c590 --- /dev/null +++ b/typo3/sysext/core/Classes/Domain/ConsumableString.php @@ -0,0 +1,53 @@ +<?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\Domain; + +/** + * String wrapper that keeps track of how often the value was consumed. + * This can be used to make decisions during runtime, depending on whether + * a provided value actually has been used (e.g. in rendered content). + */ +final class ConsumableString implements \Countable, \Stringable +{ + /** + * @internal use the `consume()` method instead + */ + public readonly string $value; + private int $counter = 0; + + public function __construct(string $value) + { + $this->value = $value; + } + + public function __toString(): string + { + return $this->consume(); + } + + public function count(): int + { + return $this->counter; + } + + public function consume(): string + { + $this->counter++; + return $this->value; + } +} diff --git a/typo3/sysext/core/Classes/Http/Security/ReferrerEnforcer.php b/typo3/sysext/core/Classes/Http/Security/ReferrerEnforcer.php index ee2cde27d5920ea19e3b1780ac08dcdce6b161c2..d347d56ecfe84aea9c48694a5fc70e7b08d4255d 100644 --- a/typo3/sysext/core/Classes/Http/Security/ReferrerEnforcer.php +++ b/typo3/sysext/core/Classes/Http/Security/ReferrerEnforcer.php @@ -19,9 +19,9 @@ namespace TYPO3\CMS\Core\Http\Security; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Domain\ConsumableString; 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; @@ -85,8 +85,8 @@ class ReferrerEnforcer 'EXT:core/Resources/Public/JavaScript/ReferrerRefresh.js' ); $attributes = ['src' => $scriptUri]; - if ($nonce instanceof Nonce) { - $attributes['nonce'] = $nonce->b64; + if ($nonce instanceof ConsumableString) { + $attributes['nonce'] = $nonce->consume(); } // simulating navigate event by clicking anchor link // since meta-refresh won't change `document.referrer` in e.g. Firefox diff --git a/typo3/sysext/core/Classes/Page/ImportMap.php b/typo3/sysext/core/Classes/Page/ImportMap.php index 47ed7319051ffcba687d4562b83fb715c277a108..edd30529293cdef58acc8b1f60411863729fdaaa 100644 --- a/typo3/sysext/core/Classes/Page/ImportMap.php +++ b/typo3/sysext/core/Classes/Page/ImportMap.php @@ -20,6 +20,7 @@ namespace TYPO3\CMS\Core\Page; use Psr\EventDispatcher\EventDispatcherInterface; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Domain\ConsumableString; use TYPO3\CMS\Core\Package\PackageInterface; use TYPO3\CMS\Core\Page\Event\ResolveJavaScriptImportEvent; use TYPO3\CMS\Core\Utility\ArrayUtility; @@ -114,7 +115,7 @@ class ImportMap public function render( string $urlPrefix, - ?string $nonce, + null|string|ConsumableString $nonce, bool $includePolyfill = true ): string { if (count($this->extensionsToLoad) === 0 || count($this->getImportMaps()) === 0) { @@ -128,7 +129,7 @@ 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 ); - $nonceAttr = $nonce !== null ? ' nonce="' . htmlspecialchars($nonce) . '"' : ''; + $nonceAttr = $nonce !== null ? ' nonce="' . htmlspecialchars((string)$nonce) . '"' : ''; $html[] = sprintf('<script type="importmap"%s>%s</script>', $nonceAttr, $json); if ($includePolyfill) { diff --git a/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php b/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php index b19ec26c3551423f9f94d2f62a192e2f6d15bb81..949309be2e34343b55e83c32a7f8e217466b01ee 100644 --- a/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php +++ b/typo3/sysext/core/Classes/Page/JavaScriptRenderer.php @@ -17,6 +17,7 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Page; +use TYPO3\CMS\Core\Domain\ConsumableString; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\PathUtility; @@ -107,7 +108,7 @@ class JavaScriptRenderer return $this->items->toArray(); } - public function render(?string $nonce = null): string + public function render(null|string|ConsumableString $nonce = null): string { if ($this->isEmpty()) { return ''; @@ -117,7 +118,7 @@ class JavaScriptRenderer 'async' => 'async', ]; if ($nonce !== null) { - $attributes['nonce'] = $nonce; + $attributes['nonce'] = (string)$nonce; } return $this->createScriptElement( $attributes, @@ -125,7 +126,7 @@ class JavaScriptRenderer ); } - public function renderImportMap(string $sitePath, ?string $nonce = null): string + public function renderImportMap(string $sitePath, null|string|ConsumableString $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 4ef74d0c4a760693c94058e54b117be6e750701f..152f6e02fef1e09448183f6070a5617b96babcbc 100644 --- a/typo3/sysext/core/Classes/Page/PageRenderer.php +++ b/typo3/sysext/core/Classes/Page/PageRenderer.php @@ -23,6 +23,7 @@ use TYPO3\CMS\Backend\Routing\Router; use TYPO3\CMS\Backend\Routing\UriBuilder; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Domain\ConsumableString; use TYPO3\CMS\Core\Http\ApplicationType; use TYPO3\CMS\Core\Localization\LanguageServiceFactory; use TYPO3\CMS\Core\Localization\Locale; @@ -32,7 +33,6 @@ 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; @@ -332,7 +332,7 @@ class PageRenderer implements SingletonInterface protected $endingSlash = ''; protected JavaScriptRenderer $javaScriptRenderer; - protected ?Nonce $nonce = null; + protected ?ConsumableString $nonce = null; protected DocType $docType = DocType::html5; protected bool $applyNonceHint = false; @@ -839,7 +839,7 @@ class PageRenderer implements SingletonInterface return $this->renderXhtml; } - public function setNonce(Nonce $nonce): void + public function setNonce(?ConsumableString $nonce): void { $this->nonce = $nonce; } @@ -2111,8 +2111,8 @@ class PageRenderer implements SingletonInterface // see https://lit.dev/docs/api/ReactiveElement/#ReactiveElement.styles) if ($this->applyNonceHint && $this->nonce !== null) { $out .= GeneralUtility::wrapJS( - sprintf('window.litNonce = %s;', GeneralUtility::quoteJSvalue($this->nonce->b64)), - ['nonce' => $this->nonce->b64] + sprintf('window.litNonce = %s;', GeneralUtility::quoteJSvalue($this->nonce->consume())), + ['nonce' => $this->nonce->consume()] ); } @@ -2123,7 +2123,7 @@ class PageRenderer implements SingletonInterface $out .= $this->javaScriptRenderer->renderImportMap( // @todo hookup with PSR-7 request/response and GeneralUtility::getIndpEnv('TYPO3_SITE_PATH'), - $this->nonce?->b64 + $this->nonce ); // Include RequireJS @@ -2159,7 +2159,7 @@ class PageRenderer implements SingletonInterface ); } } - $out .= $this->javaScriptRenderer->render($this->nonce?->b64); + $out .= $this->javaScriptRenderer->render($this->nonce); return $out; } @@ -2311,8 +2311,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; + // use nonce if given + if ($this->nonce !== null) { + $tagAttributes['nonce'] = $this->nonce->consume(); } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); $tag = '<link ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . $this->endingSlash . '>'; @@ -2379,8 +2380,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; + // use nonce if given + if ($this->nonce !== null) { + $tagAttributes['nonce'] = $this->nonce->consume(); } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); $tag = '<script ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '></script>'; @@ -2440,8 +2442,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; + // use nonce if given + if ($this->nonce !== null) { + $tagAttributes['nonce'] = $this->nonce->consume(); } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); $tag = '<script ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '></script>'; @@ -2880,8 +2883,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; + // use nonce if given - special case, since content is created from a static file + if ($this->nonce !== null) { + $tagAttributes['nonce'] = $this->nonce->consume(); } $tagAttributes = array_merge($tagAttributes, $properties['tagAttributes'] ?? []); return '<style ' . GeneralUtility::implodeAttributes($tagAttributes, true, true) . '>' . LF diff --git a/typo3/sysext/core/Tests/Unit/Http/Security/ReferrerEnforcerTest.php b/typo3/sysext/core/Tests/Unit/Http/Security/ReferrerEnforcerTest.php index c588aaa8353c8c6edcac42f178c4806cf137853a..440720d255fe01d344956573ec5c5e38ee399f90 100644 --- a/typo3/sysext/core/Tests/Unit/Http/Security/ReferrerEnforcerTest.php +++ b/typo3/sysext/core/Tests/Unit/Http/Security/ReferrerEnforcerTest.php @@ -18,6 +18,7 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Tests\Unit\Http\Security; use Psr\Http\Message\ServerRequestInterface; +use TYPO3\CMS\Core\Domain\ConsumableString; use TYPO3\CMS\Core\Http\NormalizedParams; use TYPO3\CMS\Core\Http\Security\InvalidReferrerException; use TYPO3\CMS\Core\Http\Security\MissingReferrerException; @@ -213,7 +214,7 @@ final class ReferrerEnforcerTest extends UnitTestCase $request = $this->createMock(ServerRequestInterface::class); $request->method('getAttribute')->willReturnCallback(static fn (string $name): mixed => match ($name) { 'normalizedParams' => $normalizedParams, - 'nonce' => $nonce, + 'nonce' => $nonce !== null ? new ConsumableString($nonce->b64) : null, default => null, }); $request->method('getServerParams')->willReturn(['HTTP_REFERER' => $referrer]); diff --git a/typo3/sysext/core/Tests/Unit/Page/ImportMapTest.php b/typo3/sysext/core/Tests/Unit/Page/ImportMapTest.php index f3caacbd749219436dc648b3035b1529658b65d7..26d7502087396eeeebe1a8a78629e797c4582e0d 100644 --- a/typo3/sysext/core/Tests/Unit/Page/ImportMapTest.php +++ b/typo3/sysext/core/Tests/Unit/Page/ImportMapTest.php @@ -18,6 +18,7 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Tests\Unit\Page; use TYPO3\CMS\Core\Core\Environment; +use TYPO3\CMS\Core\Domain\ConsumableString; use TYPO3\CMS\Core\Package\MetaData; use TYPO3\CMS\Core\Package\PackageInterface; use TYPO3\CMS\Core\Package\PackageManager; @@ -68,7 +69,7 @@ final class ImportMapTest extends UnitTestCase $this->packages = ['core']; $importMap = new ImportMap($this->getPackages()); - $output = $importMap->render('/', 'rAnd0m'); + $output = $importMap->render('/', new ConsumableString('rAnd0m')); self::assertSame('', $output); } @@ -82,7 +83,7 @@ final class ImportMapTest extends UnitTestCase $importMap = new ImportMap($this->getPackages()); $url = $importMap->resolveImport('lit'); - $output = $importMap->render('/', 'rAnd0m'); + $output = $importMap->render('/', new ConsumableString('rAnd0m')); self::assertSame('', $output); self::assertNull($url); @@ -110,7 +111,7 @@ final class ImportMapTest extends UnitTestCase $importMap = new ImportMap($this->getPackages()); $url = $importMap->resolveImport('lit'); - $output = $importMap->render('/', 'rAnd0m'); + $output = $importMap->render('/', new ConsumableString('rAnd0m')); self::assertStringStartsWith('Fixtures/ImportMap/core/Resources/Public/JavaScript/Contrib/lit/index.js?bust=', $url); self::assertStringContainsString('"lit/":"/Fixtures/ImportMap/core/Resources/Public/JavaScript/Contrib/lit/"', $output); @@ -127,7 +128,7 @@ final class ImportMapTest extends UnitTestCase $importMap = new ImportMap($this->getPackages()); $importMap->includeImportsFor('@typo3/core/Module1.js'); - $output = $importMap->render('/', 'rAnd0m'); + $output = $importMap->render('/', new ConsumableString('rAnd0m')); self::assertStringContainsString('"@typo3/core/":"/Fixtures/ImportMap/core/Resources/Public/JavaScript/', $output); self::assertStringContainsString('"@typo3/core/Module1.js":"/Fixtures/ImportMap/core/Resources/Public/JavaScript/Module1.js?bust=', $output); @@ -142,7 +143,7 @@ final class ImportMapTest extends UnitTestCase $importMap = new ImportMap($this->getPackages()); $importMap->includeImportsFor('@typo3/core/Module1.js'); - $output = $importMap->render('/', 'rAnd0m'); + $output = $importMap->render('/', new ConsumableString('rAnd0m')); self::assertStringContainsString('"@typo3/core/":"/Fixtures/ImportMap/core/Resources/Public/JavaScript/', $output); self::assertStringContainsString('"@typo3/core/Module1.js":"/Fixtures/ImportMap/core/Resources/Public/JavaScript/Module1.js?bust=', $output); @@ -158,7 +159,7 @@ final class ImportMapTest extends UnitTestCase $importMap = new ImportMap($this->getPackages()); $importMap->includeImportsFor('@typo3/package2/File.js'); - $output = $importMap->render('/', 'rAnd0m'); + $output = $importMap->render('/', new ConsumableString('rAnd0m')); self::assertStringContainsString('"@typo3/package2/File.js":"/Fixtures/ImportMap/package3/Resources/Public/JavaScript/Overrides/Package2/File.js?bust=', $output); self::assertThat( @@ -177,7 +178,7 @@ final class ImportMapTest extends UnitTestCase $this->packages = ['core', 'package2']; $importMap = new ImportMap($this->getPackages()); $importMap->includeImportsFor('@typo3/package2/file.js'); - $output = $importMap->render('/', 'rAnd0m'); + $output = $importMap->render('/', new ConsumableString('rAnd0m')); self::assertStringContainsString('@typo3/core/', $output); } @@ -190,7 +191,7 @@ final class ImportMapTest extends UnitTestCase $this->packages = ['core', 'package2', 'package4']; $importMap = new ImportMap($this->getPackages()); $importMap->includeImportsFor('@typo3/package2/file.js'); - $output = $importMap->render('/', 'rAnd0m'); + $output = $importMap->render('/', new ConsumableString('rAnd0m')); self::assertThat( $output, @@ -209,7 +210,7 @@ final class ImportMapTest extends UnitTestCase $importMap = new ImportMap($this->getPackages()); $importMap->includeAllImports(); $importMap->includeImportsFor('@typo3/package2/file.js'); - $output = $importMap->render('/', 'rAnd0m'); + $output = $importMap->render('/', new ConsumableString('rAnd0m')); self::assertStringContainsString('@typo3/core/', $output); self::assertStringContainsString('@acme/package4/Backend/Helper.js', $output); diff --git a/typo3/sysext/core/Tests/Unit/Page/PageRendererTest.php b/typo3/sysext/core/Tests/Unit/Page/PageRendererTest.php index f89b818a42a66db56aa1c5f31c5a5ae301cd878a..d868d267dce1514a486683a1afde0f7763def016 100644 --- a/typo3/sysext/core/Tests/Unit/Page/PageRendererTest.php +++ b/typo3/sysext/core/Tests/Unit/Page/PageRendererTest.php @@ -17,6 +17,7 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Tests\Unit\Page; +use TYPO3\CMS\Core\Domain\ConsumableString; use TYPO3\CMS\Core\Page\ImportMap; use TYPO3\CMS\Core\Page\ImportMapFactory; use TYPO3\CMS\Core\Page\PageRenderer; @@ -34,7 +35,9 @@ final class PageRendererTest extends UnitTestCase { parent::setUp(); $importMapMock = $this->createMock(ImportMap::class); - $importMapMock->method('render')->with(self::isType('string'), self::isType('string'))->willReturn(''); + $importMapMock->method('render') + ->with(self::isType('string'), self::isInstanceOf(ConsumableString::class)) + ->willReturn(''); $importMapFactoryMock = $this->createMock(ImportMapFactory::class); $importMapFactoryMock->method('create')->willReturn($importMapMock); GeneralUtility::setSingletonInstance(ImportMapFactory::class, $importMapFactoryMock); diff --git a/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php b/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php index 03ab7e27a835b02dc871e0fd7aba67e66fc81b8e..e291ca8d9b8766bccc53a17f9a8e08116384ef6f 100644 --- a/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php +++ b/typo3/sysext/frontend/Classes/Middleware/ContentSecurityPolicyHeaders.php @@ -24,6 +24,7 @@ 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\Domain\ConsumableString; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\UriValue; @@ -45,7 +46,7 @@ final class ContentSecurityPolicyHeaders implements MiddlewareInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $request = $request->withAttribute('nonce', $this->requestId->nonce); + $request = $request->withAttribute('nonce', new ConsumableString($this->requestId->nonce->b64)); $response = $handler->handle($request); if (!$this->features->isFeatureEnabled('security.frontend.enforceContentSecurityPolicy')) {