diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index ce62c9cdf836b..bb2ff02f4008f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -56,6 +56,7 @@ CHANGELOG * Make `ValidatorCacheWarmer` use `kernel.build_dir` instead of `cache_dir` * Make `SerializeCacheWarmer` use `kernel.build_dir` instead of `cache_dir` * Support executing custom workflow validators during container compilation + * Add new `framework.asset_mapper.importmap_integrity_algorithms` option to add integrity metadata to importmaps 7.2 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 4c40455526e57..36282a868aab2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -863,6 +863,7 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $ ->fixXmlConfig('excluded_pattern') ->fixXmlConfig('extension') ->fixXmlConfig('importmap_script_attribute') + ->fixXmlConfig('importmap_integrity_algorithm') ->children() // add array node called "paths" that will be an array of strings ->arrayNode('paths') @@ -972,6 +973,12 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $ ->end() ->end() ->end() + ->arrayNode('importmap_integrity_algorithms') + ->info('Array of algorithms used to compute importmap resources integrity.') + ->beforeNormalization()->castToArray()->end() + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 385a4caf38ded..31adf624ffa82 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1482,6 +1482,7 @@ private function registerAssetMapperConfiguration(array $config, ContainerBuilde $container ->getDefinition('asset_mapper.mapped_asset_factory') ->replaceArgument(2, $config['vendor_dir']) + ->setArgument(3, $config['importmap_integrity_algorithms']) ; $container diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php index eeb1ceb4f8962..550d00643363c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php @@ -67,6 +67,7 @@ service('asset_mapper.public_assets_path_resolver'), service('asset_mapper_compiler'), abstract_arg('vendor directory'), + abstract_arg('integrity hash algorithms'), ]) ->set('asset_mapper.cached_mapped_asset_factory', CachedMappedAssetFactory::class) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 3a6242b837dd3..1f1e8dfa4d721 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -208,6 +208,7 @@ + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index c8142e98ab1a7..d8fac8cafdf28 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -148,6 +148,7 @@ public function testAssetMapperCanBeEnabled() 'formats' => [], 'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [], ], + 'importmap_integrity_algorithms' => [], ]; $this->assertEquals($defaultConfig, $config['asset_mapper']); @@ -877,6 +878,7 @@ protected static function getBundleDefaultConfig() 'formats' => [], 'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [], ], + 'importmap_integrity_algorithms' => [], ], 'cache' => [ 'pools' => [], diff --git a/src/Symfony/Component/AssetMapper/CHANGELOG.md b/src/Symfony/Component/AssetMapper/CHANGELOG.md index 93d622101c0c8..5b998a3eaf991 100644 --- a/src/Symfony/Component/AssetMapper/CHANGELOG.md +++ b/src/Symfony/Component/AssetMapper/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add support for pre-compressing assets with Brotli, Zstandard, Zopfli, and gzip * Add option `--dry-run` to `importmap:require` command * `ImportMapRequireCommand` now takes `projectDir` as a required third constructor argument + * Add integrity metadata to importmaps 7.2 --- diff --git a/src/Symfony/Component/AssetMapper/Factory/MappedAssetFactory.php b/src/Symfony/Component/AssetMapper/Factory/MappedAssetFactory.php index f1cf2ad5897f7..a92ecb5d713b1 100644 --- a/src/Symfony/Component/AssetMapper/Factory/MappedAssetFactory.php +++ b/src/Symfony/Component/AssetMapper/Factory/MappedAssetFactory.php @@ -13,6 +13,7 @@ use Symfony\Component\AssetMapper\AssetMapperCompiler; use Symfony\Component\AssetMapper\Exception\CircularAssetsException; +use Symfony\Component\AssetMapper\Exception\LogicException; use Symfony\Component\AssetMapper\Exception\RuntimeException; use Symfony\Component\AssetMapper\MappedAsset; use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; @@ -25,6 +26,7 @@ class MappedAssetFactory implements MappedAssetFactoryInterface { private const PREDIGESTED_REGEX = '/-([0-9a-zA-Z]{7,128}\.digested)/'; private const PUBLIC_DIGEST_LENGTH = 7; + private const INTEGRITY_HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512']; private array $assetsCache = []; private array $assetsBeingCreated = []; @@ -33,7 +35,11 @@ public function __construct( private readonly PublicAssetsPathResolverInterface $assetsPathResolver, private readonly AssetMapperCompiler $compiler, private readonly string $vendorDir, + private readonly array $integrityHashAlgorithms = [], ) { + if ($unsupportedAlgorithms = array_diff($this->integrityHashAlgorithms, self::INTEGRITY_HASH_ALGORITHMS)) { + throw new LogicException(sprintf('Unsupported "%s" algorithm(s). Supported ones are "%s".', implode('", "', $unsupportedAlgorithms), implode('", "', self::INTEGRITY_HASH_ALGORITHMS))); + } } public function createMappedAsset(string $logicalPath, string $sourcePath): ?MappedAsset @@ -63,6 +69,7 @@ public function createMappedAsset(string $logicalPath, string $sourcePath): ?Map $asset->getDependencies(), $asset->getFileDependencies(), $asset->getJavaScriptImports(), + $this->getIntegrity($asset, $content), ); $this->assetsCache[$logicalPath] = $asset; @@ -131,4 +138,20 @@ private function isVendor(string $sourcePath): bool return $sourcePath && $vendorDir && str_starts_with($sourcePath, $vendorDir); } + + private function getIntegrity(MappedAsset $asset, ?string $content): ?string + { + $integrity = null; + + foreach ($this->integrityHashAlgorithms as $algorithm) { + $hash = null !== $content + ? hash($algorithm, $content, true) + : hash_file($algorithm, $asset->sourcePath, true) + ; + + $integrity .= \sprintf('%s%s-%s', $integrity ? ' ' : '', $algorithm, base64_encode($hash)); + } + + return $integrity; + } } diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php index 89579fb313ed2..bd813c3e6d44d 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapGenerator.php @@ -50,7 +50,7 @@ public function getEntrypointNames(): array /** * @param string[] $entrypointNames * - * @return array + * @return array * * @internal */ @@ -83,7 +83,7 @@ public function getImportMapData(array $entrypointNames): array /** * @internal * - * @return array + * @return array */ public function getRawImportMapData(): array { @@ -104,9 +104,10 @@ public function getRawImportMapData(): array throw $this->createMissingImportMapAssetException($entry); } - $path = $asset->publicPath; - $data = ['path' => $path, 'type' => $entry->type->value]; - $rawImportMapData[$entry->importName] = $data; + $rawImportMapData[$entry->importName] = ['path' => $asset->publicPath, 'type' => $entry->type->value]; + if ($asset->integrity) { + $rawImportMapData[$entry->importName]['integrity'] = $asset->integrity; + } } return $rawImportMapData; diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php index 87d557f6d422f..855a5bd68238b 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php @@ -49,6 +49,7 @@ public function render(string|array $entryPoint, array $attributes = []): string $importMap = []; $modulePreloads = []; $cssLinks = []; + $integrity = []; $polyfillPath = null; foreach ($importMapData as $importName => $data) { $path = $data['path']; @@ -70,8 +71,12 @@ public function render(string|array $entryPoint, array $attributes = []): string } $preload = $data['preload'] ?? false; + $assetIntegrity = $data['integrity'] ?? false; if ('css' !== $data['type']) { $importMap[$importName] = $path; + if ($assetIntegrity) { + $integrity[$path] = $assetIntegrity; + } if ($preload) { $modulePreloads[] = $path; } @@ -96,7 +101,7 @@ public function render(string|array $entryPoint, array $attributes = []): string } $scriptAttributes = $attributes || $this->scriptAttributes ? ' '.$this->createAttributesString($attributes) : ''; - $importMapJson = json_encode(['imports' => $importMap], \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG); + $importMapJson = json_encode(['imports' => $importMap, ...$integrity ? ['integrity' => $integrity] : []], \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG); $output .= << diff --git a/src/Symfony/Component/AssetMapper/MappedAsset.php b/src/Symfony/Component/AssetMapper/MappedAsset.php index 6bb828d61cae2..7bc4348d604e3 100644 --- a/src/Symfony/Component/AssetMapper/MappedAsset.php +++ b/src/Symfony/Component/AssetMapper/MappedAsset.php @@ -52,6 +52,7 @@ public function __construct( private array $dependencies = [], private array $fileDependencies = [], private array $javaScriptImports = [], + public ?string $integrity = null, ) { if (null !== $sourcePath) { $this->sourcePath = $sourcePath; diff --git a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php index f63edd6a7dfd6..ff60f5ee4ee81 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php @@ -19,6 +19,7 @@ use Symfony\Component\AssetMapper\Compiler\CssAssetUrlCompiler; use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler; use Symfony\Component\AssetMapper\Exception\CircularAssetsException; +use Symfony\Component\AssetMapper\Exception\LogicException; use Symfony\Component\AssetMapper\Factory\MappedAssetFactory; use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; use Symfony\Component\AssetMapper\MappedAsset; @@ -148,7 +149,35 @@ public function testCreateMappedAssetInMissingVendor() $this->assertFalse($asset->isVendor); } - private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?string $vendorDir = self::VENDOR_FIXTURES_DIR): MappedAssetFactory + public function testCreateMappedAssetWithoutIntegrity() + { + $factory = $this->createFactory(); + $asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js'); + $this->assertNull($asset->integrity); + } + + public function testCreateMappedAssetWithOneIntegrityAlgorithm() + { + $factory = $this->createFactory(integrityHashAlgorithms: ['sha256']); + $asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js'); + $this->assertSame('sha256-b8bze+0OP5qLVVEG0aUh25UkvNjZXLeugH9Jg7MvSz8=', $asset->integrity); + } + + public function testCreateMappedAssetWithManyIntegrityAlgorithms() + { + $factory = $this->createFactory(integrityHashAlgorithms: ['sha256', 'sha384']); + $asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js'); + $this->assertSame('sha256-b8bze+0OP5qLVVEG0aUh25UkvNjZXLeugH9Jg7MvSz8= sha384-2cpbxkWC8I4PKAhlQ+LaFmVek6qd8w35xUZ+QRGMzcSvX9SP2EgjLvKSawSmS9J7', $asset->integrity); + } + + public function testCreateMappedAssetWithInvalidIntegrityAlgorithm() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unsupported "sha1" algorithm(s). Supported ones are "sha256", "sha384", "sha512".'); + $this->createFactory(integrityHashAlgorithms: ['sha1']); + } + + private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?string $vendorDir = self::VENDOR_FIXTURES_DIR, array $integrityHashAlgorithms = []): MappedAssetFactory { $compilers = [ new JavaScriptImportPathCompiler($this->createMock(ImportMapConfigReader::class)), @@ -174,6 +203,7 @@ private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?s $pathResolver, $compiler, $vendorDir, + $integrityHashAlgorithms, ); // mock the AssetMapper to behave like normal: by calling back to the factory diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php index bdc8bc36c1ed7..813aced32c3b1 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapGeneratorTest.php @@ -92,6 +92,10 @@ public function testGetImportMapData() path: 'styles/never_imported_css.css', type: ImportMapType::CSS, ), + self::createLocalEntry( + 'js_file_with_integrity', + path: 'js_file_with_integrity.js', + ), ]); $importedFile1 = new MappedAsset( @@ -142,6 +146,13 @@ public function testGetImportMapData() publicPathWithoutDigest: '/assets/styles/never_imported_css.css', publicPath: '/assets/styles/never_imported_css-d1g35t.css', ); + $jsFileWithIntegrity = new MappedAsset( + 'js_file_with_integrity.js', + '/path/to/js_file_with_integrity.js', + publicPathWithoutDigest: '/assets/js_file_with_integrity.js', + publicPath: '/assets/js_file_with_integrity-d1g35t.js', + integrity: 'sha384-base64-hash' + ); $this->mockAssetMapper([ new MappedAsset( 'entry1.js', @@ -179,6 +190,7 @@ public function testGetImportMapData() $importedCss2, $importedCssInImportmap, $neverImportedCss, + $jsFileWithIntegrity, ]); $actualImportMapData = $manager->getImportMapData(['entry2', 'entry1']); @@ -232,6 +244,11 @@ public function testGetImportMapData() 'path' => '/assets/styles/never_imported_css-d1g35t.css', 'type' => 'css', ], + 'js_file_with_integrity' => [ + 'path' => '/assets/js_file_with_integrity-d1g35t.js', + 'type' => 'js', + 'integrity' => 'sha384-base64-hash', + ], ], $actualImportMapData); // now check the order @@ -251,6 +268,7 @@ public function testGetImportMapData() // importmap entries never imported 'entry3', 'never_imported_css', + 'js_file_with_integrity', ], array_keys($actualImportMapData)); } @@ -570,6 +588,31 @@ public static function getRawImportMapDataTests(): iterable ], ], ]; + + yield 'it adds integrity when it exists' => [ + [ + self::createLocalEntry( + 'app', + path: './assets/app.js', + ), + ], + [ + new MappedAsset( + 'app.js', + // /fake/root is the mocked root directory + '/fake/root/assets/app.js', + publicPath: '/assets/app-d1g3st.js', + integrity: 'sha384-base64-hash', + ), + ], + [ + 'app' => [ + 'path' => '/assets/app-d1g3st.js', + 'type' => 'js', + 'integrity' => 'sha384-base64-hash', + ], + ], + ]; } public function testGetRawImportDataUsesCacheFile() diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php index ef519ff719b4b..e0e2b6375cf81 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php @@ -58,6 +58,15 @@ public function testBasicRender() 'path' => '/assets/implicitly-added-d1g35t.js', 'type' => 'js', ], + '/assets/with-integrity' => [ + 'path' => '/assets/with-integrity-d1g35t.js', + 'type' => 'js', + 'integrity' => 'sha384-base64-hash', + ], + '/assets/without-integrity' => [ + 'path' => '/assets/without-integrity-d1g35t.js', + 'type' => 'js', + ], ]); $assetPackages = $this->createMock(Packages::class); @@ -98,6 +107,10 @@ public function testBasicRender() $this->assertStringContainsString('"remote_js": "https://cdn.example.com/assets/remote-d1g35t.js"', $html); // both the key and value are prefixed with the subdirectory $this->assertStringContainsString('"/subdirectory/assets/implicitly-added": "/subdirectory/assets/implicitly-added-d1g35t.js"', $html); + // integrity + $this->assertStringContainsString('"integrity":', $html); + $this->assertStringContainsString('"/subdirectory/assets/with-integrity-d1g35t.js": "sha384-base64-hash"', $html); + $this->assertStringNotContainsString('"/subdirectory/assets/without-integrity-d1g35t.js":', $html); } public function testNoPolyfill() @@ -106,6 +119,12 @@ public function testNoPolyfill() $this->assertStringNotContainsString('https://ga.jspm.io/npm:es-module-shims', $renderer->render([])); } + public function testNoIntegrity() + { + $renderer = new ImportMapRenderer($this->createBasicImportMapGenerator(), null, 'UTF-8', false); + $this->assertStringNotContainsString('"integrity":', $renderer->render([])); + } + public function testDefaultPolyfillUsedIfNotInImportmap() { $importMapGenerator = $this->createMock(ImportMapGenerator::class); @@ -154,26 +173,6 @@ public function testWithEntrypoint() $this->assertStringContainsString("import 'bar';", $html); } - private function createBasicImportMapGenerator(): ImportMapGenerator - { - $importMapGenerator = $this->createMock(ImportMapGenerator::class); - $importMapGenerator->expects($this->once()) - ->method('getImportMapData') - ->willReturn([ - 'app' => [ - 'path' => 'app.js', - 'type' => 'js', - ], - 'es-module-shims' => [ - 'path' => 'https://polyfillUrl.example', - 'type' => 'js', - ], - ]) - ; - - return $importMapGenerator; - } - public function testItAddsPreloadLinks() { $importMapGenerator = $this->createMock(ImportMapGenerator::class); @@ -210,4 +209,24 @@ public function testItAddsPreloadLinks() $this->assertSame(['as' => 'style'], $linkProvider->getLinks()[0]->getAttributes()); $this->assertSame('/assets/styles/app-preload-d1g35t.css', $linkProvider->getLinks()[0]->getHref()); } + + private function createBasicImportMapGenerator(): ImportMapGenerator + { + $importMapGenerator = $this->createMock(ImportMapGenerator::class); + $importMapGenerator->expects($this->once()) + ->method('getImportMapData') + ->willReturn([ + 'app' => [ + 'path' => 'app.js', + 'type' => 'js', + ], + 'es-module-shims' => [ + 'path' => 'https://polyfillUrl.example', + 'type' => 'js', + ], + ]) + ; + + return $importMapGenerator; + } }