diff --git a/app/code/Magento/Csp/Block/Sri/Hashes.php b/app/code/Magento/Csp/Block/Sri/Hashes.php new file mode 100644 index 0000000000000..7b67ec4ca534f --- /dev/null +++ b/app/code/Magento/Csp/Block/Sri/Hashes.php @@ -0,0 +1,94 @@ +integrityRepositoryPool = $integrityRepositoryPool ?: ObjectManager::getInstance() + ->get(SubresourceIntegrityRepositoryPool::class); + + $this->serializer = $serializer ?: ObjectManager::getInstance() + ->get(SerializerInterface::class); + } + + /** + * Retrieves integrity hashes in serialized format. + * + * @throws LocalizedException + * + * @return string + */ + public function getSerialized(): string + { + $result = []; + + $baseUrl = $this->_urlBuilder->getBaseUrl( + ["_type" => UrlInterface::URL_TYPE_STATIC] + ); + + $integrityRepository = $this->integrityRepositoryPool->get( + Package::BASE_AREA + ); + + foreach ($integrityRepository->getAll() as $integrity) { + $url = $baseUrl . $integrity->getPath(); + + $result[$url] = $integrity->getHash(); + } + + $integrityRepository = $this->integrityRepositoryPool->get( + $this->_appState->getAreaCode() + ); + + foreach ($integrityRepository->getAll() as $integrity) { + $url = $baseUrl . $integrity->getPath(); + + $result[$url] = $integrity->getHash(); + } + + return $this->serializer->serialize($result); + } +} diff --git a/app/code/Magento/Csp/Model/Deploy/Package/Processor/PostProcessor/Integrity.php b/app/code/Magento/Csp/Model/Deploy/Package/Processor/PostProcessor/Integrity.php new file mode 100644 index 0000000000000..41e81752632fb --- /dev/null +++ b/app/code/Magento/Csp/Model/Deploy/Package/Processor/PostProcessor/Integrity.php @@ -0,0 +1,89 @@ +filesystem = $filesystem; + $this->hashGenerator = $hashGenerator; + $this->integrityFactory = $integrityFactory; + $this->integrityCollector = $integrityCollector; + } + + /** + * @inheritdoc + */ + public function process(Package $package, array $options): bool + { + $staticDir = $this->filesystem->getDirectoryRead( + DirectoryList::ROOT + ); + + foreach ($package->getFiles() as $file) { + if ($file->getExtension() == "js") { + $integrity = $this->integrityFactory->create( + [ + "data" => [ + 'hash' => $this->hashGenerator->generate( + $staticDir->readFile($file->getSourcePath()) + ), + 'path' => $file->getDeployedFilePath() + ] + ] + ); + + $this->integrityCollector->collect($integrity); + } + } + + return true; + } +} diff --git a/app/code/Magento/Csp/Model/Deploy/Package/Processor/PostProcessor/Map.php b/app/code/Magento/Csp/Model/Deploy/Package/Processor/PostProcessor/Map.php new file mode 100644 index 0000000000000..4e97517a165c5 --- /dev/null +++ b/app/code/Magento/Csp/Model/Deploy/Package/Processor/PostProcessor/Map.php @@ -0,0 +1,139 @@ +minification = $minification; + $this->integrityFactory = $integrityFactory; + $this->hashGenerator = $hashGenerator; + $this->driver = $driver; + $this->integrityCollector = $integrityCollector; + $this->filesystem = $filesystem; + parent::__construct($deployStaticFile, $formatter, $packageFileFactory, $minification); + } + + /** + * @inheritdoc + * + * @throws FileSystemException + */ + public function process(Package $package, array $options): bool + { + parent::process($package, $options); + $fileName = $this->minification->addMinifiedSign(RepositoryMap::REQUIRE_JS_MAP_NAME); + $path = $package->getPath(); + $relativePath = $path . DIRECTORY_SEPARATOR . $fileName; + + if ($this->fileExists($relativePath)) { + $dir = $this->filesystem->getDirectoryWrite(DirectoryList::STATIC_VIEW); + $absolutePath = $dir->getAbsolutePath($relativePath); + $fileContent = $this->driver->fileGetContents($absolutePath); + + if ($fileContent) { + $integrity = $this->integrityFactory->create( + [ + "data" => [ + 'hash' => $this->hashGenerator->generate($fileContent), + 'path' => $relativePath + ] + ] + ); + $this->integrityCollector->collect($integrity); + } + } + return true; + } + + /** + * Check if file exist + * + * @param string $path + * @return bool + * @throws FileSystemException + */ + private function fileExists(string $path): bool + { + $dir = $this->filesystem->getDirectoryWrite(DirectoryList::STATIC_VIEW); + return $dir->isExist($path); + } +} diff --git a/app/code/Magento/Csp/Model/SubresourceIntegrity.php b/app/code/Magento/Csp/Model/SubresourceIntegrity.php new file mode 100644 index 0000000000000..0a2824c93027e --- /dev/null +++ b/app/code/Magento/Csp/Model/SubresourceIntegrity.php @@ -0,0 +1,34 @@ +getData("path"); + } + + /** + * Gets an integrity hash. + * + * @return string|null + */ + public function getHash(): ?string + { + return $this->getData("hash"); + } +} diff --git a/app/code/Magento/Csp/Model/SubresourceIntegrity/HashGenerator.php b/app/code/Magento/Csp/Model/SubresourceIntegrity/HashGenerator.php new file mode 100644 index 0000000000000..be78a1a0c66b8 --- /dev/null +++ b/app/code/Magento/Csp/Model/SubresourceIntegrity/HashGenerator.php @@ -0,0 +1,37 @@ +data = $data; + } + + /** + * Collects given Integrity object. + * + * @param SubresourceIntegrity $integrity + * + * @return void + */ + public function collect(SubresourceIntegrity $integrity): void + { + $this->data[] = $integrity; + } + + /** + * Provides all collected Integrity objects. + * + * @return SubresourceIntegrity[] + */ + public function release(): array + { + return $this->data; + } +} diff --git a/app/code/Magento/Csp/Model/SubresourceIntegrityRepository.php b/app/code/Magento/Csp/Model/SubresourceIntegrityRepository.php new file mode 100644 index 0000000000000..f2ef550a8bda3 --- /dev/null +++ b/app/code/Magento/Csp/Model/SubresourceIntegrityRepository.php @@ -0,0 +1,207 @@ +cache = $cache; + $this->serializer = $serializer; + $this->integrityFactory = $integrityFactory; + $this->context = $context; + } + + /** + * Gets an Integrity object by path. + * + * @param string $path + * + * @return SubresourceIntegrity|null + */ + public function getByPath(string $path): ?SubresourceIntegrity + { + $data = $this->getData(); + + if (isset($data[$path])) { + return $this->integrityFactory->create( + [ + "data" => [ + "path" => $path, + "hash" => $data[$path] + ] + ] + ); + } + + return null; + } + + /** + * Gets all available Integrity objects. + * + * @return SubresourceIntegrity[] + */ + public function getAll(): array + { + $result = []; + + foreach ($this->getData() as $path => $hash) { + $result[] = $this->integrityFactory->create( + [ + "data" => [ + "path" => $path, + "hash" => $hash + ] + ] + ); + } + + return $result; + } + + /** + * Saves Integrity object. + * + * @param SubresourceIntegrity $integrity + * + * @return bool + */ + public function save(SubresourceIntegrity $integrity): bool + { + $data = $this->getData(); + + $data[$integrity->getPath()] = $integrity->getHash(); + + $this->data = $data; + + return $this->cache->save( + $this->serializer->serialize($this->data), + $this->getCacheKey(), + [self::CACHE_PREFIX] + ); + } + + /** + * Saves a bunch of Integrity objects. + * + * @param SubresourceIntegrity[] $bunch + * + * @return bool + */ + public function saveBunch(array $bunch): bool + { + $data = $this->getData(); + + foreach ($bunch as $integrity) { + $data[$integrity->getPath()] = $integrity->getHash(); + } + + $this->data = $data; + + return $this->cache->save( + $this->serializer->serialize($this->data), + $this->getCacheKey(), + [self::CACHE_PREFIX] + ); + } + + /** + * Deletes all Integrity objects. + * + * @return bool + */ + public function deleteAll(): bool + { + $this->data = null; + + return $this->cache->remove( + $this->getCacheKey() + ); + } + + /** + * Loads integrity data from a storage. + * + * @return array + */ + private function getData(): array + { + if ($this->data === null) { + $cache = $this->cache->load($this->getCacheKey()); + + $this->data = $cache ? $this->serializer->unserialize($cache) : []; + } + + return $this->data; + } + + /** + * Gets a cache key based on current context. + * + * @return string + */ + private function getCacheKey(): string + { + $cacheKey = self::CACHE_PREFIX; + + if ($this->context) { + $cacheKey .= "_" . $this->context; + } + + return $cacheKey; + } +} diff --git a/app/code/Magento/Csp/Model/SubresourceIntegrityRepositoryPool.php b/app/code/Magento/Csp/Model/SubresourceIntegrityRepositoryPool.php new file mode 100644 index 0000000000000..5ad9c6c676de8 --- /dev/null +++ b/app/code/Magento/Csp/Model/SubresourceIntegrityRepositoryPool.php @@ -0,0 +1,53 @@ +integrityRepositoryFactory = $integrityRepositoryFactory; + } + + /** + * Gets subresource integrity repository by given context. + * + * @param string $context + * + * @return SubresourceIntegrityRepository + */ + public function get(string $context): SubresourceIntegrityRepository + { + if (!isset($this->repositories[$context])) { + $this->repositories[$context] = $this->integrityRepositoryFactory->create( + [ + "context" => $context + ] + ); + } + + return $this->repositories[$context]; + } +} diff --git a/app/code/Magento/Csp/Plugin/AddDefaultPropertiesToGroupPlugin.php b/app/code/Magento/Csp/Plugin/AddDefaultPropertiesToGroupPlugin.php new file mode 100644 index 0000000000000..159e2180427a5 --- /dev/null +++ b/app/code/Magento/Csp/Plugin/AddDefaultPropertiesToGroupPlugin.php @@ -0,0 +1,81 @@ +state = $state; + $this->integrityRepositoryPool = $integrityRepositoryPool; + } + + /** + * Before Plugin to add Properties to JS assets + * + * @param GroupedCollection $subject + * @param AssetInterface $asset + * @param array $properties + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeGetFilteredProperties( + GroupedCollection $subject, + AssetInterface $asset, + array $properties = [] + ): array { + if ($asset instanceof LocalInterface) { + $integrityRepository = $this->integrityRepositoryPool->get( + Package::BASE_AREA + ); + + $integrity = $integrityRepository->getByPath($asset->getPath()); + + if (!$integrity) { + $integrityRepository = $this->integrityRepositoryPool->get( + $this->state->getAreaCode() + ); + + $integrity = $integrityRepository->getByPath($asset->getPath()); + } + + if ($integrity && $integrity->getHash()) { + $properties['attributes']['integrity'] = $integrity->getHash(); + $properties['attributes']['crossorigin'] = 'anonymous'; + } + } + + return [$asset, $properties]; + } +} diff --git a/app/code/Magento/Csp/Plugin/GenerateAssetIntegrity.php b/app/code/Magento/Csp/Plugin/GenerateAssetIntegrity.php new file mode 100644 index 0000000000000..2b461588fa5ad --- /dev/null +++ b/app/code/Magento/Csp/Plugin/GenerateAssetIntegrity.php @@ -0,0 +1,91 @@ +hashGenerator = $hashGenerator; + $this->integrityFactory = $integrityFactory; + $this->integrityCollector = $integrityCollector; + } + + /** + * Generates integrity for RequireJs config. + * + * @param FileManager $subject + * @param File $result + * + * @return File + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterCreateRequireJsConfigAsset( + FileManager $subject, + File $result + ): File { + if (PHP_SAPI == 'cli') { + if (in_array($result->getContentType(), self::CONTENT_TYPES)) { + $integrity = $this->integrityFactory->create( + [ + "data" => [ + 'hash' => $this->hashGenerator->generate( + $result->getContent() + ), + 'path' => $result->getPath() + ] + ] + ); + + $this->integrityCollector->collect($integrity); + } + } + + return $result; + } +} diff --git a/app/code/Magento/Csp/Plugin/RemoveAllAssetIntegrityHashes.php b/app/code/Magento/Csp/Plugin/RemoveAllAssetIntegrityHashes.php new file mode 100644 index 0000000000000..85b97e9091c54 --- /dev/null +++ b/app/code/Magento/Csp/Plugin/RemoveAllAssetIntegrityHashes.php @@ -0,0 +1,69 @@ +integrityRepositoryPool = $integrityRepositoryPool; + } + + /** + * Removes existing integrity hashes before static content deploy + * + * @param DeployStaticContent $subject + * @param array $options + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeDeploy( + DeployStaticContent $subject, + array $options + ): void { + if (PHP_SAPI == 'cli' && !$this->isRefreshContentVersionOnly($options)) { + foreach ([Package::BASE_AREA, Area::AREA_FRONTEND, Area::AREA_ADMINHTML] as $area) { + $this->integrityRepositoryPool->get($area) + ->deleteAll(); + } + } + } + + /** + * Checks if only version refresh is requested. + * + * @param array $options + * + * @return bool + */ + private function isRefreshContentVersionOnly(array $options): bool + { + return isset($options[DeployStaticOptions::REFRESH_CONTENT_VERSION_ONLY]) + && $options[DeployStaticOptions::REFRESH_CONTENT_VERSION_ONLY]; + } +} diff --git a/app/code/Magento/Csp/Plugin/StoreAssetIntegrityHashes.php b/app/code/Magento/Csp/Plugin/StoreAssetIntegrityHashes.php new file mode 100644 index 0000000000000..21bfd581449cb --- /dev/null +++ b/app/code/Magento/Csp/Plugin/StoreAssetIntegrityHashes.php @@ -0,0 +1,70 @@ +integrityCollector = $integrityCollector; + $this->integrityRepositoryPool = $integrityRepositoryPool; + } + + /** + * Stores generated integrity hashes after static content deploy + * + * @param DeployStaticContent $subject + * @param mixed $result + * @param array $options + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterDeploy( + DeployStaticContent $subject, + mixed $result, + array $options + ): void { + $bunches = []; + + foreach ($this->integrityCollector->release() as $integrity) { + $area = explode("/", $integrity->getPath())[0]; + + $bunches[$area][] = $integrity; + } + + foreach ($bunches as $area => $bunch) { + $this->integrityRepositoryPool->get($area) + ->saveBunch($bunch); + } + } +} diff --git a/app/code/Magento/Csp/Test/Unit/Model/SubresourceIntegrityRepositoryTest.php b/app/code/Magento/Csp/Test/Unit/Model/SubresourceIntegrityRepositoryTest.php new file mode 100644 index 0000000000000..0b7a55d896f04 --- /dev/null +++ b/app/code/Magento/Csp/Test/Unit/Model/SubresourceIntegrityRepositoryTest.php @@ -0,0 +1,129 @@ +cacheMock = $this->getMockBuilder(CacheInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['save', 'load']) + ->getMockForAbstractClass(); + $this->serializerMock = $this->getMockBuilder(SerializerInterface::class) + ->disableOriginalConstructor() + ->onlyMethods(['serialize', 'unserialize']) + ->getMockForAbstractClass(); + $this->integrityFactoryMock = $this->getMockBuilder(SubresourceIntegrityFactory::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->subresourceIntegrityRepository = new SubresourceIntegrityRepository( + $this->cacheMock, + $this->serializerMock, + $this->integrityFactoryMock + ); + } + + /** Test save repository + * + * + * @return void + */ + public function testSave(): void + { + $data = new SubresourceIntegrity( + [ + 'hash' => 'testhash', + 'path' => 'js/jquery.js' + ] + ); + + $expected[$data->getPath()] = $data->getHash(); + $serialized = json_encode($expected); + $this->cacheMock->expects($this->once())->method('load')->willReturn(false); + $this->serializerMock->expects($this->once())->method('serialize')->with($expected)->willReturn($serialized); + $this->cacheMock->expects($this->once())->method('save')->willReturn(true); + $this->assertTrue($this->subresourceIntegrityRepository->save($data)); + } + + /** Test that cache saves in bunch + * + * + * @return void + */ + public function testSaveBunch(): void + { + $bunch1 = new SubresourceIntegrity( + [ + 'hash' => 'testhash', + 'path' => 'js/jquery.js' + ] + ); + + $bunch2 = new SubresourceIntegrity( + [ + 'hash' => 'testhash2', + 'path' => 'js/test.js' + ] + ); + + $bunches = [$bunch1, $bunch2]; + + $expected = []; + + foreach ($bunches as $bunch) { + $expected[$bunch->getPath()] = $bunch->getHash(); + } + $serializedBunch = json_encode($expected); + $this->cacheMock->expects($this->once())->method('load')->willReturn(false); + $this->serializerMock->expects($this->once())->method('serialize') + ->with($expected)->willReturn($serializedBunch); + $this->cacheMock->expects($this->once())->method('save')->willReturn(true); + $this->assertTrue($this->subresourceIntegrityRepository->saveBunch($bunches)); + } +} diff --git a/app/code/Magento/Csp/Test/Unit/Plugin/AddDefaultPropertiesToGroupPluginTest.php b/app/code/Magento/Csp/Test/Unit/Plugin/AddDefaultPropertiesToGroupPluginTest.php new file mode 100644 index 0000000000000..3e6b08f17bb7b --- /dev/null +++ b/app/code/Magento/Csp/Test/Unit/Plugin/AddDefaultPropertiesToGroupPluginTest.php @@ -0,0 +1,113 @@ +integrityRepositoryPoolMock = $this->getMockBuilder(SubresourceIntegrityRepositoryPool::class) + ->disableOriginalConstructor() + ->onlyMethods(['get']) + ->getMock(); + $this->assetInterfaceMock = $this->getMockBuilder(File::class) + ->disableOriginalConstructor() + ->onlyMethods(['getPath']) + ->getMockForAbstractClass(); + $this->stateMock = $this->getMockBuilder(State::class) + ->disableOriginalConstructor() + ->onlyMethods(['getAreaCode']) + ->getMock(); + $this->plugin = new AddDefaultPropertiesToGroupPlugin( + $this->stateMock, + $this->integrityRepositoryPoolMock + ); + } + + /** + * Test for plugin with Js assets + * + * @return void + */ + public function testBeforeGetFilteredProperties(): void + { + $integrityRepositoryMock = $this->getMockBuilder(SubresourceIntegrityRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['getByPath']) + ->getMock(); + $groupedCollectionMock = $this->getMockBuilder(GroupedCollection::class) + ->disableOriginalConstructor() + ->getMock(); + $path = 'jquery.js'; + $area = 'base'; + + $data = new SubresourceIntegrity( + [ + 'hash' => 'testhash', + 'path' => $path + ] + ); + $properties['attributes']['integrity'] = $data->getHash(); + $properties['attributes']['crossorigin'] = 'anonymous'; + $expected = [$this->assetInterfaceMock, $properties]; + $this->integrityRepositoryPoolMock->expects($this->once())->method('get')->with($area) + ->willReturn( + $integrityRepositoryMock + ); + $this->assetInterfaceMock->expects($this->once())->method('getPath')->willReturn($path); + $integrityRepositoryMock->expects($this->once())->method('getByPath')->with($path)->willReturn($data); + $this->assertEquals( + $expected, + $this->plugin->beforeGetFilteredProperties( + $groupedCollectionMock, + $this->assetInterfaceMock + ) + ); + } +} diff --git a/app/code/Magento/Csp/Test/Unit/Plugin/StoreAssetIntegrityHashesTest.php b/app/code/Magento/Csp/Test/Unit/Plugin/StoreAssetIntegrityHashesTest.php new file mode 100644 index 0000000000000..7ac1d462015c7 --- /dev/null +++ b/app/code/Magento/Csp/Test/Unit/Plugin/StoreAssetIntegrityHashesTest.php @@ -0,0 +1,96 @@ +integrityRepositoryPoolMock = $this->getMockBuilder(SubresourceIntegrityRepositoryPool::class) + ->disableOriginalConstructor() + ->onlyMethods(['get']) + ->getMock(); + $this->integrityCollectorMock = $this->getMockBuilder(SubresourceIntegrityCollector::class) + ->disableOriginalConstructor() + ->onlyMethods(['release']) + ->getMock(); + $this->plugin = new StoreAssetIntegrityHashes( + $this->integrityCollectorMock, + $this->integrityRepositoryPoolMock, + ); + } + + /** + * Test After Deploy method of plugin + * + * @return void + * @doesNotPerformAssertions + */ + public function testAfterDeploy(): void + { + $bunch1 = new SubresourceIntegrity( + [ + 'hash' => 'testhash', + 'path' => 'adminhtml/js/jquery.js' + ] + ); + + $bunch2 = new SubresourceIntegrity( + [ + 'hash' => 'testhash2', + 'path' => 'frontend/js/test.js' + ] + ); + + $bunches = [$bunch1, $bunch2]; + $deployStaticContentMock = $this->getMockBuilder(DeployStaticContent::class) + ->disableOriginalConstructor() + ->getMock(); + $subResourceIntegrityMock = $this->getMockBuilder(SubresourceIntegrityRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['saveBunch']) + ->getMock(); + $this->integrityCollectorMock->expects($this->once())->method('release')->willReturn($bunches); + $this->integrityRepositoryPoolMock->expects($this->any())->method('get')->willReturn($subResourceIntegrityMock); + $subResourceIntegrityMock->expects($this->any())->method('saveBunch')->willReturn(true); + $this->plugin->afterDeploy($deployStaticContentMock, null, []); + } +} diff --git a/app/code/Magento/Csp/composer.json b/app/code/Magento/Csp/composer.json index c085b06d6900d..c85b28e04a6b0 100644 --- a/app/code/Magento/Csp/composer.json +++ b/app/code/Magento/Csp/composer.json @@ -7,7 +7,9 @@ "require": { "php": "~8.1.0||~8.2.0||~8.3.0", "magento/framework": "*", - "magento/module-store": "*" + "magento/module-store": "*", + "magento/module-require-js": "*", + "magento/module-deploy": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Csp/etc/di.xml b/app/code/Magento/Csp/etc/di.xml index b6a887ab9d943..13c1f225a00f2 100644 --- a/app/code/Magento/Csp/etc/di.xml +++ b/app/code/Magento/Csp/etc/di.xml @@ -111,4 +111,27 @@ Magento\Csp\Model\BlockCache + + + + Magento\Csp\Model\Deploy\Package\Processor\PostProcessor\Integrity + + + + + + + + + + + + + + + + + Magento\Framework\Filesystem\Driver\File + + diff --git a/app/code/Magento/Csp/view/adminhtml/layout/sales_order_create_index.xml b/app/code/Magento/Csp/view/adminhtml/layout/sales_order_create_index.xml new file mode 100644 index 0000000000000..73fe99913466c --- /dev/null +++ b/app/code/Magento/Csp/view/adminhtml/layout/sales_order_create_index.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/app/code/Magento/Csp/view/base/templates/sri/hashes.phtml b/app/code/Magento/Csp/view/base/templates/sri/hashes.phtml new file mode 100644 index 0000000000000..e9d666a50550d --- /dev/null +++ b/app/code/Magento/Csp/view/base/templates/sri/hashes.phtml @@ -0,0 +1,18 @@ + + +getSerialized(); +$scriptString = <<