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 1163d283d0207..0ece492346b06 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 b6744c585579e..75e6a272ed313 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 @@ public function panelAction(Request $request, $token) $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 @@ public function infoAction(Request $request, $about) $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 @@ 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, @@ -193,7 +204,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')); + )); } /** @@ -213,6 +224,10 @@ public function searchBarAction(Request $request) $this->profiler->disable(); + if (null !== $this->cspHandler) { + $this->cspHandler->disableCsp(); + } + if (null === $session = $request->getSession()) { $ip = $method = @@ -268,6 +283,10 @@ public function searchResultsAction(Request $request, $token) $this->profiler->disable(); + if (null !== $this->cspHandler) { + $this->cspHandler->disableCsp(); + } + $profile = $this->profiler->loadProfile($token); $ip = $request->query->get('ip'); @@ -364,6 +383,10 @@ public function phpinfoAction() $this->profiler->disable(); + if (null !== $this->cspHandler) { + $this->cspHandler->disableCsp(); + } + ob_start(); phpinfo(); $phpinfo = ob_get_clean(); @@ -384,4 +407,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..195f3f18ff25d --- /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 0000000000000..728043551f3ee --- /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 ee537af4f9889..ebc1037d359ee 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->updateResponseHeaders($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 fe8b323babedd..16d065aa7c02f 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 f624cb323c48a..3f19d22c8136e 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 6a88048c0db94..8a6c2e5677666 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 02c8b8a9fe4ec..87827fa7ba903 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..e414bdec73dae 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..c6b65f336865f 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 19443ed0db467..f10e3503fd9f1 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 @@ public function getEmptyTokenCases() ); } - 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 @@ public function testReturns404onTokenNotFound() })) ; + $controller = $this->createController($profiler, $twig, $withCsp); + $response = $controller->toolbarAction(Request::create('/_wdt/found'), 'found'); $this->assertEquals(200, $response->getStatusCode()); @@ -72,16 +75,18 @@ public function testReturns404onTokenNotFound() $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 @@ public function testSearchResult() ->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 @@ public function testSearchResult() $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 0000000000000..bfcfb80a8bfb0 --- /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 e5d95471ae09f..e315cddad9959 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 @@ 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()); } @@ -243,6 +244,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 a4ef40a541380..64e5f6ed28249 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" }, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy