diff --git a/src/Asset/NonceNullProvider.php b/src/Asset/NonceNullProvider.php new file mode 100644 index 00000000..9502a385 --- /dev/null +++ b/src/Asset/NonceNullProvider.php @@ -0,0 +1,18 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\WebpackEncoreBundle\Asset; + + + +interface NonceProviderInterface +{ + /** + * Returns a nonce attribute value + * + * @return string + */ + public function getNonceValue(): string; +} diff --git a/src/Asset/TagRenderer.php b/src/Asset/TagRenderer.php index 4e5ffc25..42cb0645 100644 --- a/src/Asset/TagRenderer.php +++ b/src/Asset/TagRenderer.php @@ -18,6 +18,8 @@ */ class TagRenderer implements ResetInterface { + private const NONCE_ATTRIBUTE_NAME = 'nonce'; + private $entrypointLookupCollection; private $packages; @@ -26,10 +28,13 @@ class TagRenderer implements ResetInterface private $renderedFiles = []; + private $nonceProvider; + public function __construct( $entrypointLookupCollection, Packages $packages, - array $defaultAttributes = [] + array $defaultAttributes = [], + NonceProviderInterface $nonceProvider = null ) { if ($entrypointLookupCollection instanceof EntrypointLookupInterface) { @trigger_error(sprintf('The "$entrypointLookupCollection" argument in method "%s()" must be an instance of EntrypointLookupCollection.', __METHOD__), E_USER_DEPRECATED); @@ -47,7 +52,7 @@ public function __construct( $this->packages = $packages; $this->defaultAttributes = $defaultAttributes; - + $this->nonceProvider = $nonceProvider; $this->reset(); } @@ -67,7 +72,7 @@ public function renderWebpackScriptTags(string $entryName, string $packageName = $scriptTags[] = sprintf( '', - $this->convertArrayToAttributes($attributes) + $this->convertArrayToAttributes($attributes,'script') ); $this->renderedFiles['scripts'][] = $attributes['src']; @@ -93,7 +98,7 @@ public function renderWebpackLinkTags(string $entryName, string $packageName = n $scriptTags[] = sprintf( '', - $this->convertArrayToAttributes($attributes) + $this->convertArrayToAttributes($attributes,'link') ); $this->renderedFiles['styles'][] = $attributes['href']; @@ -142,8 +147,12 @@ private function getEntrypointLookup(string $buildName): EntrypointLookupInterfa return $this->entrypointLookupCollection->getEntrypointLookup($buildName); } - private function convertArrayToAttributes(array $attributesMap): string + private function convertArrayToAttributes(array $attributesMap,string $targetTag=''): string { + if (\in_array($targetTag,['script','style'],true) && null !== $this->nonceProvider && $this->nonceProvider->getNonceValue() != '') { + $attributesMap = array_merge([self::NONCE_ATTRIBUTE_NAME => $this->nonceProvider->getNonceValue()], $attributesMap); + } + return implode(' ', array_map( function ($key, $value) { return sprintf('%s="%s"', $key, htmlentities($value)); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 9dce21d2..cce5c044 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -47,6 +47,14 @@ public function getConfigTreeBuilder() ->info('Enable caching of the entry point file(s)') ->defaultFalse() ->end() + ->booleanNode('nonce_enable') + ->info('Enable attrbute in script and style tag') + ->defaultFalse() + ->end() + ->scalarNode('nonce_provider') + ->info('Nonce provider class') + ->defaultNull() + ->end() ->booleanNode('strict_mode') ->info('Throw an exception if the entrypoints.json file is missing or an entry is missing from the data') ->defaultTrue() diff --git a/src/DependencyInjection/WebpackEncoreExtension.php b/src/DependencyInjection/WebpackEncoreExtension.php index 521a2e97..ee1f2437 100644 --- a/src/DependencyInjection/WebpackEncoreExtension.php +++ b/src/DependencyInjection/WebpackEncoreExtension.php @@ -69,6 +69,8 @@ public function load(array $configs, ContainerBuilder $container) $container->getDefinition('webpack_encore.tag_renderer') ->replaceArgument(2, $defaultAttributes); + + if ($config['preload']) { if (!class_exists(AddLinkHeaderListener::class)) { @@ -77,6 +79,21 @@ public function load(array $configs, ContainerBuilder $container) } else { $container->removeDefinition('webpack_encore.preload_assets_event_listener'); } + + if (false !== $config['nonce_enable']) { + + if (empty($config['nonce_provider'])) { + throw new \LogicException('If nonce_enable it is true must be provide nonce_provider service class'); + } + + $serviceId = $config['nonce_provider']; + $serviceNonceProvider = new Reference($serviceId); + + $container->getDefinition('webpack_encore.tag_renderer') + ->replaceArgument(3, $serviceNonceProvider); + + } + } private function entrypointFactory(ContainerBuilder $container, string $name, string $path, bool $cacheEnabled, bool $strictMode): Reference diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 2464c368..9d0204ea 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -13,11 +13,14 @@ + + + diff --git a/tests/Asset/TagRendererTest.php b/tests/Asset/TagRendererTest.php index 25131807..f4652ae7 100644 --- a/tests/Asset/TagRendererTest.php +++ b/tests/Asset/TagRendererTest.php @@ -14,32 +14,17 @@ use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupCollection; use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface; use Symfony\WebpackEncoreBundle\Asset\IntegrityDataProviderInterface; +use Symfony\WebpackEncoreBundle\Asset\NonceNullProvider; +use Symfony\WebpackEncoreBundle\Asset\NonceProviderInterface; use Symfony\WebpackEncoreBundle\Asset\TagRenderer; class TagRendererTest extends TestCase { public function testRenderScriptTagsWithDefaultAttributes() { - $entrypointLookup = $this->createMock(EntrypointLookupInterface::class); - $entrypointLookup->expects($this->once()) - ->method('getJavaScriptFiles') - ->willReturn(['/build/file1.js', '/build/file2.js']); - $entrypointCollection = $this->createMock(EntrypointLookupCollection::class); - $entrypointCollection->expects($this->once()) - ->method('getEntrypointLookup') - ->withConsecutive(['_default']) - ->will($this->onConsecutiveCalls($entrypointLookup)); + $entrypointCollection = $this->getMockEntryPointLookup(); - $packages = $this->createMock(Packages::class); - $packages->expects($this->exactly(2)) - ->method('getUrl') - ->withConsecutive( - ['/build/file1.js', 'custom_package'], - ['/build/file2.js', 'custom_package'] - ) - ->willReturnCallback(function ($path) { - return 'http://localhost:8080'.$path; - }); + $packages = $this->getMockPackges(); $renderer = new TagRenderer($entrypointCollection, $packages, []); $output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package'); @@ -69,7 +54,7 @@ public function testRenderScriptTagsWithBadFilename() $packages->expects($this->once()) ->method('getUrl') ->willReturnCallback(function ($path) { - return 'http://localhost:8080'.$path; + return 'http://localhost:8080' . $path; }); $renderer = new TagRenderer($entrypointCollection, $packages, ['crossorigin' => 'anonymous']); @@ -115,7 +100,7 @@ public function testRenderScriptTagsWithinAnEntryPointCollection() ['/build/file3.js', 'specific_package'] ) ->willReturnCallback(function ($path) { - return 'http://localhost:8080'.$path; + return 'http://localhost:8080' . $path; }); $renderer = new TagRenderer($entrypointCollection, $packages, ['crossorigin' => 'anonymous']); @@ -157,16 +142,7 @@ public function testRenderScriptTagsWithHashes() ->withConsecutive(['_default']) ->will($this->onConsecutiveCalls($entrypointLookup)); - $packages = $this->createMock(Packages::class); - $packages->expects($this->exactly(2)) - ->method('getUrl') - ->withConsecutive( - ['/build/file1.js', 'custom_package'], - ['/build/file2.js', 'custom_package'] - ) - ->willReturnCallback(function ($path) { - return 'http://localhost:8080'.$path; - }); + $packages = $this->getMockPackges(); $renderer = new TagRenderer($entrypointCollection, $packages, ['crossorigin' => 'anonymous']); $output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package'); @@ -198,7 +174,7 @@ public function testGetRenderedFilesAndReset() $packages->expects($this->any()) ->method('getUrl') ->willReturnCallback(function ($path) { - return 'http://localhost:8080'.$path; + return 'http://localhost:8080' . $path; }); $renderer = new TagRenderer($entrypointCollection, $packages); @@ -211,4 +187,95 @@ public function testGetRenderedFilesAndReset() $this->assertEmpty($renderer->getRenderedScripts()); $this->assertEmpty($renderer->getRenderedStyles()); } + + /** + * @dataProvider nonceProvider() + */ + public function testRenderScriptWithNonceNullNonce($nonceProvider): void + { + $entrypointCollection = $this->getMockEntryPointLookup(); + + $packages = $this->getMockPackges(); + + $renderer = new TagRenderer($entrypointCollection, $packages, [], $nonceProvider); + + $output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package'); + $this->assertStringContainsString( + '', + $output + ); + + $this->assertStringContainsString( + '', + $output + ); + + } + + public function testRenderScriptWithCustomNonce(): void + { + $entrypointCollection = $this->getMockEntryPointLookup(); + + $packages = $this->getMockPackges(); + + $customNonce = (new class implements NonceProviderInterface { + /** + * @inheritDoc + */ + public function getNonceValue(): string + { + return '123456-nonce'; + } + }); + + $renderer = new TagRenderer($entrypointCollection, $packages, [], $customNonce); + + $output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package'); + $this->assertStringContainsString( + '', + $output + ); + + $this->assertStringContainsString( + '', + $output + ); + + } + + + private function getMockEntryPointLookup() + { + $entrypointLookup = $this->createMock(EntrypointLookupInterface::class); + $entrypointLookup->expects($this->once()) + ->method('getJavaScriptFiles') + ->willReturn(['/build/file1.js', '/build/file2.js']); + $entrypointCollection = $this->createMock(EntrypointLookupCollection::class); + $entrypointCollection->expects($this->once()) + ->method('getEntrypointLookup') + ->withConsecutive(['_default']) + ->will($this->onConsecutiveCalls($entrypointLookup)); + return $entrypointCollection; + } + + private function getMockPackges() + { + $packages = $this->createMock(Packages::class); + $packages->expects($this->exactly(2)) + ->method('getUrl') + ->withConsecutive( + ['/build/file1.js', 'custom_package'], + ['/build/file2.js', 'custom_package'] + ) + ->willReturnCallback(function ($path) { + return 'http://localhost:8080' . $path; + }); + return $packages; + } + + public function nonceProvider(): \Generator + { + yield [ new NonceNullProvider()]; + yield [ null ]; + } }