diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b263871 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/.scrutinizer.yml export-ignore +/tests export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..751f6c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build +composer.lock +docs +vendor \ No newline at end of file diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..f3ec4f4 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,35 @@ +filter: + excluded_paths: [tests/*] +checks: + php: + code_rating: true + remove_extra_empty_lines: true + remove_php_closing_tag: true + remove_trailing_whitespace: true + fix_use_statements: + remove_unused: true + preserve_multiple: false + preserve_blanklines: true + order_alphabetically: true + fix_php_opening_tag: true + fix_linefeed: true + fix_line_ending: true + fix_identation_4spaces: true + fix_doc_comments: true +tools: + external_code_coverage: + timeout: 600 + runs: 1 + php_analyzer: true + php_code_coverage: false + php_code_sniffer: + config: + standard: PSR2 + filter: + paths: ['src'] + php_loc: + enabled: true + excluded_dirs: [vendor, tests] + php_cpd: + enabled: true + excluded_dirs: [vendor, tests] diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..94490e0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: php + +php: + - 5.6 + - 7.0 + - hhvm + +before_script: + - travis_retry composer self-update + - travis_retry composer install --no-interaction --prefer-source --dev + - travis_retry phpenv rehash + +script: + - ./vendor/bin/phpcs --standard=psr2 src/ + - ./vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover + +after_script: + - wget https://scrutinizer-ci.com/ocular.phar + - php ocular.phar code-coverage:upload --format=php-clover coverage.clover diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd87572 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +#Changelog + +All Notable changes to `laravel-middleware-csp` will be documented in this file + +## NEXT - YYYY-MM-DD + +### Added +- Nothing + +### Deprecated +- Nothing + +### Fixed +- Nothing + +### Removed +- Nothing + +### Security +- Nothing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9857f70 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +We accept contributions via Pull Requests on [Github](https://github.com/stevenmaguire/laravel-middleware-csp). + + +## Pull Requests + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. + +- **Create feature branches** - Don't ask us to pull from your master branch. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting. + + +## Running Tests + +``` bash +$ phpunit +``` + + +**Happy coding**! diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1ee5d79 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2015 Steven Maguire + +> 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..aa642f2 --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# Content Security Policy Middleware + +[![Latest Version](https://img.shields.io/github/release/stevenmaguire/laravel-middleware-csp.svg?style=flat-square)](https://github.com/stevenmaguire/laravel-middleware-csp/releases) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) +[![Build Status](https://img.shields.io/travis/stevenmaguire/laravel-middleware-csp/master.svg?style=flat-square)](https://travis-ci.org/stevenmaguire/laravel-middleware-csp) +[![Coverage Status](https://img.shields.io/scrutinizer/coverage/g/stevenmaguire/laravel-middleware-csp.svg?style=flat-square)](https://scrutinizer-ci.com/g/stevenmaguire/laravel-middleware-csp/code-structure) +[![Quality Score](https://img.shields.io/scrutinizer/g/stevenmaguire/laravel-middleware-csp.svg?style=flat-square)](https://scrutinizer-ci.com/g/stevenmaguire/laravel-middleware-csp) +[![Total Downloads](https://img.shields.io/packagist/dt/stevenmaguire/laravel-middleware-csp.svg?style=flat-square)](https://packagist.org/packages/stevenmaguire/laravel-middleware-csp) + +Provides support for enforcing Content Security Policy with headers in Laravel responses. + +## Install + +Via Composer + +``` bash +$ composer require stevenmaguire/laravel-middleware-csp +``` + +## Usage + +### Register as route middleware + +``` php +// within app/Http/Kernal.php + +protected $routeMiddleware = [ + // + 'secure.content' => \Stevenmaguire\Http\Middleware\Laravel\EnforceContentSecurity::class, + // +]; +``` + +### Apply content security policy to routes + +The following will apply all default profiles to the `gallery` route. + +``` php +// within app/Http/routes.php + +Route::get('gallery', ['middleware' => 'secure.content', function () { + return 'pictures!'; +}]); +``` + +The following will apply all default profiles and a specific `flickr` profile to the `gallery` route. + +``` php +// within app/Http/routes.php + +Route::get('gallery', ['middleware' => 'secure.content:flickr', function () { + return 'pictures!'; +}]); +``` + + +### Apply content security policy to controllers + +The following will apply all default profiles to all methods within the `GalleryController`. + +``` php +// within app/Http/Controllers/GalleryController.php + +public function __construct() +{ + $this->middleware('secure.content'); +} +``` +The following will apply all default profiles and a specific `google` profile to all methods within the `GalleryController`. + +``` php +// within app/Http/Controllers/GalleryController.php + +public function __construct() +{ + $this->middleware('secure.content:google'); +} +``` +You can include any number of specific profiles to any middleware decoration. For instance, the following will apply default, `google`, `flickr`, and `my_custom` profiles to all methods within the `GalleryController`. + +``` php +// within app/Http/Controllers/GalleryController.php + +public function __construct() +{ + $this->middleware('secure.content:google,flickr,my_custom'); +} +``` + +### Create content security profiles + +The default location for content security profiles is `security.content`. If you wish to use this default configuration, ensure your project includes the appropriate configuration files. + +The structure of this configuration array is important. The middleware expects to find a `default` key with a string value and a `profiles` key with an array value. + +``` php +// within config/security.php + +return [ + 'content' => [ + 'default' => '', + 'profiles' => [], + ], +]; + +``` +The `profiles` array contains the security profiles for your application. Each profile name must be unique and is expected to have a value of an array. + +``` php +// within config/security.php + +return [ + 'content' => [ + 'default' => '', + 'profiles' => [ + 'profile_one' => [], + 'profile_two' => [], + 'profile_three' => [], + ], + ], +]; + +``` +Each profile array should contain keys that correspond to Content Security Policy directives. The value of each of these directives can be a string, comma-separated string, or array of strings. Each string value should correspond to the domain associated with your directive and profile. + +``` php +// within config/security.php + +return [ + 'content' => [ + 'default' => '', + 'profiles' => [ + 'profile_one' => [ + 'base-uri' => 'https://domain.com,http://google.com', + ], + 'profile_two' => [ + 'font-src' => 'https://domain.com', + 'base-uri' => [ + "'self'", + 'http://google.com' + ], + ], + 'profile_three' => [ + 'font-src' => [ + "'self'" + ], + ], + ], + ], +]; + +``` +The `default` key value should be a string, comma-separated string, or array of strings that correspond to the unique profile names that you would like to enforce on all responses with minimal content security applied. + +``` php +// within config/security.php + +return [ + 'content' => [ + 'default' => 'profile_one', + 'profiles' => [ + 'profile_one' => [ + 'base-uri' => 'https://domain.com,http://google.com', + ], + 'profile_two' => [ + 'font-src' => 'https://domain.com', + 'base-uri' => [ + "'self'", + 'http://google.com' + ], + ], + 'profile_three' => [ + 'font-src' => [ + "'self'" + ], + ], + ], + ], +]; + +``` + +Here is a real-world example: + +``` php +// within config/security.php + +return [ + 'content' => [ + 'default' => 'global', + 'profiles' => [ + 'global' => [ + 'base-uri' => "'self'", + 'font-src' => [ + "'self'", + 'fonts.gstatic.com' + ], + 'img-src' => "'self'", + 'script-src' => "'self'", + 'style-src' => [ + "'self'", + "'unsafe-inline'", + 'fonts.googleapis.com' + ], + ], + 'flickr' => [ + 'img-src' => [ + 'https://*.staticflickr.com', + ], + ], + ], + ], +]; + +``` + +## Testing + +``` bash +$ ./vendor/bin/phpunit +``` + +## Contributing + +Please see [CONTRIBUTING](https://github.com/stevenmaguire/laravel-middleware-csp/blob/master/CONTRIBUTING.md) for details. + +## Credits + +- [Steven Maguire](https://github.com/stevenmaguire) +- [All Contributors](https://github.com/stevenmaguire/laravel-middleware-csp/contributors) + +## 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..53d3a1e --- /dev/null +++ b/composer.json @@ -0,0 +1,47 @@ +{ + "name": "stevenmaguire/laravel-middleware-csp", + "description": "Provides support for enforcing Content Security Policy with headers in Laravel responses.", + "keywords": [ + "middleware", + "psr7", + "content security policy", + "headers", + "laravel" + ], + "homepage": "https://github.com/stevenmaguire/laravel-middleware-csp", + "license": "MIT", + "authors": [ + { + "name": "Steven Maguire", + "email": "stevenmaguire@gmail.com", + "homepage": "https://github.com/stevenmaguire", + "role": "Developer" + } + ], + "require": { + "php" : ">=5.5.9", + "stevenmaguire/middleware-csp": "^0.1", + "guzzlehttp/psr7": "^1.1", + "illuminate/http": "^5.1" + }, + "require-dev": { + "phpunit/phpunit": "3.7.*", + "mockery/mockery": "0.9.*@dev", + "squizlabs/php_codesniffer": "~2.0" + }, + "autoload": { + "psr-4": { + "Stevenmaguire\\Http\\Middleware\\Laravel\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Stevenmaguire\\Http\\Middleware\\Laravel\\Test\\": "tests" + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..81e7915 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,38 @@ + + + + + + + + + ./tests/ + + + + + ./ + + ./vendor + ./tests + + + + diff --git a/src/EnforceContentSecurity.php b/src/EnforceContentSecurity.php new file mode 100644 index 0000000..e255720 --- /dev/null +++ b/src/EnforceContentSecurity.php @@ -0,0 +1,164 @@ +setConfigClosure(function ($key = null, $default = null) { + // @codeCoverageIgnoreStart + if (function_exists('config')) { + return config($key, $default); + } + + return null; + // @codeCoverageIgnoreEnd + }); + } + + /** + * Handles an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + $response = $next($request); + + if ($response instanceof Response) { + $this->setProfiles($this->getProfileConfig()); + + $this->setProfilesWithParameters(func_get_args()); + + $psr7Response = $this->createPsr7Response($response); + + $psr7Response = $this->addPolicyHeader($psr7Response); + + $response = $this->createLaravelResponse($psr7Response); + } + + return $response; + } + + /** + * Creates Laravel response object from PSR 7 response. + * + * @param ResponseInterface $response + * + * @return Response + */ + protected function createLaravelResponse(ResponseInterface $response) + { + return new Response( + (string) $response->getBody(), + $response->getStatusCode(), + $response->getHeaders() + ); + } + + /** + * Creates PSR 7 response object from Laravel response. + * + * @param Response $response + * + * @return ResponseInterface + */ + protected function createPsr7Response(Response $response) + { + return new PsrResponse( + $response->getStatusCode(), + $response->headers->all(), + $response->getContent(), + $response->getProtocolVersion() + ); + } + + /** + * Retrives profile configuration from Laravel config object. + * + * @return array + */ + protected function getProfileConfig() + { + $configCallable = $this->config; + $config = $configCallable($this->getProfileConfigKey()); + + if (!is_array($config)) { + $config = [$config]; + } + + return array_filter($config); + } + + /** + * Retrieves configuration key associated with content security profiles. + * + * @return string + */ + protected function getProfileConfigKey() + { + return 'security.content'; + } + + /** + * Gets profiles from handle method arguments. + * + * @param array $arguments + * + * @return array + */ + protected function getProfilesFromArguments(array $arguments) + { + $profiles = []; + if (count($arguments) > 2) { + unset($arguments[0]); + unset($arguments[1]); + $profiles = $arguments; + } + return $profiles; + } + + /** + * Updates config callable used to access application configuration data. + * + * @param Closure $config + * + * @return EnforceContentSecurity + */ + public function setConfigClosure(Closure $config) + { + $this->config = $config; + + return $this; + } + + /** + * Updates policy configuration with rules from each profile in given parameters. + * + * @param array $parameters + * + * @return void + */ + protected function setProfilesWithParameters(array $parameters) + { + $profiles = $this->getProfilesFromArguments($parameters); + array_map([$this, 'loadProfileByKey'], $profiles); + } +} diff --git a/tests/EnforceContentSecurityTest.php b/tests/EnforceContentSecurityTest.php new file mode 100644 index 0000000..5e18cd5 --- /dev/null +++ b/tests/EnforceContentSecurityTest.php @@ -0,0 +1,184 @@ +middleware = new EnforceContentSecurity; + $this->configShouldReturn(null); + } + + protected function getTestConfig() + { + return [ + 'default' => 'test', + 'profiles' => [ + 'test' => [ + 'base-uri' => [ + 'http://domain.com', + 'http://domain.co', + 'http://domain.biz', + ], + ], + 'test2' => [ + 'base-uri' => [ + 'http://domain.online', + 'http://domain.music', + 'http://domain.chickens', + ], + ] + ] + ]; + } + + protected function configShouldReturn($value) + { + $this->middleware->setConfigClosure(function ($key) use ($value) { + return $value; + }); + } + + public function testResponseUnaffectedWhenJsonResponse() + { + $request = m::mock(Request::class); + $response = m::mock(JsonResponse::class); + $next = function ($request) use ($response) { + return $response; + }; + + $result = $this->middleware->handle($request, $next); + + $this->assertEquals($response, $result); + } + + public function testResponseUnaffectedWhenRedirectResponse() + { + $request = m::mock(Request::class); + $response = m::mock(RedirectResponse::class); + $next = function ($request) use ($response) { + return $response; + }; + + $result = $this->middleware->handle($request, $next); + + $this->assertEquals($response, $result); + } + + public function testProfileConfigAlwaysReturnsAnArray() + { + $statusCode = 200; + $body = uniqid(); + $request = m::mock(Request::class); + $headers = m::mock(ResponseHeaderBag::class); + $headers->shouldReceive('all')->andReturn([]); + $response = m::mock(Response::class); + $response->headers = $headers; + $response->shouldReceive('getStatusCode')->andReturn($statusCode); + $response->shouldReceive('getContent')->andReturn($body); + $response->shouldReceive('getProtocolVersion')->andReturn('1.1'); + $next = function ($request) use ($response) { + return $response; + }; + + $this->configShouldReturn(null); + + $response = $this->middleware->handle($request, $next); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($statusCode, $response->status()); + $this->assertEquals($body, $response->getOriginalContent()); + $this->assertNull($response->headers->get('content-security-policy')); + } + + public function testContentSecurityAdded() + { + $statusCode = 200; + $body = uniqid(); + $expectedPolicy = 'base-uri http://domain.biz http://domain.co http://domain.com'; + $request = m::mock(Request::class); + $headers = m::mock(ResponseHeaderBag::class); + $headers->shouldReceive('all')->andReturn([]); + $response = m::mock(Response::class); + $response->headers = $headers; + $response->shouldReceive('getStatusCode')->andReturn($statusCode); + $response->shouldReceive('getContent')->andReturn($body); + $response->shouldReceive('getProtocolVersion')->andReturn('1.1'); + $next = function ($request) use ($response) { + return $response; + }; + + $this->configShouldReturn($this->getTestConfig()); + + $response = $this->middleware->handle($request, $next); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($statusCode, $response->status()); + $this->assertEquals($body, $response->getOriginalContent()); + $this->assertEquals($expectedPolicy, $response->headers->get('content-security-policy')); + } + + public function testContentSecurityAddedWithGivenProfile() + { + $statusCode = 200; + $body = uniqid(); + $expectedPolicy = 'base-uri http://domain.biz http://domain.chickens http://domain.co http://domain.com http://domain.music http://domain.online'; + $request = m::mock(Request::class); + $headers = m::mock(ResponseHeaderBag::class); + $headers->shouldReceive('all')->andReturn([]); + $response = m::mock(Response::class); + $response->headers = $headers; + $response->shouldReceive('getStatusCode')->andReturn($statusCode); + $response->shouldReceive('getContent')->andReturn($body); + $response->shouldReceive('getProtocolVersion')->andReturn('1.1'); + $next = function ($request) use ($response) { + return $response; + }; + + $this->configShouldReturn($this->getTestConfig()); + + $response = $this->middleware->handle($request, $next, 'test2'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($statusCode, $response->status()); + $this->assertEquals($body, $response->getOriginalContent()); + $this->assertEquals($expectedPolicy, $response->headers->get('content-security-policy')); + } + + public function testContentSecurityAddedWithOnlyGivenProfiles() + { + $statusCode = 200; + $body = uniqid(); + $expectedPolicy = 'base-uri http://domain.chickens http://domain.music http://domain.online'; + $request = m::mock(Request::class); + $headers = m::mock(ResponseHeaderBag::class); + $headers->shouldReceive('all')->andReturn([]); + $response = m::mock(Response::class); + $response->headers = $headers; + $response->shouldReceive('getStatusCode')->andReturn($statusCode); + $response->shouldReceive('getContent')->andReturn($body); + $response->shouldReceive('getProtocolVersion')->andReturn('1.1'); + $next = function ($request) use ($response) { + return $response; + }; + $config = $this->getTestConfig(); + unset($config['default']); + + $this->configShouldReturn($config); + + $response = $this->middleware->handle($request, $next, 'test2'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($statusCode, $response->status()); + $this->assertEquals($body, $response->getOriginalContent()); + $this->assertEquals($expectedPolicy, $response->headers->get('content-security-policy')); + } +}