Skip to content

Commit

Permalink
feature #42 Add support for integrity hashes (Lyrkan)
Browse files Browse the repository at this point in the history
This PR was squashed before being merged into the master branch (closes #42).

Discussion
----------

Add support for integrity hashes

This PR allows to automatically add `integrity` attributes on `<script>` and `<link>` tags based on the content of the `entrypoints.json` file (related to the following PR on Encore: symfony/webpack-encore#522).

It requires the following configuration:

```js
// webpack.config.js
// Enable it for all builds with the
// default hash algorithm (sha384)
Encore.enableIntegrityHashes();

// Or enable it only in production
// with a custom hash algorithm
Encore.enableIntegrityHashes(
    Encore.isProduction(),
    'sha384'
);

// Or with multiple hash algorithms
Encore.enableIntegrityHashes(
    Encore.isProduction(),
    ['sha384','sha512']
);
```

Then, calling `yarn encore` then generates an entrypoints.json that contains hashes for all the files it references:

```js
{
  "entrypoints": {
    // (...)
  },
  "integrity": {
    "/build/runtime.fa8f03f5.js": "sha384-5WSgDNxkAY6j6/bzAcp3v//+PCXLgXCU3u5QgRXWiRfMnN4Ic/a/EF6HJnbRXik8",
    "/build/0.b70b772e.js": "sha384-FA3+8ecenjmV1Y751s0fKxGBNtyLBA8hDY4sqFoqvsCPOamLlA5ckhRBttBg1esp",
    // (...)
  }
}
```

And these hashes are automatically added when calling `encore_entry_script_tags` and `encore_entry_link_tags`:

```html
<html lang="en">
  <head>
    <!-- ... -->
    <link rel="stylesheet" href="/build/css/app.2235bc2d.css" integrity="sha384-Jmd35HF93DFCXjisVeMi6U3lniH/mOdAF6wLtOMqhYMh2ZiBRUdtF7jXB55IAKfm">
    <!-- ... -->
  </head>
  <body id="homepage">
    <!-- ... -->
    <script src="/build/runtime.fa8f03f5.js" integrity="sha384-5WSgDNxkAY6j6/bzAcp3v//+PCXLgXCU3u5QgRXWiRfMnN4Ic/a/EF6HJnbRXik8"></script>
    <script src="/build/0.b70b772e.js" integrity="sha384-FA3+8ecenjmV1Y751s0fKxGBNtyLBA8hDY4sqFoqvsCPOamLlA5ckhRBttBg1esp"></script>
    <!-- ... -->
  </body>
</html>
```

An example using Symfony Demo can be found here: Lyrkan/symfony-demo@91a06cd

Commits
-------

84c41ed Add support for integrity hashes
  • Loading branch information
weaverryan committed Mar 29, 2019
2 parents cd9894a + 84c41ed commit fc18456
Show file tree
Hide file tree
Showing 7 changed files with 156 additions and 10 deletions.
13 changes: 12 additions & 1 deletion src/Asset/EntrypointLookup.php
Expand Up @@ -19,7 +19,7 @@
*
* @final
*/
class EntrypointLookup implements EntrypointLookupInterface
class EntrypointLookup implements EntrypointLookupInterface, IntegrityDataProviderInterface
{
private $entrypointJsonPath;

Expand All @@ -46,6 +46,17 @@ public function getCssFiles(string $entryName): array
return $this->getEntryFiles($entryName, 'css');
}

public function getIntegrityData(): array
{
$entriesData = $this->getEntriesData();

if (!array_key_exists('integrity', $entriesData)) {
return [];
}

return $entriesData['integrity'];
}

/**
* Resets the state of this service.
*/
Expand Down
29 changes: 29 additions & 0 deletions src/Asset/IntegrityDataProviderInterface.php
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the Symfony WebpackEncoreBundle package.
* (c) Fabien Potencier <fabien@symfony.com>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\WebpackEncoreBundle\Asset;

interface IntegrityDataProviderInterface
{
/**
* Returns a map of integrity hashes indexed by asset paths.
*
* If multiples hashes are defined for a given asset they must
* be separated by a space.
*
* For instance:
* [
* 'path/to/file1.js' => 'sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
* 'path/to/styles.css' => 'sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
* ]
*
* @return string[]
*/
public function getIntegrityData(): array;
}
46 changes: 40 additions & 6 deletions src/Asset/TagRenderer.php
Expand Up @@ -42,10 +42,21 @@ public function __construct(
public function renderWebpackScriptTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string
{
$scriptTags = [];
foreach ($this->getEntrypointLookup($entrypointName)->getJavaScriptFiles($entryName) as $filename) {
$entryPointLookup = $this->getEntrypointLookup($entrypointName);
$integrityHashes = ($entryPointLookup instanceof IntegrityDataProviderInterface) ? $entryPointLookup->getIntegrityData() : [];

foreach ($entryPointLookup->getJavaScriptFiles($entryName) as $filename) {
$attributes = [
'src' => $this->getAssetPath($filename, $packageName),
];

if (isset($integrityHashes[$filename])) {
$attributes['integrity'] = $integrityHashes[$filename];
}

$scriptTags[] = sprintf(
'<script src="%s"></script>',
htmlentities($this->getAssetPath($filename, $packageName))
'<script %s></script>',
$this->convertArrayToAttributes($attributes)
);
}

Expand All @@ -55,10 +66,22 @@ public function renderWebpackScriptTags(string $entryName, string $packageName =
public function renderWebpackLinkTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string
{
$scriptTags = [];
foreach ($this->getEntrypointLookup($entrypointName)->getCssFiles($entryName) as $filename) {
$entryPointLookup = $this->getEntrypointLookup($entrypointName);
$integrityHashes = ($entryPointLookup instanceof IntegrityDataProviderInterface) ? $entryPointLookup->getIntegrityData() : [];

foreach ($entryPointLookup->getCssFiles($entryName) as $filename) {
$attributes = [
'rel' => 'stylesheet',
'href' => $this->getAssetPath($filename, $packageName),
];

if (isset($integrityHashes[$filename])) {
$attributes['integrity'] = $integrityHashes[$filename];
}

$scriptTags[] = sprintf(
'<link rel="stylesheet" href="%s">',
htmlentities($this->getAssetPath($filename, $packageName))
'<link %s>',
$this->convertArrayToAttributes($attributes)
);
}

Expand All @@ -81,4 +104,15 @@ private function getEntrypointLookup(string $buildName): EntrypointLookupInterfa
{
return $this->entrypointLookupCollection->getEntrypointLookup($buildName);
}

private function convertArrayToAttributes(array $attributesMap): string
{
return implode(' ', array_map(
function ($key, $value) {
return sprintf('%s="%s"', $key, htmlentities($value));
},
array_keys($attributesMap),
$attributesMap
));
}
}
21 changes: 21 additions & 0 deletions tests/Asset/EntrypointLookupTest.php
Expand Up @@ -32,6 +32,10 @@ class EntrypointLookupTest extends TestCase
],
"css": []
}
},
"integrity": {
"file1.js": "sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc",
"styles.css": "sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J"
}
}
EOF;
Expand Down Expand Up @@ -93,6 +97,23 @@ public function testEmptyReturnOnValidEntryNoJsOrCssFile()
);
}

public function testGetIntegrityData()
{
$this->assertEquals([
'file1.js' => 'sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
'styles.css' => 'sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
], $this->entrypointLookup->getIntegrityData());
}

public function testMissingIntegrityData()
{
$filename = tempnam(sys_get_temp_dir(), 'WebpackEncoreBundle');
file_put_contents($filename, '{ "entrypoints": { "other_entry": { "js": { } } } }');

$this->entrypointLookup = new EntrypointLookup($filename);
$this->assertEquals([], $this->entrypointLookup->getIntegrityData());
}

/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessageContains There was a problem JSON decoding the
Expand Down
44 changes: 44 additions & 0 deletions tests/Asset/TagRendererTest.php
Expand Up @@ -6,6 +6,7 @@
use Symfony\Component\Asset\Packages;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupCollection;
use Symfony\WebpackEncoreBundle\Asset\IntegrityDataProviderInterface;
use Symfony\WebpackEncoreBundle\Asset\TagRenderer;

class TagRendererTest extends TestCase
Expand Down Expand Up @@ -128,4 +129,47 @@ public function testRenderScriptTagsWithinAnEntryPointCollection()
);
}

public function testRenderScriptTagsWithHashes()
{
$entrypointLookup = $this->createMock([
EntrypointLookupInterface::class,
IntegrityDataProviderInterface::class,
]);
$entrypointLookup->expects($this->once())
->method('getJavaScriptFiles')
->willReturn(['/build/file1.js', '/build/file2.js']);
$entrypointLookup->expects($this->once())
->method('getIntegrityData')
->willReturn([
'/build/file1.js' => 'sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
'/build/file2.js' => 'sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
]);
$entrypointCollection = $this->createMock(EntrypointLookupCollection::class);
$entrypointCollection->expects($this->once())
->method('getEntrypointLookup')
->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;
});
$renderer = new TagRenderer($entrypointCollection, $packages, true);

$output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package');
$this->assertContains(
'<script src="http://localhost:8080/build/file1.js" integrity="sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc"></script>',
$output
);
$this->assertContains(
'<script src="http://localhost:8080/build/file2.js" integrity="sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J"></script>',
$output
);
}
}
6 changes: 3 additions & 3 deletions tests/IntegrationTest.php
Expand Up @@ -24,12 +24,12 @@ public function testTwigIntegration()

$html1 = $container->get('twig')->render('@integration_test/template.twig');
$this->assertContains(
'<script src="/build/file1.js"></script>',
'<script src="/build/file1.js" integrity="sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc"></script>',
$html1
);
$this->assertContains(
'<link rel="stylesheet" href="/build/styles.css">'.
'<link rel="stylesheet" href="/build/styles2.css">',
'<link rel="stylesheet" href="/build/styles.css" integrity="sha384-4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n">' .
'<link rel="stylesheet" href="/build/styles2.css" integrity="sha384-hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn">',
$html1
);
$this->assertContains(
Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/build/entrypoints.json
Expand Up @@ -16,5 +16,12 @@
"build/file3.js"
]
}
},
"integrity": {
"build/file1.js": "sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc",
"build/file2.js": "sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J",
"build/styles.css": "sha384-4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n",
"build/styles2.css": "sha384-hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn",
"build/file3.js": "sha384-ZU3hiTN/+Va9WVImPi+cI0/j/Q7SzAVezqL1aEXha8sVgE5HU6/0wKUxj1LEnkC9"
}
}

0 comments on commit fc18456

Please sign in to comment.