feature #18568 [3.2][WebProfilerBundle] Fix bundle usage in Content-Security-Policy context without unsafe-inline (romainneutron)

This PR was merged into the 3.2-dev branch.

Discussion
----------

[3.2][WebProfilerBundle] Fix bundle usage in Content-Security-Policy context without unsafe-inline

| Q             | A
| ------------- | ---
| Branch?       | 3.2
| Bug fix?      | yes
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #15397
| License       | MIT
| Doc PR        | N/A

Hello, this PR fixes the compatibility of the WebprofilerBundle in a context where Content-Security-Policy headers are could prevent `unsafe-inline` of `script-src` or `style-src` directives.

This PR has been originally proposed in 2.8 in #18434

Commits
-------

571a1f2 [WebProfilerBundle] Fix bundle usage in Content-Security-Policy context without unsafe-inline
This commit is contained in:
Fabien Potencier 2016-06-09 13:18:21 +02:00
commit 856c9f6024
16 changed files with 650 additions and 48 deletions

View File

@ -27,7 +27,6 @@
{{ dump.data|raw }}
</div>
{% endfor %}
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" onload="var h = this.parentNode.innerHTML, rx=/<script>(.*?)<\/script>/g, s; while (s = rx.exec(h)) {eval(s[1]);};" />
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }}

View File

@ -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;
}
}

View File

@ -0,0 +1,265 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <imprec@gmail.com>
*
* @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;
}
}

View File

@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <imprec@gmail.com>
*
* @internal
*/
class NonceGenerator
{
public function generate()
{
return bin2hex(random_bytes(16));
}
}

View File

@ -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, '</body>');
@ -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);

View File

@ -11,6 +11,7 @@
<argument type="service" id="twig" />
<argument>%data_collector.templates%</argument>
<argument>%web_profiler.debug_toolbar.position%</argument>
<argument type="service" id="web_profiler.csp.handler" />
</service>
<service id="web_profiler.controller.router" class="Symfony\Bundle\WebProfilerBundle\Controller\RouterController">
@ -25,6 +26,12 @@
<argument>%kernel.debug%</argument>
</service>
<service id="web_profiler.csp.handler" class="Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler" public="false">
<argument type="service">
<service class="Symfony\Bundle\WebProfilerBundle\Csp\NonceGenerator" />
</argument>
</service>
<service id="twig.extension.webprofiler" class="Symfony\Bundle\WebProfilerBundle\Twig\WebProfilerExtension" public="false">
<tag name="twig.extension" />
</service>

View File

@ -13,6 +13,7 @@
<argument>%web_profiler.debug_toolbar.position%</argument>
<argument type="service" id="router" on-invalid="ignore" />
<argument /> <!-- paths that should be excluded from the AJAX requests shown in the toolbar -->
<argument type="service" id="web_profiler.csp.handler" />
</service>
</services>
</container>

View File

@ -1,4 +1,4 @@
<script>/*<![CDATA[*/
<script{% if csp_script_nonce is defined and csp_script_nonce %} nonce={{ csp_script_nonce }}{% endif %}>/*<![CDATA[*/
{# Caution: the contents of this file are processed by Twig before loading
them as JavaScript source code. Always use '/*' comments instead
of '//' comments to avoid impossible-to-debug side-effects #}

View File

@ -94,7 +94,7 @@
<div id="sidebar">
<div id="sidebar-shortcuts">
<div class="shortcuts">
<a href="#" class="visible-small" onclick="Sfjs.toggleClass(document.getElementById('sidebar'), 'expanded'); return false;">
<a href="#" id="sidebarShortcutsMenu" class="visible-small">
<span class="icon">{{ include('@WebProfiler/Icon/menu.svg') }}</span>
</a>
@ -124,4 +124,12 @@
</div>
</div>
</div>
<script>
(function () {
Sfjs.addEventListener(document.getElementById('sidebarShortcutsMenu'), 'click', function (event) {
event.preventDefault();
Sfjs.toggleClass(document.getElementById('sidebar'), 'expanded');
})
}())
</script>
{% endblock %}

View File

@ -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;

View File

@ -1,27 +1,14 @@
<!-- START of Symfony Web Debug Toolbar -->
{% if 'normal' != position %}
<div id="sfMiniToolbar-{{ token }}" class="sf-minitoolbar" data-no-turbolink>
<a href="javascript:void(0);" title="Show Symfony toolbar" tabindex="-1" accesskey="D" onclick="
var elem = this.parentNode;
if (elem.style.display == 'none') {
document.getElementById('sfToolbarMainContent-{{ token }}').style.display = 'none';
document.getElementById('sfToolbarClearer-{{ token }}').style.display = 'none';
elem.style.display = 'block';
} else {
document.getElementById('sfToolbarMainContent-{{ token }}').style.display = 'block';
document.getElementById('sfToolbarClearer-{{ token }}').style.display = 'block';
elem.style.display = 'none'
}
Sfjs.setPreference('toolbar/displayState', 'block');
">
<a href="#" title="Show Symfony toolbar" tabindex="-1" id="sfToolbarMiniToggler-{{ token }}" accesskey="D">
{{ include('@WebProfiler/Icon/symfony.svg') }}
</a>
</div>
<style>
<style{% if csp_style_nonce %} nonce="{{ csp_style_nonce }}"{% endif %}>
{{ include('@WebProfiler/Profiler/toolbar.css.twig', { 'position': position, 'floatable': true }) }}
</style>
<div id="sfToolbarClearer-{{ token }}" style="clear: both; height: 36px;"></div>
<div id="sfToolbarClearer-{{ token }}" class="sf-toolbar-clearer"></div>
{% endif %}
<div id="sfToolbarMainContent-{{ token }}" class="sf-toolbarreset clear-fix" data-no-turbolink>
@ -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 %}
<a class="hide-button" title="Close Toolbar" tabindex="-1" accesskey="D" onclick="
var p = this.parentNode;
p.style.display = 'none';
(p.previousElementSibling || p.previousSibling).style.display = 'none';
document.getElementById('sfMiniToolbar-{{ token }}').style.display = 'block';
Sfjs.setPreference('toolbar/displayState', 'none');
">
<a class="hide-button" id="sfToolbarHideButton-{{ token }}" title="Close Toolbar" tabindex="-1" accesskey="D">
{{ include('@WebProfiler/Icon/close.svg') }}
</a>
{% endif %}

View File

@ -1,6 +1,6 @@
<div id="sfwdt{{ token }}" class="sf-toolbar" style="display: none"></div>
<div id="sfwdt{{ token }}" class="sf-toolbar sf-display-none"></div>
{{ include('@WebProfiler/Profiler/base_js.html.twig') }}
<script>/*<![CDATA[*/
<script{% if csp_script_nonce %} nonce={{ csp_script_nonce }}{% endif %}>/*<![CDATA[*/
(function () {
{% if 'top' == position %}
var sfwdt = document.getElementById('sfwdt{{ token }}');
@ -14,6 +14,11 @@
'sfwdt{{ token }}',
'{{ path("_wdt", { "token": token }) }}',
function(xhr, el) {
/* Evaluate embedded scripts inside the toolbar */
var rx=/<script>(.*?)<\/script>/g, s;
while (s = rx.exec(el.innerHTML)) {eval(s[1]);};
el.style.display = -1 !== xhr.responseText.indexOf('sf-toolbarreset') ? 'block' : 'none';
if (el.style.display == 'none') {
@ -58,13 +63,38 @@
}
};
}
Sfjs.addEventListener(document.getElementById('sfToolbarHideButton-{{ token }}'), 'click', function () {
event.preventDefault();
var p = this.parentNode;
p.style.display = 'none';
(p.previousElementSibling || p.previousSibling).style.display = 'none';
document.getElementById('sfMiniToolbar-{{ token }}').style.display = 'block';
Sfjs.setPreference('toolbar/displayState', 'none');
});
Sfjs.addEventListener(document.getElementById('sfToolbarMiniToggler-{{ token }}'), 'click', function (event) {
event.preventDefault();
var elem = this.parentNode;
if (elem.style.display == 'none') {
document.getElementById('sfToolbarMainContent-{{ token }}').style.display = 'none';
document.getElementById('sfToolbarClearer-{{ token }}').style.display = 'none';
elem.style.display = 'block';
} else {
document.getElementById('sfToolbarMainContent-{{ token }}').style.display = 'block';
document.getElementById('sfToolbarClearer-{{ token }}').style.display = 'block';
elem.style.display = 'none'
}
Sfjs.setPreference('toolbar/displayState', 'block');
})
},
function(xhr) {
if (xhr.status !== 0) {
confirm('An error occurred while loading the web debug toolbar (' + xhr.status + ': ' + xhr.statusText + ').\n\nDo you want to open the profiler?') && (window.location = '{{ path("_profiler", { "token": token }) }}');
}
},
{'maxTries': 5}
{ maxTries: 5 }
);
})();
/*]]>*/</script>

View File

@ -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');
}
}

View File

@ -0,0 +1,199 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@ -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())

View File

@ -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"
},