From 571a1f2f04829ed91c990a4070b90ab3e245f627 Mon Sep 17 00:00:00 2001 From: Romain Neutron Date: Mon, 4 Apr 2016 10:48:39 +0200 Subject: [PATCH] [WebProfilerBundle] Fix bundle usage in Content-Security-Policy context without unsafe-inline --- .../Resources/views/Profiler/dump.html.twig | 1 - .../Controller/ProfilerController.php | 43 ++- .../Csp/ContentSecurityPolicyHandler.php | 265 ++++++++++++++++++ .../WebProfilerBundle/Csp/NonceGenerator.php | 27 ++ .../EventListener/WebDebugToolbarListener.php | 13 +- .../Resources/config/profiler.xml | 7 + .../Resources/config/toolbar.xml | 1 + .../views/Profiler/base_js.html.twig | 2 +- .../Resources/views/Profiler/layout.html.twig | 10 +- .../Resources/views/Profiler/toolbar.css.twig | 9 + .../views/Profiler/toolbar.html.twig | 31 +- .../views/Profiler/toolbar_js.html.twig | 36 ++- .../Controller/ProfilerControllerTest.php | 48 +++- .../Csp/ContentSecurityPolicyHandlerTest.php | 199 +++++++++++++ .../WebDebugToolbarListenerTest.php | 5 +- .../Bundle/WebProfilerBundle/composer.json | 1 + 16 files changed, 650 insertions(+), 48 deletions(-) create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Tests/Csp/ContentSecurityPolicyHandlerTest.php diff --git a/src/Symfony/Bundle/DebugBundle/Resources/views/Profiler/dump.html.twig b/src/Symfony/Bundle/DebugBundle/Resources/views/Profiler/dump.html.twig index 1163d283d0..0ece492346 100644 --- a/src/Symfony/Bundle/DebugBundle/Resources/views/Profiler/dump.html.twig +++ b/src/Symfony/Bundle/DebugBundle/Resources/views/Profiler/dump.html.twig @@ -27,7 +27,6 @@ {{ dump.data|raw }} {% endfor %} - {% endset %} {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php b/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php index b6744c5855..75e6a272ed 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\WebProfilerBundle\Controller; +use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler; use Symfony\Bundle\WebProfilerBundle\Profiler\TemplateManager; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -33,6 +34,7 @@ class ProfilerController private $twig; private $templates; private $toolbarPosition; + private $cspHandler; /** * Constructor. @@ -43,13 +45,14 @@ class ProfilerController * @param array $templates The templates * @param string $toolbarPosition The toolbar position (top, bottom, normal, or null -- use the configuration) */ - public function __construct(UrlGeneratorInterface $generator, Profiler $profiler = null, \Twig_Environment $twig, array $templates, $toolbarPosition = 'normal') + public function __construct(UrlGeneratorInterface $generator, Profiler $profiler = null, \Twig_Environment $twig, array $templates, $toolbarPosition = 'normal', ContentSecurityPolicyHandler $cspHandler = null) { $this->generator = $generator; $this->profiler = $profiler; $this->twig = $twig; $this->templates = $templates; $this->toolbarPosition = $toolbarPosition; + $this->cspHandler = $cspHandler; } /** @@ -88,6 +91,10 @@ class ProfilerController $this->profiler->disable(); + if (null !== $this->cspHandler) { + $this->cspHandler->disableCsp(); + } + $panel = $request->query->get('panel', 'request'); $page = $request->query->get('page', 'home'); @@ -134,6 +141,10 @@ class ProfilerController $this->profiler->disable(); + if (null !== $this->cspHandler) { + $this->cspHandler->disableCsp(); + } + return new Response($this->twig->render('@WebProfiler/Profiler/info.html.twig', array( 'about' => $about, 'request' => $request, @@ -185,7 +196,7 @@ class ProfilerController // the profiler is not enabled } - return new Response($this->twig->render('@WebProfiler/Profiler/toolbar.html.twig', array( + return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/toolbar.html.twig', array( 'request' => $request, 'position' => $position, 'profile' => $profile, @@ -193,7 +204,7 @@ class ProfilerController 'profiler_url' => $url, 'token' => $token, 'profiler_markup_version' => 2, // 1 = original toolbar, 2 = Symfony 2.8+ toolbar - )), 200, array('Content-Type' => 'text/html')); + )); } /** @@ -213,6 +224,10 @@ class ProfilerController $this->profiler->disable(); + if (null !== $this->cspHandler) { + $this->cspHandler->disableCsp(); + } + if (null === $session = $request->getSession()) { $ip = $method = @@ -268,6 +283,10 @@ class ProfilerController $this->profiler->disable(); + if (null !== $this->cspHandler) { + $this->cspHandler->disableCsp(); + } + $profile = $this->profiler->loadProfile($token); $ip = $request->query->get('ip'); @@ -364,6 +383,10 @@ class ProfilerController $this->profiler->disable(); + if (null !== $this->cspHandler) { + $this->cspHandler->disableCsp(); + } + ob_start(); phpinfo(); $phpinfo = ob_get_clean(); @@ -384,4 +407,18 @@ class ProfilerController return $this->templateManager; } + + private function renderWithCspNonces(Request $request, $template, $variables, $code = 200, $headers = array('Content-Type' => 'text/html')) + { + $response = new Response('', $code, $headers); + + $nonces = $this->cspHandler ? $this->cspHandler->getNonces($request, $response) : array(); + + $variables['csp_script_nonce'] = isset($nonces['csp_script_nonce']) ? $nonces['csp_script_nonce'] : null; + $variables['csp_style_nonce'] = isset($nonces['csp_style_nonce']) ? $nonces['csp_style_nonce'] : null; + + $response->setContent($this->twig->render($template, $variables)); + + return $response; + } } diff --git a/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php b/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php new file mode 100644 index 0000000000..195f3f18ff --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php @@ -0,0 +1,265 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\WebProfilerBundle\Csp; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Handles Content-Security-Policy HTTP header for the WebProfiler Bundle. + * + * @author Romain Neutron + * + * @internal + */ +class ContentSecurityPolicyHandler +{ + private $nonceGenerator; + private $cspDisabled = false; + + public function __construct(NonceGenerator $nonceGenerator) + { + $this->nonceGenerator = $nonceGenerator; + } + + /** + * Returns an array of nonces to be used in Twig templates and Content-Security-Policy headers. + * + * Nonce can be provided by; + * - The request - In case HTML content is fetched via AJAX and inserted in DOM, it must use the same nonce as origin + * - The response - A call to getNonces() has already been done previously. Same nonce are returned + * - They are otherwise randomly generated + * + * @return array + */ + public function getNonces(Request $request, Response $response) + { + if ($request->headers->has('X-SymfonyProfiler-Script-Nonce') && $request->headers->has('X-SymfonyProfiler-Style-Nonce')) { + return array( + 'csp_script_nonce' => $request->headers->get('X-SymfonyProfiler-Script-Nonce'), + 'csp_style_nonce' => $request->headers->get('X-SymfonyProfiler-Style-Nonce'), + ); + } + + if ($response->headers->has('X-SymfonyProfiler-Script-Nonce') && $response->headers->has('X-SymfonyProfiler-Style-Nonce')) { + return array( + 'csp_script_nonce' => $response->headers->get('X-SymfonyProfiler-Script-Nonce'), + 'csp_style_nonce' => $response->headers->get('X-SymfonyProfiler-Style-Nonce'), + ); + } + + $nonces = array( + 'csp_script_nonce' => $this->generateNonce(), + 'csp_style_nonce' => $this->generateNonce(), + ); + + $response->headers->set('X-SymfonyProfiler-Script-Nonce', $nonces['csp_script_nonce']); + $response->headers->set('X-SymfonyProfiler-Style-Nonce', $nonces['csp_style_nonce']); + + return $nonces; + } + + /** + * Disables Content-Security-Policy. + * + * All related headers will be removed. + */ + public function disableCsp() + { + $this->cspDisabled = true; + } + + /** + * Cleanup temporary headers and updates Content-Security-Policy headers. + * + * @return array Nonces used by the bundle in Content-Security-Policy header + */ + public function updateResponseHeaders(Request $request, Response $response) + { + if ($this->cspDisabled) { + $this->removeCspHeaders($response); + + return array(); + } + + $nonces = $this->getNonces($request, $response); + $this->cleanHeaders($response); + $this->updateCspHeaders($response, $nonces); + + return $nonces; + } + + private function cleanHeaders(Response $response) + { + $response->headers->remove('X-SymfonyProfiler-Script-Nonce'); + $response->headers->remove('X-SymfonyProfiler-Style-Nonce'); + } + + private function removeCspHeaders(Response $response) + { + $response->headers->remove('X-Content-Security-Policy'); + $response->headers->remove('Content-Security-Policy'); + } + + /** + * Updates Content-Security-Policy headers in a response. + * + * @return array + */ + private function updateCspHeaders(Response $response, array $nonces = array()) + { + $nonces = array_replace(array( + 'csp_script_nonce' => $this->generateNonce(), + 'csp_style_nonce' => $this->generateNonce(), + ), $nonces); + + $ruleIsSet = false; + + $headers = $this->getCspHeaders($response); + + foreach ($headers as $header => $directives) { + foreach (array('script-src' => 'csp_script_nonce', 'style-src' => 'csp_style_nonce') as $type => $tokenName) { + if ($this->authorizesInline($directives, $type)) { + continue; + } + if (!isset($headers[$header][$type])) { + if (isset($headers[$header]['default-src'])) { + $headers[$header][$type] = $headers[$header]['default-src']; + } else { + $headers[$header][$type] = array(); + } + } + $ruleIsSet = true; + if (!in_array('\'unsafe-inline\'', $headers[$header][$type], true)) { + $headers[$header][$type][] = '\'unsafe-inline\''; + } + $headers[$header][$type][] = sprintf('\'nonce-%s\'', $nonces[$tokenName]); + } + } + + if (!$ruleIsSet) { + return $nonces; + } + + foreach ($headers as $header => $directives) { + $response->headers->set($header, $this->generateCspHeader($directives)); + } + + return $nonces; + } + + /** + * Generates a valid Content-Security-Policy nonce. + * + * @return string + */ + private function generateNonce() + { + return $this->nonceGenerator->generate(); + } + + /** + * Converts a directive set array into Content-Security-Policy header. + * + * @param array $directives The directive set + * + * @return string The Content-Security-Policy header + */ + private function generateCspHeader(array $directives) + { + return array_reduce(array_keys($directives), function ($res, $name) use ($directives) { + return ($res !== '' ? $res.'; ' : '').sprintf('%s %s', $name, implode(' ', $directives[$name])); + }, ''); + } + + /** + * Converts a Content-Security-Policy header value into a directive set array. + * + * @param string $header The header value + * + * @return array The directive set + */ + private function parseDirectives($header) + { + $directives = array(); + + foreach (explode(';', $header) as $directive) { + $parts = explode(' ', trim($directive)); + if (count($parts) < 1) { + continue; + } + $name = array_shift($parts); + $directives[$name] = $parts; + } + + return $directives; + } + + /** + * Detects if the 'unsafe-inline' is prevented for a directive within the directive set. + * + * @param array $directivesSet The directive set + * @param string $type The name of the directive to check + * + * @return bool + */ + private function authorizesInline(array $directivesSet, $type) + { + if (isset($directivesSet[$type])) { + $directives = $directivesSet[$type]; + } elseif (isset($directivesSet['default-src'])) { + $directives = $directivesSet['default-src']; + } else { + return false; + } + + return in_array('\'unsafe-inline\'', $directives, true) && !$this->hasHashOrNonce($directives); + } + + private function hasHashOrNonce(array $directives) + { + foreach ($directives as $directive) { + if ('\'' !== substr($directive, -1)) { + continue; + } + if ('\'nonce-' === substr($directive, 0, 7)) { + return true; + } + if (in_array(substr($directive, 0, 8), array('\'sha256-', '\'sha384-', '\'sha512-'), true)) { + return true; + } + } + + return false; + } + + /** + * Retrieves the Content-Security-Policy headers (either X-Content-Security-Policy or Content-Security-Policy) from + * a response. + * + * @return array An associative array of headers + */ + private function getCspHeaders(Response $response) + { + $headers = array(); + + if ($response->headers->has('Content-Security-Policy')) { + $headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy')); + } + + if ($response->headers->has('X-Content-Security-Policy')) { + $headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy')); + } + + return $headers; + } +} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php b/src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php new file mode 100644 index 0000000000..728043551f --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\WebProfilerBundle\Csp; + +/** + * Generates Content-Security-Policy nonce. + * + * @author Romain Neutron + * + * @internal + */ +class NonceGenerator +{ + public function generate() + { + return bin2hex(random_bytes(16)); + } +} diff --git a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php index ee537af4f9..ebc1037d35 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php +++ b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\WebProfilerBundle\EventListener; +use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Flash\AutoExpireFlashBag; @@ -40,8 +41,9 @@ class WebDebugToolbarListener implements EventSubscriberInterface protected $mode; protected $position; protected $excludedAjaxPaths; + private $cspHandler; - public function __construct(\Twig_Environment $twig, $interceptRedirects = false, $mode = self::ENABLED, $position = 'bottom', UrlGeneratorInterface $urlGenerator = null, $excludedAjaxPaths = '^/bundles|^/_wdt') + public function __construct(\Twig_Environment $twig, $interceptRedirects = false, $mode = self::ENABLED, $position = 'bottom', UrlGeneratorInterface $urlGenerator = null, $excludedAjaxPaths = '^/bundles|^/_wdt', ContentSecurityPolicyHandler $cspHandler = null) { $this->twig = $twig; $this->urlGenerator = $urlGenerator; @@ -49,6 +51,7 @@ class WebDebugToolbarListener implements EventSubscriberInterface $this->mode = (int) $mode; $this->position = $position; $this->excludedAjaxPaths = $excludedAjaxPaths; + $this->cspHandler = $cspHandler; } public function isEnabled() @@ -76,6 +79,8 @@ class WebDebugToolbarListener implements EventSubscriberInterface return; } + $nonces = $this->cspHandler ? $this->cspHandler->updateResponseHeaders($request, $response) : array(); + // do not capture redirects or modify XML HTTP Requests if ($request->isXmlHttpRequest()) { return; @@ -102,7 +107,7 @@ class WebDebugToolbarListener implements EventSubscriberInterface return; } - $this->injectToolbar($response, $request); + $this->injectToolbar($response, $request, $nonces); } /** @@ -110,7 +115,7 @@ class WebDebugToolbarListener implements EventSubscriberInterface * * @param Response $response A Response instance */ - protected function injectToolbar(Response $response, Request $request) + protected function injectToolbar(Response $response, Request $request, array $nonces) { $content = $response->getContent(); $pos = strripos($content, ''); @@ -123,6 +128,8 @@ class WebDebugToolbarListener implements EventSubscriberInterface 'excluded_ajax_paths' => $this->excludedAjaxPaths, 'token' => $response->headers->get('X-Debug-Token'), 'request' => $request, + 'csp_script_nonce' => isset($nonces['csp_script_nonce']) ? $nonces['csp_script_nonce'] : null, + 'csp_style_nonce' => isset($nonces['csp_style_nonce']) ? $nonces['csp_style_nonce'] : null, ) ))."\n"; $content = substr($content, 0, $pos).$toolbar.substr($content, $pos); diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.xml b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.xml index fe8b323bab..16d065aa7c 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.xml +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.xml @@ -11,6 +11,7 @@ %data_collector.templates% %web_profiler.debug_toolbar.position% + @@ -25,6 +26,12 @@ %kernel.debug% + + + + + + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml index f624cb323c..3f19d22c81 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml @@ -13,6 +13,7 @@ %web_profiler.debug_toolbar.position% + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/base_js.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/base_js.html.twig index 6a88048c0d..8a6c2e5677 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/base_js.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/base_js.html.twig @@ -1,4 +1,4 @@ - {% endblock %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.css.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.css.twig index 02c8b8a9fe..87827fa7ba 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.css.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.css.twig @@ -26,6 +26,15 @@ display: inline; } +.sf-toolbar-clearer { + clear: both; + height: 36px; +} + +.sf-display-none { + display: none; +} + .sf-toolbarreset * { -webkit-box-sizing: content-box; -moz-box-sizing: content-box; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.html.twig index 825631b1dd..e414bdec73 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.html.twig @@ -1,27 +1,14 @@ {% if 'normal' != position %} - -
+
{% endif %}
@@ -31,19 +18,15 @@ 'profiler_url': profiler_url, 'token': profile.token, 'name': name, - 'profiler_markup_version': profiler_markup_version + 'profiler_markup_version': profiler_markup_version, + 'csp_script_nonce': csp_script_nonce, + 'csp_style_nonce': csp_style_nonce }) }} {% endfor %} {% if 'normal' != position %} - + {{ include('@WebProfiler/Icon/close.svg') }} {% endif %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar_js.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar_js.html.twig index a82a59ecca..c6b65f3368 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar_js.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar_js.html.twig @@ -1,6 +1,6 @@ - +
{{ include('@WebProfiler/Profiler/base_js.html.twig') }} - diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php index 19443ed0db..f10e3503fd 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\WebProfilerBundle\Tests\Controller; use Symfony\Bundle\WebProfilerBundle\Controller\ProfilerController; +use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler; use Symfony\Component\HttpKernel\Profiler\Profile; use Symfony\Component\HttpFoundation\Request; @@ -44,17 +45,17 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase ); } - public function testReturns404onTokenNotFound() + /** + * @dataProvider provideCspVariants + */ + public function testReturns404onTokenNotFound($withCsp) { - $urlGenerator = $this->getMock('Symfony\Component\Routing\Generator\UrlGeneratorInterface'); $twig = $this->getMockBuilder('Twig_Environment')->disableOriginalConstructor()->getMock(); $profiler = $this ->getMockBuilder('Symfony\Component\HttpKernel\Profiler\Profiler') ->disableOriginalConstructor() ->getMock(); - $controller = new ProfilerController($urlGenerator, $profiler, $twig, array()); - $profiler ->expects($this->exactly(2)) ->method('loadProfile') @@ -65,6 +66,8 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase })) ; + $controller = $this->createController($profiler, $twig, $withCsp); + $response = $controller->toolbarAction(Request::create('/_wdt/found'), 'found'); $this->assertEquals(200, $response->getStatusCode()); @@ -72,16 +75,18 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase $this->assertEquals(404, $response->getStatusCode()); } - public function testSearchResult() + /** + * @dataProvider provideCspVariants + */ + public function testSearchResult($withCsp) { - $urlGenerator = $this->getMock('Symfony\Component\Routing\Generator\UrlGeneratorInterface'); $twig = $this->getMockBuilder('Twig_Environment')->disableOriginalConstructor()->getMock(); $profiler = $this ->getMockBuilder('Symfony\Component\HttpKernel\Profiler\Profiler') ->disableOriginalConstructor() ->getMock(); - $controller = new ProfilerController($urlGenerator, $profiler, $twig, array()); + $controller = $this->createController($profiler, $twig, $withCsp); $tokens = array( array( @@ -109,10 +114,10 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase ->will($this->returnValue($tokens)); $request = Request::create('/_profiler/empty/search/results', 'GET', array( - 'limit' => 2, - 'ip' => '127.0.0.1', - 'method' => 'GET', - 'url' => 'http://example.com/', + 'limit' => 2, + 'ip' => '127.0.0.1', + 'method' => 'GET', + 'url' => 'http://example.com/', )); $twig->expects($this->once()) @@ -135,4 +140,25 @@ class ProfilerControllerTest extends \PHPUnit_Framework_TestCase $response = $controller->searchResultsAction($request, 'empty'); $this->assertEquals(200, $response->getStatusCode()); } + + public function provideCspVariants() + { + return array( + array(true), + array(false), + ); + } + + private function createController($profiler, $twig, $withCSP) + { + $urlGenerator = $this->getMock('Symfony\Component\Routing\Generator\UrlGeneratorInterface'); + + if ($withCSP) { + $nonceGenerator = $this->getMock('Symfony\Bundle\WebProfilerBundle\Csp\NonceGenerator'); + + return new ProfilerController($urlGenerator, $profiler, $twig, array(), 'normal', new ContentSecurityPolicyHandler($nonceGenerator)); + } + + return new ProfilerController($urlGenerator, $profiler, $twig, array(), 'normal'); + } } diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/Csp/ContentSecurityPolicyHandlerTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/Csp/ContentSecurityPolicyHandlerTest.php new file mode 100644 index 0000000000..bfcfb80a8b --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/Csp/ContentSecurityPolicyHandlerTest.php @@ -0,0 +1,199 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\WebProfilerBundle\Tests\Csp; + +use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +class ContentSecurityPolicyHandlerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @dataProvider provideRequestAndResponses + */ + public function testGetNonces($nonce, $expectedNonce, Request $request, Response $response) + { + $cspHandler = new ContentSecurityPolicyHandler($this->mockNonceGenerator($nonce)); + + $this->assertSame($expectedNonce, $cspHandler->getNonces($request, $response)); + } + + /** + * @dataProvider provideRequestAndResponsesForOnKernelResponse + */ + public function testOnKernelResponse($nonce, $expectedNonce, Request $request, Response $response, array $expectedCsp) + { + $cspHandler = new ContentSecurityPolicyHandler($this->mockNonceGenerator($nonce)); + + $this->assertSame($expectedNonce, $cspHandler->updateResponseHeaders($request, $response)); + + $this->assertFalse($response->headers->has('X-SymfonyProfiler-Script-Nonce')); + $this->assertFalse($response->headers->has('X-SymfonyProfiler-Style-Nonce')); + + foreach ($expectedCsp as $header => $value) { + $this->assertSame($value, $response->headers->get($header)); + } + } + + public function provideRequestAndResponses() + { + $nonce = bin2hex(random_bytes(16)); + + $requestScriptNonce = 'request-with-headers-script-nonce'; + $requestStyleNonce = 'request-with-headers-style-nonce'; + + $responseScriptNonce = 'response-with-headers-script-nonce'; + $responseStyleNonce = 'response-with-headers-style-nonce'; + + $requestNonceHeaders = array( + 'X-SymfonyProfiler-Script-Nonce' => $requestScriptNonce, + 'X-SymfonyProfiler-Style-Nonce' => $requestStyleNonce, + ); + $responseNonceHeaders = array( + 'X-SymfonyProfiler-Script-Nonce' => $responseScriptNonce, + 'X-SymfonyProfiler-Style-Nonce' => $responseStyleNonce, + ); + + return array( + array($nonce, array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), $this->createRequest(), $this->createResponse()), + array($nonce, array('csp_script_nonce' => $requestScriptNonce, 'csp_style_nonce' => $requestStyleNonce), $this->createRequest($requestNonceHeaders), $this->createResponse($responseNonceHeaders)), + array($nonce, array('csp_script_nonce' => $requestScriptNonce, 'csp_style_nonce' => $requestStyleNonce), $this->createRequest($requestNonceHeaders), $this->createResponse()), + array($nonce, array('csp_script_nonce' => $responseScriptNonce, 'csp_style_nonce' => $responseStyleNonce), $this->createRequest(), $this->createResponse($responseNonceHeaders)), + ); + } + + public function provideRequestAndResponsesForOnKernelResponse() + { + $nonce = bin2hex(random_bytes(16)); + + $requestScriptNonce = 'request-with-headers-script-nonce'; + $requestStyleNonce = 'request-with-headers-style-nonce'; + + $responseScriptNonce = 'response-with-headers-script-nonce'; + $responseStyleNonce = 'response-with-headers-style-nonce'; + + $requestNonceHeaders = array( + 'X-SymfonyProfiler-Script-Nonce' => $requestScriptNonce, + 'X-SymfonyProfiler-Style-Nonce' => $requestStyleNonce, + ); + $responseNonceHeaders = array( + 'X-SymfonyProfiler-Script-Nonce' => $responseScriptNonce, + 'X-SymfonyProfiler-Style-Nonce' => $responseStyleNonce, + ); + + return array( + array( + $nonce, + array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), + $this->createRequest(), + $this->createResponse(), + array('Content-Security-Policy' => null, 'X-Content-Security-Policy' => null), + ), + array( + $nonce, array('csp_script_nonce' => $requestScriptNonce, 'csp_style_nonce' => $requestStyleNonce), + $this->createRequest($requestNonceHeaders), + $this->createResponse($responseNonceHeaders), + array('Content-Security-Policy' => null, 'X-Content-Security-Policy' => null), + ), + array( + $nonce, + array('csp_script_nonce' => $requestScriptNonce, 'csp_style_nonce' => $requestStyleNonce), + $this->createRequest($requestNonceHeaders), + $this->createResponse(), + array('Content-Security-Policy' => null, 'X-Content-Security-Policy' => null), + ), + array( + $nonce, + array('csp_script_nonce' => $responseScriptNonce, 'csp_style_nonce' => $responseStyleNonce), + $this->createRequest(), + $this->createResponse($responseNonceHeaders), + array('Content-Security-Policy' => null, 'X-Content-Security-Policy' => null), + ), + array( + $nonce, + array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), + $this->createRequest(), + $this->createResponse(array('Content-Security-Policy' => 'default-src \'self\' domain.com; script-src \'self\' \'unsafe-inline\'')), + array('Content-Security-Policy' => 'default-src \'self\' domain.com; script-src \'self\' \'unsafe-inline\'; style-src \'self\' domain.com \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'X-Content-Security-Policy' => null), + ), + array( + $nonce, + array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), + $this->createRequest(), + $this->createResponse(array('Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'')), + array('Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'; style-src \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'X-Content-Security-Policy' => null), + ), + array( + $nonce, + array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), + $this->createRequest(), + $this->createResponse(array('Content-Security-Policy' => 'script-src \'self\'; style-src \'self\'')), + array('Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'; style-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'X-Content-Security-Policy' => null), + ), + array( + $nonce, + array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), + $this->createRequest(), + $this->createResponse(array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'')), + array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'; style-src \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'Content-Security-Policy' => null), + ), + array( + $nonce, + array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), + $this->createRequest(), + $this->createResponse(array('X-Content-Security-Policy' => 'script-src \'self\'')), + array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'; style-src \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'Content-Security-Policy' => null), + ), + array( + $nonce, + array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), + $this->createRequest(), + $this->createResponse(array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'sha384-LALALALALAAL\'')), + array('X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'sha384-LALALALALAAL\' \'nonce-'.$nonce.'\'; style-src \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'Content-Security-Policy' => null), + ), + array( + $nonce, + array('csp_script_nonce' => $nonce, 'csp_style_nonce' => $nonce), + $this->createRequest(), + $this->createResponse(array('Content-Security-Policy' => 'script-src \'self\'; style-src \'self\'', 'X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'; style-src \'self\'')), + array('Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'; style-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\'', 'X-Content-Security-Policy' => 'script-src \'self\' \'unsafe-inline\'; style-src \'self\' \'unsafe-inline\' \'nonce-'.$nonce.'\''), + ), + ); + } + + private function createRequest(array $headers = array()) + { + $request = new Request(); + $request->headers->add($headers); + + return $request; + } + + private function createResponse(array $headers = array()) + { + $response = new Response(); + $response->headers->add($headers); + + return $response; + } + + private function mockNonceGenerator($value) + { + $generator = $this->getMock('Symfony\Bundle\WebProfilerBundle\Csp\NonceGenerator'); + + $generator->expects($this->any()) + ->method('generate') + ->will($this->returnValue($value)); + + return $generator; + } +} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php index e5d95471ae..e315cddad9 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\WebProfilerBundle\Tests\EventListener; use Symfony\Bundle\WebProfilerBundle\EventListener\WebDebugToolbarListener; +use Symfony\Component\HttpFoundation\HeaderBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; @@ -31,7 +32,7 @@ class WebDebugToolbarListenerTest extends \PHPUnit_Framework_TestCase $response = new Response($content); - $m->invoke($listener, $response, Request::create('/')); + $m->invoke($listener, $response, Request::create('/'), array('csp_script_nonce' => 'scripto', 'csp_style_nonce' => 'stylo')); $this->assertEquals($expected, $response->getContent()); } @@ -243,6 +244,8 @@ class WebDebugToolbarListenerTest extends \PHPUnit_Framework_TestCase ->method('getRequestFormat') ->will($this->returnValue($requestFormat)); + $request->headers = new HeaderBag(); + if ($hasSession) { $session = $this->getMock('Symfony\Component\HttpFoundation\Session\Session', array(), array(), '', false); $request->expects($this->any()) diff --git a/src/Symfony/Bundle/WebProfilerBundle/composer.json b/src/Symfony/Bundle/WebProfilerBundle/composer.json index a4ef40a541..64e5f6ed28 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/composer.json +++ b/src/Symfony/Bundle/WebProfilerBundle/composer.json @@ -18,6 +18,7 @@ "require": { "php": ">=5.5.9", "symfony/http-kernel": "~2.8|~3.0", + "symfony/polyfill-php70": "~1.0", "symfony/routing": "~2.8|~3.0", "symfony/twig-bridge": "~2.8|~3.0" },