diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0aa4476 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +composer.lock +tests/temp +vendor diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 0000000..c303f4e --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,6 @@ +preset: laravel + +linting: true + +disabled: + - single_class_element_per_statement diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5a1e77f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: php + +php: + - 5.5.9 + - 5.6 + - 7.0 + - 7.1 + +env: + matrix: + - COMPOSER_FLAGS="--prefer-lowest" + - COMPOSER_FLAGS="" + +before_script: + - travis_retry composer self-update + - travis_retry composer update ${COMPOSER_FLAGS} --no-interaction --prefer-source + +script: + - phpunit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..75851c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2016] [Sébastien Nikolaou] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..15f7aa8 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Laravel SRI + +Subresource Integrity (SRI) package for Laravel + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/sebdesign/laravel-sri.svg)](https://packagist.org/packages/sebdesign/laravel-sri) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) +[![Build Status](https://img.shields.io/travis/sebdesign/laravel-sri/master.svg)](https://travis-ci.org/sebdesign/laravel-sri) + +Reference and generate [Subresource Integrity (SRI)](https://www.w3.org/TR/SRI/) hashes from your Laravel Elixir asset pipeline. + +## Installation + +You can install the package via composer: + +```bash +$ composer require sebdesign/laravel-sri +``` + +Next, you must install the service provider: + +```php +// config/app.php +'providers' => [ + ... + Sebdesign\SRI\SubresourceIntegrityServiceProvider::class, +]; +``` + +## Usage + +This package is aimed to reference SRI hashes for `css` and `js` files from a `sri.json` file in your `/public` folder. In order to generate this file, see the [laravel-elixir-sri]('https://github.com/sebdesign/laravel-elixir-sri') repository. + +To reference the generated hashes from the `sri.json` in your views, you may use the `integrity` helper function with the name of the file you are using in your `elixir` or `asset` function. + +As a fallback, if the given file is not found in the `sri.json`, **it will generate the appropriate hashes on the fly** for your convenience. + +```php +{{-- Use with elixir() function --}} + + +{{-- Use with asset() function --}} + +``` + +If you have set the output folder for the `sri.json` in a different location in your Gulpfile, you can specify its `path` on the `config/sri.php`. + +```php +// config/sri.php +'path' => '/public/assets', +``` + +You can also override the config options by passing an array as a second argument on the `integrity` helper function: + +```php +{{-- Use different hash algorithm --}} + +``` + +## Testing + +``` bash +$ composer test +``` + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..05a1cd2 --- /dev/null +++ b/composer.json @@ -0,0 +1,42 @@ +{ + "name": "sebdesign/laravel-sri", + "description": "Subresource Integrity (SRI) package for Laravel", + "keywords": [ + "laravel", + "sri" + ], + "homepage": "https://github.com/sebdesign/laravel-sri", + "type": "library", + "require": { + "laravel/framework": "~5.1.16|~5.2.0|~5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0", + "mockery/mockery": "^0.9.5", + "orchestra/testbench": "^3.3" + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Sebdesign\\SRI\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Sebdesign\\SRI\\Test\\": "tests" + } + }, + "scripts": { + "test": "vendor/bin/phpunit" + }, + "license": "MIT", + "authors": [ + { + "name": "Sébastien Nikolaou", + "email": "info@sebdesign.eu", + "homepage": "http://sebdesign.eu" + } + ] +} diff --git a/config/sri.php b/config/sri.php new file mode 100644 index 0000000..7c5faeb --- /dev/null +++ b/config/sri.php @@ -0,0 +1,28 @@ + [ + 'sha256', + // 'sha384', + // 'sha512', + ], + + /** + * Integrity attribute delimiter. + */ + 'delimiter' => ' ', + + /** + * Output filename. + */ + 'filename' => 'sri.json', + + /** + * Output path. + */ + 'path' => '/public', +]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..f2eebe0 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + tests + + + + + src/ + + + diff --git a/src/Hasher.php b/src/Hasher.php new file mode 100644 index 0000000..f0b7f46 --- /dev/null +++ b/src/Hasher.php @@ -0,0 +1,119 @@ +supportedAlgorithms = $supportedAlgorithms; + $this->options = $options; + } + + /** + * Hash the given file. + * + * @param string $file + * @param array $options + * @return string + */ + public function make($file, array $options = []) + { + $options = $this->getOptions($options); + + return collect($this->getAlgorithms($options)) + ->map(function ($algorithm) use ($file) { + $digest = base64_encode(hash_file($algorithm, $file, true)); + + return $algorithm.'-'.$digest; + }) + ->implode($options['delimiter']); + } + + /** + * Check the given file against a hash. + * + * @param string $file + * @param string $integrity + * @param array $options + * @return bool + */ + public function check($file, $integrity, array $options = []) + { + return $this->make($file, $options) === $integrity; + } + + /** + * Check if the given hash has been hashed using the given options. + * + * @param string $integrity + * @param array $options + * @return bool + */ + public function needsRehash($integrity, array $options = []) + { + $options = $this->getOptions($options); + + $algorithms = collect(explode($options['delimiter'], $integrity)) + ->map(function ($hash) { + return head(explode('-', $hash)); + }); + + return $this->getAlgorithms($options) != $algorithms->all(); + } + + /** + * Replace the given options with the defaults. + * + * @param array $options + * @return array + */ + protected function getOptions(array $options) + { + return array_replace_recursive($this->options, $options); + } + + /** + * Get the hashing algorithms from the options. + * + * @param array $options + * @return array + * @throws \InvalidArgumentException + */ + protected function getAlgorithms(array $options) + { + if (!isset($options['algorithms']) + || !is_array($options['algorithms']) + || empty($options['algorithms'])) { + throw new \InvalidArgumentException('No hashing algorithms are set.'); + } + + if ($notSupported = array_diff($options['algorithms'], $this->supportedAlgorithms)) { + throw new \InvalidArgumentException(sprintf( + "The hashing algorithms [%s] are not supported.", + implode(', ', $notSupported) + )); + } + + return $options['algorithms']; + } +} diff --git a/src/SubresourceIntegrityServiceProvider.php b/src/SubresourceIntegrityServiceProvider.php new file mode 100644 index 0000000..8a14d36 --- /dev/null +++ b/src/SubresourceIntegrityServiceProvider.php @@ -0,0 +1,50 @@ +app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../config/sri.php' => config_path('sri.php'), + ], 'config'); + } + } + + /** + * Register the application services. + */ + public function register() + { + $this->mergeConfigFrom(__DIR__.'/../config/sri.php', 'sri'); + + $this->app->singleton(Hasher::class, function ($app) { + return new Hasher(hash_algos(), $app['config']->get('sri')); + }); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return [Hasher::class]; + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..9f14bca --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,47 @@ +version(), '5.3', '<')) { + $hashCollection = $hashCollection->flip(); + } + + $file = public_path($file); + + return $hashCollection->first(function ($hash, $hashedFile) use ($file) { + return base_path($hashedFile) == $file; + }, function () use ($file, $options) { + if (file_exists($file)) { + return app(Hasher::class)->make($file, $options); + } + + throw new \InvalidArgumentException("File {$file} not defined in {$options['filename']}."); + }); + } +} diff --git a/tests/HasherTest.php b/tests/HasherTest.php new file mode 100644 index 0000000..81f5eb4 --- /dev/null +++ b/tests/HasherTest.php @@ -0,0 +1,180 @@ +getCss(); + $hasher = $this->app->make(Hasher::class); + + // act + + $hash = $hasher->make($css); + + // assert + + $this->assertStringStartsWith('sha256-', $hash); + } + + /** + * @test + */ + public function it_accepts_different_algorithms() + { + // arrange + + $css = $this->getCss(); + $this->app['config']->set('sri.algorithms', ['sha384']); + $hasher = $this->app->make(Hasher::class); + + // act + + $hash = $hasher->make($css); + + // assert + + $this->assertStringStartsWith('sha384-', $hash); + + // act + + $hash = $hasher->make($css, ['algorithms' => ['sha512']]); + + // assert + + $this->assertStringStartsWith('sha512-', $hash); + } + + /** + * @test + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage sha1024 + */ + public function it_does_not_accept_invalid_algorithms() + { + // arrange + + $css = $this->getCss(); + $this->app['config']->set('sri.algorithms', ['sha512', 'sha1024']); + $hasher = $this->app->make(Hasher::class); + + // act + + $hasher->make($css); + } + + /** + * @test + */ + public function it_accepts_multiple_algorithms() + { + // arrange + + $css = $this->getCss(); + $this->app['config']->set('sri.algorithms', ['sha256', 'sha384']); + $hasher = $this->app->make(Hasher::class); + + // act + + $hash = $hasher->make($css); + + // assert + + $this->assertRegExp('/^sha256-.+ sha384-.+$/', $hash); + + // act + + $hash = $hasher->make($css, ['algorithms' => ['sha384', 'sha512']]); + + // assert + + $this->assertRegExp('/^sha384-.+ sha512-.+$/', $hash); + } + + /** + * @test + */ + public function it_accepts_a_different_delimiter() + { + // arrange + + $css = $this->getCss(); + $this->app['config']->set('sri.algorithms', ['sha256', 'sha384']); + $this->app['config']->set('sri.delimiter', '_'); + $hasher = $this->app->make(Hasher::class); + + // act + + $hash = $hasher->make($css); + + // assert + + $this->assertRegExp('/^sha256-.+_sha384-.+$/', $hash); + + // act + + $hash = $hasher->make($css, ['delimiter' => ':']); + + // assert + + $this->assertRegExp('/^sha256-.+:sha384-.+$/', $hash); + } + + /** + * @test + */ + public function it_checks_a_hash() + { + // arrange + + $css = $this->getCss(); + $hasher = $this->app->make(Hasher::class); + + // act + + $hash = $hasher->make($css); + + // assert + + $this->assertTrue($hasher->check($css, $hash)); + $this->assertFalse($hasher->check($css, $hash, ['algorithms' => ['sha512']])); + } + + /** + * @test + */ + public function it_checks_if_the_hash_needs_rehash() + { + // arrange + + $css = $this->getCss(); + $hasher = $this->app->make(Hasher::class); + + // act + + $hash = $hasher->make($css, ['algorithms' => ['sha256', 'sha384']]); + + // assert + + $this->assertFalse($hasher->needsRehash($hash, [ + 'algorithms' => ['sha256', 'sha384'], + ])); + + $this->assertTrue($hasher->needsRehash($hash, [ + 'algorithms' => ['sha384'], + ])); + + $this->assertTrue($hasher->needsRehash($hash, [ + 'algorithms' => ['sha256', 'sha384'], + 'delimiter' => ':', + ])); + } +} diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php new file mode 100644 index 0000000..ba262d0 --- /dev/null +++ b/tests/HelpersTest.php @@ -0,0 +1,60 @@ +app->make(Hasher::class); + + $integrity = integrity('app.css'); + + $this->assertTrue($hasher->check($this->getCss(), $integrity)); + } + + /** + * @test + */ + public function it_generates_the_hash_if_it_does_not_exist() + { + $hasher = $this->app->make(Hasher::class); + + $integrity = integrity('app.js'); + + $this->assertTrue($hasher->check($this->getJs(), $integrity)); + } + + /** + * @test + */ + public function it_accepts_options() + { + $hasher = $this->app->make(Hasher::class); + + rename( + $this->getTestFilesDirectory('sri.json'), + $this->getTestFilesDirectory('integrity.json') + ); + + $integrity = integrity('app.css', ['filename' => 'integrity.json']); + + $this->assertTrue($hasher->check($this->getCss(), $integrity)); + } + + /** + * @test + * @expectedException \InvalidArgumentException + */ + public function it_fails_if_a_file_does_not_exist() + { + $hasher = $this->app->make(Hasher::class); + + integrity('app.min.css'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..a1d1be2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,82 @@ +setUpTempTestFiles(); + } + + /** + * @param \Illuminate\Foundation\Application $app + * + * @return array + */ + protected function getPackageProviders($app) + { + return [ + SubresourceIntegrityServiceProvider::class, + ]; + } + + /** + * @param \Illuminate\Foundation\Application $app + */ + protected function getEnvironmentSetUp($app) + { + $this->initializeDirectory($this->getTempDirectory()); + + $app->setBasePath($this->getTestFilesDirectory()); + + $app->singleton('path.public', function () { + return $this->getTestFilesDirectory(); + }); + + $config = $app['files']->getRequire(__DIR__ . '/../config/sri.php'); + + $app['config']->set('sri', $config); + } + + protected function setUpTempTestFiles() + { + $this->initializeDirectory($this->getTestFilesDirectory()); + File::copyDirectory(__DIR__.'/testfiles', $this->getTestFilesDirectory()); + } + + protected function initializeDirectory($directory) + { + if (File::isDirectory($directory)) { + File::deleteDirectory($directory); + } + File::makeDirectory($directory); + } + + public function getTempDirectory($suffix = '') + { + return __DIR__.'/temp'.($suffix == '' ? '' : '/'.$suffix); + } + + public function getTestFilesDirectory($suffix = '') + { + return $this->getTempDirectory().'/testfiles'.($suffix == '' ? '' : '/'.$suffix); + } + + public function getCss() + { + return $this->getTestFilesDirectory('app.css'); + } + + public function getJs() + { + return $this->getTestFilesDirectory('app.js'); + } +} diff --git a/tests/testfiles/app.css b/tests/testfiles/app.css new file mode 100644 index 0000000..4a0203a --- /dev/null +++ b/tests/testfiles/app.css @@ -0,0 +1,3 @@ +.flex { + display: flex; +} diff --git a/tests/testfiles/app.js b/tests/testfiles/app.js new file mode 100644 index 0000000..ec9bc0e --- /dev/null +++ b/tests/testfiles/app.js @@ -0,0 +1 @@ +console.log('SRI'); diff --git a/tests/testfiles/sri.json b/tests/testfiles/sri.json new file mode 100644 index 0000000..e654f04 --- /dev/null +++ b/tests/testfiles/sri.json @@ -0,0 +1,3 @@ +{ + "app.css": "sha256-LwVRqU6YK4JxEeAofRks4sFR9U/yambtZYgim8JJpXQ=" +}