From 0c61a7b0037c50e1478d70126fab2cb226c48db9 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 --- .../Controller/ProfilerController.php | 35 ++- .../Csp/ContentSecurityPolicyHandler.php | 253 ++++++++++++++++++ .../WebProfilerBundle/Csp/NonceGenerator.php | 25 ++ .../EventListener/WebDebugToolbarListener.php | 13 +- .../Resources/config/profiler.xml | 7 + .../Resources/config/toolbar.xml | 1 + .../views/Collector/exception.html.twig | 2 +- .../Resources/views/Collector/form.html.twig | 4 +- .../Resources/views/Collector/time.html.twig | 2 +- .../Resources/views/Profiler/base.html.twig | 2 +- .../views/Profiler/base_js.html.twig | 8 +- .../Resources/views/Profiler/layout.html.twig | 12 +- .../Resources/views/Profiler/toolbar.css.twig | 9 + .../views/Profiler/toolbar.html.twig | 31 +-- .../views/Profiler/toolbar_js.html.twig | 31 ++- .../Resources/views/Router/panel.html.twig | 2 +- .../Controller/ProfilerControllerTest.php | 61 +++-- .../Csp/ContentSecurityPolicyHandlerTest.php | 199 ++++++++++++++ .../WebDebugToolbarListenerTest.php | 5 +- .../Bundle/WebProfilerBundle/composer.json | 1 + 20 files changed, 634 insertions(+), 69 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/WebProfilerBundle/Controller/ProfilerController.php b/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php index 6d37caf4f11dc..158eebed72192 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; } /** @@ -103,7 +106,7 @@ public function panelAction(Request $request, $token) throw new NotFoundHttpException(sprintf('Panel "%s" is not available for token "%s".', $panel, $token)); } - return new Response($this->twig->render($this->getTemplateManager()->getName($profile, $panel), array( + return $this->renderWithCspNonces($request, $this->getTemplateManager()->getName($profile, $panel), array( 'token' => $token, 'profile' => $profile, 'collector' => $profile->getCollector($panel), @@ -113,7 +116,7 @@ public function panelAction(Request $request, $token) 'templates' => $this->getTemplateManager()->getTemplates($profile), 'is_ajax' => $request->isXmlHttpRequest(), 'profiler_markup_version' => 2, // 1 = original profiler, 2 = Symfony 2.8+ profiler - )), 200, array('Content-Type' => 'text/html')); + )); } /** @@ -155,10 +158,10 @@ public function infoAction(Request $request, $about) $this->profiler->disable(); - return new Response($this->twig->render('@WebProfiler/Profiler/info.html.twig', array( + return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/info.html.twig', array( 'about' => $about, 'request' => $request, - )), 200, array('Content-Type' => 'text/html')); + )); } /** @@ -206,7 +209,7 @@ public function toolbarAction(Request $request, $token) // 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, @@ -214,7 +217,7 @@ public function toolbarAction(Request $request, $token) 'profiler_url' => $url, 'token' => $token, 'profiler_markup_version' => 2, // 1 = original toolbar, 2 = Symfony 2.8+ toolbar - )), 200, array('Content-Type' => 'text/html')); + )); } /** @@ -295,7 +298,7 @@ public function searchResultsAction(Request $request, $token) $end = $request->query->get('end', null); $limit = $request->query->get('limit'); - return new Response($this->twig->render('@WebProfiler/Profiler/results.html.twig', array( + return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/results.html.twig', array( 'request' => $request, 'token' => $token, 'profile' => $profile, @@ -307,7 +310,7 @@ public function searchResultsAction(Request $request, $token) 'end' => $end, 'limit' => $limit, 'panel' => null, - )), 200, array('Content-Type' => 'text/html')); + )); } /** @@ -397,4 +400,18 @@ protected function getTemplateManager() 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 0000000000000..59a76f01437d1 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php @@ -0,0 +1,253 @@ + + * + * 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 + */ +class ContentSecurityPolicyHandler +{ + private $nonceGenerator; + + public function __construct(NonceGenerator $nonceGenerator) + { + return $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 + * + * @param Request $request + * @param Response $response + * + * @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; + } + + /** + * Cleanup temporary headers and updates Content-Security-Policy headers. + * + * This method should be called on KernelEvents::RESPONSE + * + * @param Request $request + * @param Response $response + * + * @return array Nonce used by the bundle in Content-Security-Policy header + */ + public function onKernelResponse(Request $request, Response $response) + { + $nonces = $this->getNonces($request, $response); + $this->cleanHeaders($response); + $this->updateCspHeaders($response, $nonces); + + return $nonces; + } + + /** + * @param Response $response + */ + private function cleanHeaders(Response $response) + { + $response->headers->remove('X-SymfonyProfiler-Script-Nonce'); + $response->headers->remove('X-SymfonyProfiler-Style-Nonce'); + } + + /** + * Updates Content-Security-Policy headers in a response. + * + * @param Response $response + * @param array $nonces + * + * @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)) { + 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 directives set array into Content-Security-Policy header. + * + * @param array $directives The directives 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 directives set array. + * + * @param string $header The header value + * + * @return array The directives 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 directives set. + * + * @param array $directivesSet The directives 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. + * + * @param Response $response The 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 0000000000000..2950a4b1a69d3 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php @@ -0,0 +1,25 @@ + + * + * 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 + */ +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 71c5090fc8a53..c2c38e5c022a0 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 @@ public function __construct(\Twig_Environment $twig, $interceptRedirects = false $this->mode = (int) $mode; $this->position = $position; $this->excludedAjaxPaths = $excludedAjaxPaths; + $this->cspHandler = $cspHandler; } public function isEnabled() @@ -76,6 +79,8 @@ public function onKernelResponse(FilterResponseEvent $event) return; } + $nonces = $this->cspHandler ? $this->cspHandler->onKernelResponse($request, $response) : array(); + // do not capture redirects or modify XML HTTP Requests if ($request->isXmlHttpRequest()) { return; @@ -102,7 +107,7 @@ public function onKernelResponse(FilterResponseEvent $event) return; } - $this->injectToolbar($response, $request); + $this->injectToolbar($response, $request, $nonces); } /** @@ -110,7 +115,7 @@ public function onKernelResponse(FilterResponseEvent $event) * * @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 @@ protected function injectToolbar(Response $response, Request $request) '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 ed7e923f0d05d..425c037b573ba 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.xml +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/profiler.xml @@ -18,6 +18,7 @@ %data_collector.templates% %web_profiler.debug_toolbar.position% + @@ -32,6 +33,12 @@ %kernel.debug% + + + + + + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml index d72c28532c334..477e8c0b78c96 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml @@ -17,6 +17,7 @@ %web_profiler.debug_toolbar.position% + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/exception.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/exception.html.twig index 94dfbb6acac0a..d54b8f0d77bd9 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/exception.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/exception.html.twig @@ -2,7 +2,7 @@ {% block head %} {% if collector.hasexception %} - {% endif %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/form.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/form.html.twig index eb87a8fa8abb1..bd5a4930d89e4 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/form.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/form.html.twig @@ -42,7 +42,7 @@ {% block head %} {{ parent() }} - {% endblock %} 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 408e5c6bdcbf3..831dd032a6879 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 d87e81399d412..e72786bd5ac51 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 825631b1dd871..c47f081da6fe7 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 a82a59ecca54f..1b9c9171e6a79 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/Resources/views/Router/panel.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Router/panel.html.twig index 8a6929c4df916..16487d3fcbd36 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Router/panel.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Router/panel.html.twig @@ -30,7 +30,7 @@

Route Redirection

This page redirects to:

-
+
{{ router.targetUrl }} {% if router.targetRoute %}(route: "{{ router.targetRoute }}"){% endif %}
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/Controller/ProfilerControllerTest.php index a6b9d3b340246..30d682f9123b6 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,11 @@ public function getEmptyTokenCases() ); } - public function testReturns404onTokenNotFound() + /** + * @dataProvider provideController + */ + public function testReturns404onTokenNotFound($controller, $profiler, $twig) { - $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') @@ -72,17 +67,11 @@ public function testReturns404onTokenNotFound() $this->assertEquals(404, $response->getStatusCode()); } - public function testSearchResult() + /** + * @dataProvider provideController + */ + public function testSearchResult($controller, $profiler, $twig, $nonceGenerator = null) { - $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()); - $tokens = array( array( 'token' => 'token1', @@ -115,6 +104,12 @@ public function testSearchResult() 'url' => 'http://example.com/', )); + if ($nonceGenerator) { + $nonceGenerator->expects($this->exactly(2)) + ->method('generate') + ->will($this->returnValue('123456')); + } + $twig->expects($this->once()) ->method('render') ->with($this->stringEndsWith('results.html.twig'), $this->equalTo(array( @@ -129,9 +124,35 @@ public function testSearchResult() 'limit' => 2, 'panel' => null, 'request' => $request, + 'csp_script_nonce' => $nonceGenerator ? '123456' : null, + 'csp_style_nonce' => $nonceGenerator ? '123456' : null, ))); $response = $controller->searchResultsAction($request, 'empty'); $this->assertEquals(200, $response->getStatusCode()); } + + public function provideController() + { + $urlGenerator1 = $this->getMock('Symfony\Component\Routing\Generator\UrlGeneratorInterface'); + $twig1 = $this->getMockBuilder('Twig_Environment')->disableOriginalConstructor()->getMock(); + $profiler1 = $this + ->getMockBuilder('Symfony\Component\HttpKernel\Profiler\Profiler') + ->disableOriginalConstructor() + ->getMock(); + + $urlGenerator2 = $this->getMock('Symfony\Component\Routing\Generator\UrlGeneratorInterface'); + $twig2 = $this->getMockBuilder('Twig_Environment')->disableOriginalConstructor()->getMock(); + $profiler2 = $this + ->getMockBuilder('Symfony\Component\HttpKernel\Profiler\Profiler') + ->disableOriginalConstructor() + ->getMock(); + + $nonceGenerator = $this->getMock('Symfony\Bundle\WebProfilerBundle\Csp\NonceGenerator'); + + return array( + array(new ProfilerController($urlGenerator1, $profiler1, $twig1, array(), 'normal'), $profiler1, $twig1, null), + array(new ProfilerController($urlGenerator2, $profiler2, $twig2, array(), 'normal', new ContentSecurityPolicyHandler($nonceGenerator)), $profiler2, $twig2, $nonceGenerator), + ); + } } 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 0000000000000..5594b52ed895f --- /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->onKernelResponse($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 446aefb793e89..496a8b42f1af4 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; @@ -30,7 +31,7 @@ public function testInjectToolbar($content, $expected) $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()); } @@ -242,6 +243,8 @@ protected function getRequestMock($isXmlHttpRequest = false, $requestFormat = 'h ->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 edea43b002b26..0ea0ee6873693 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/composer.json +++ b/src/Symfony/Bundle/WebProfilerBundle/composer.json @@ -18,6 +18,7 @@ "require": { "php": ">=5.3.9", "symfony/http-kernel": "~2.4|~3.0.0", + "symfony/polyfill-php70": "~1.0", "symfony/routing": "~2.2|~3.0.0", "symfony/twig-bridge": "~2.7|~3.0.0" },