diff --git a/README.md b/README.md index 354ab36..ad238b2 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ webloader: watchFiles: # only watch modify file - {files: ["*.css", "*.less"], from: css} - {files: ["*.css", "*.less"], in: css} + sriHashingAlgorithms: # allowed values are sha256, sha384, and sha512, multiple can be specified + - sha256 js: default: @@ -81,6 +83,8 @@ webloader: files: - %appDir%/../libs/nette/nette/client-side/netteForms.js - web.js + sriHashingAlgorithms: # allowed values are sha256, sha384, and sha512, multiple can be specified + - sha256 ``` For older versions of Nette, you have to register the extension in `app/bootstrap.php`: diff --git a/WebLoader/Compiler.php b/WebLoader/Compiler.php index 370995e..81e7abe 100644 --- a/WebLoader/Compiler.php +++ b/WebLoader/Compiler.php @@ -34,6 +34,9 @@ class Compiler /** @var bool */ private $debugging = FALSE; + /** @var array */ + private $sriHashingAlgorithms = array(); + public function __construct(IFileCollection $files, IOutputNamingConvention $convention, $outputDir) { $this->collection = $files; @@ -303,6 +306,25 @@ public function addFileFilter($filter) $this->fileFilters[] = $filter; } + /** + * Add hashing algorithm for Subresource Integrity checksum + * + * @link https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity + * @param string $algorithm + */ + public function addSriHashingAlgorithm($algorithm) + { + $this->sriHashingAlgorithms[] = $algorithm; + } + + /** + * @return array + */ + public function getSriHashingAlgorithms() + { + return $this->sriHashingAlgorithms; + } + /** * @return array */ diff --git a/WebLoader/Nette/CssLoader.php b/WebLoader/Nette/CssLoader.php index 0fd9935..5331eee 100644 --- a/WebLoader/Nette/CssLoader.php +++ b/WebLoader/Nette/CssLoader.php @@ -112,13 +112,17 @@ public function setAlternate($alternate) */ public function getElement($source) { - if ($this->alternate) { - $alternate = ' alternate'; - } else { - $alternate = ''; - } - - return Html::el("link")->rel("stylesheet".$alternate)->type($this->type)->media($this->media)->title($this->title)->href($source); + $alternate = $this->alternate ? ' alternate' : ''; + $content = $this->getCompiledFileContent($source); + $sriChecksum = $this->getSriChecksums($content) ?: false; + + return Html::el('link') + ->integrity($sriChecksum) + ->rel('stylesheet' . $alternate) + ->type($this->type) + ->media($this->media) + ->title($this->title) + ->href($source); } } diff --git a/WebLoader/Nette/Extension.php b/WebLoader/Nette/Extension.php index 4e5013e..8fb245f 100644 --- a/WebLoader/Nette/Extension.php +++ b/WebLoader/Nette/Extension.php @@ -38,6 +38,9 @@ public function getDefaultConfig() 'fileFilters' => array(), 'joinFiles' => TRUE, 'namingConvention' => '@' . $this->prefix('jsNamingConvention'), + 'sriHashingAlgorithms' => array( + 'sha256', + ), ), 'cssDefaults' => array( 'checkLastModified' => TRUE, @@ -52,6 +55,9 @@ public function getDefaultConfig() 'fileFilters' => array(), 'joinFiles' => TRUE, 'namingConvention' => '@' . $this->prefix('cssNamingConvention'), + 'sriHashingAlgorithms' => array( + 'sha256', + ), ), 'js' => array( @@ -149,6 +155,10 @@ private function addWebLoader(ContainerBuilder $builder, $name, $config) $compiler->addSetup('setCheckLastModified', array($config['checkLastModified'])); + foreach ($config['sriHashingAlgorithms'] as $algorithm) { + $compiler->addSetup('addSriHashingAlgorithm', array($algorithm)); + } + // todo css media } diff --git a/WebLoader/Nette/JavaScriptLoader.php b/WebLoader/Nette/JavaScriptLoader.php index 1b138b2..c88e082 100644 --- a/WebLoader/Nette/JavaScriptLoader.php +++ b/WebLoader/Nette/JavaScriptLoader.php @@ -20,7 +20,13 @@ class JavaScriptLoader extends WebLoader */ public function getElement($source) { - return Html::el("script")->type("text/javascript")->src($source); + $content = $this->getCompiledFileContent($source); + $sriChecksum = $this->getSriChecksums($content) ?: false; + + return Html::el("script") + ->integrity($sriChecksum) + ->type("text/javascript") + ->src($source); } } \ No newline at end of file diff --git a/WebLoader/Nette/WebLoader.php b/WebLoader/Nette/WebLoader.php index 6a3fb48..3726a81 100644 --- a/WebLoader/Nette/WebLoader.php +++ b/WebLoader/Nette/WebLoader.php @@ -94,9 +94,59 @@ public function render() } } + /** + * Get content of a compiled file by its URL path + * + * @param string $source + * @return string + */ + protected function getCompiledFileContent($source) + { + $outputDir = $this->compiler->getOutputDir(); + $urlPath = parse_url($source, PHP_URL_PATH); + $fileName = basename($urlPath); + $filePath = $outputDir . '/' . $fileName; + $content = file_get_contents($filePath); + + return $content; + } + protected function getGeneratedFilePath($file) { return $this->tempPath . '/' . $file->file . '?' . $file->lastModified; } + /** + * Generate Subresource Integrity checksums for all set hashing algorithms + * + * @param string $fileContent + * @return string + */ + protected function getSriChecksums($fileContent) + { + $checksums = []; + + foreach ($this->compiler->getSriHashingAlgorithms() as $algorithm) { + $checksums[] = $this->getOneSriChecksum($algorithm, $fileContent); + } + + return implode(' ', $checksums); + } + + /** + * Generate Subresource Integrity checksum + * + * @link https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity + * @param string $hashingAlgorithm + * @param string $fileContent + * @return string + */ + private function getOneSriChecksum($hashingAlgorithm, $fileContent) + { + $hash = hash($hashingAlgorithm, $fileContent, true); + $hashBase64 = base64_encode($hash); + + return $hashingAlgorithm . '-' . $hashBase64; + } + } diff --git a/tests/Nette/WebLoaderTest.php b/tests/Nette/WebLoaderTest.php new file mode 100644 index 0000000..cc3bc0f --- /dev/null +++ b/tests/Nette/WebLoaderTest.php @@ -0,0 +1,146 @@ + 'sha256-Ss8LOdnEdmcJo2ifVTrAGrVQVF/6RUTfwLLOqC+6AqM=', + self::HASHES_SHA384 => 'sha384-OZ7wmy2rB2wregDCOAvEmnrP7wUiSrCbaFEn6r86mq6oPm8oqDrZMRy2GnFPUyxm', + self::HASHES_SHA512 => 'sha512-xIr1p/bUqFH8ikNO7WOKsabvaOGdvK6JSsZ8n7xbywGCuOcSOz3zyeTct2kMIxA/A9wX9UNSBxzrKk6yBLJrkQ==', + ]; + + private $fileCollectionRootPath; + + private $sourceFileDirPath; + + private $tempPath; + + public function setUp() + { + $this->fileCollectionRootPath = __DIR__ . '/../fixtures'; + $this->sourceFileDirPath = __DIR__ . '/../fixtures/dir'; + $this->tempPath = __DIR__ . '/../temp'; + + @mkdir($this->tempPath); + copy( + $this->sourceFileDirPath . '/' . self::SOURCE_FILE_NAME, + $this->tempPath . '/' . self::SOURCE_FILE_NAME + ); + } + + /** + * @dataProvider provideTestGetSriChecksums + * @param $hashingAlgorithms + * @param $fileContent + * @param $expected + */ + public function testGetSriChecksums($hashingAlgorithms, $fileContent, $expected) + { + $compiler = $this->getCompiler($hashingAlgorithms); + $webloader = $this->getWebLoader($compiler); + $sriChecksumsResult = $webloader->getSriChecksumsResult($fileContent); + + $this->assertSame($expected, $sriChecksumsResult); + } + + public function provideTestGetSriChecksums() + { + return [ + [ + [], + self::HASHES_TEST_STRING, + '', + ], + [ + [ + self::HASHES_SHA256, + ], + self::HASHES_TEST_STRING, + $this->hashes[self::HASHES_SHA256], + ], + [ + [ + self::HASHES_SHA256, + self::HASHES_SHA512, + ], + self::HASHES_TEST_STRING, + implode(' ', [ + $this->hashes[self::HASHES_SHA256], + $this->hashes[self::HASHES_SHA512], + ]), + ], + ]; + } + + public function testGetCompiledFileContent() + { + $compiler = $this->getCompiler(); + $webloader = $this->getWebLoader($compiler); + $compiledFileContentResult = $webloader->getCompiledFileContentResult( + $this->sourceFileDirPath . '/' . self::SOURCE_FILE_NAME + ); + $expected = file_get_contents($this->sourceFileDirPath . '/' . self::SOURCE_FILE_NAME); + + $this->assertSame($expected, $compiledFileContentResult); + } + + /** + * @param array $hashingAlgorithms + * @return Compiler + */ + private function getCompiler($hashingAlgorithms = []) + { + $files = new FileCollection($this->fileCollectionRootPath); + $compiler = new Compiler($files, new DefaultOutputNamingConvention(), $this->tempPath); + + foreach ($hashingAlgorithms as $alhorithm) { + $compiler->addSriHashingAlgorithm($alhorithm); + } + + return $compiler; + } + + /** + * @param Compiler $compiler + * @return WebLoaderTestImplementation + */ + private function getWebLoader(Compiler $compiler) + { + return new WebLoaderTestImplementation($compiler, $this->tempPath); + } +} + + +class WebLoaderTestImplementation extends WebLoader +{ + public function getCompiledFileContentResult($source) + { + return $this->getCompiledFileContent($source); + } + + public function getSriChecksumsResult($fileContent) + { + return $this->getSriChecksums($fileContent); + } + + public function getElement($source) + { + // not important now + } +}