diff --git a/src/Symfony/Bundle/DebugBundle/Resources/views/Profiler/dump.html.twig b/src/Symfony/Bundle/DebugBundle/Resources/views/Profiler/dump.html.twig
index 1163d283d0..0ece492346 100644
--- a/src/Symfony/Bundle/DebugBundle/Resources/views/Profiler/dump.html.twig
+++ b/src/Symfony/Bundle/DebugBundle/Resources/views/Profiler/dump.html.twig
@@ -27,7 +27,6 @@
{{ dump.data|raw }}
{% endfor %}
-
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }}
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php b/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php
index b6744c5855..75e6a272ed 100644
--- a/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php
+++ b/src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php
@@ -11,6 +11,7 @@
namespace Symfony\Bundle\WebProfilerBundle\Controller;
+use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler;
use Symfony\Bundle\WebProfilerBundle\Profiler\TemplateManager;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -33,6 +34,7 @@ class ProfilerController
private $twig;
private $templates;
private $toolbarPosition;
+ private $cspHandler;
/**
* Constructor.
@@ -43,13 +45,14 @@ class ProfilerController
* @param array $templates The templates
* @param string $toolbarPosition The toolbar position (top, bottom, normal, or null -- use the configuration)
*/
- public function __construct(UrlGeneratorInterface $generator, Profiler $profiler = null, \Twig_Environment $twig, array $templates, $toolbarPosition = 'normal')
+ public function __construct(UrlGeneratorInterface $generator, Profiler $profiler = null, \Twig_Environment $twig, array $templates, $toolbarPosition = 'normal', ContentSecurityPolicyHandler $cspHandler = null)
{
$this->generator = $generator;
$this->profiler = $profiler;
$this->twig = $twig;
$this->templates = $templates;
$this->toolbarPosition = $toolbarPosition;
+ $this->cspHandler = $cspHandler;
}
/**
@@ -88,6 +91,10 @@ class ProfilerController
$this->profiler->disable();
+ if (null !== $this->cspHandler) {
+ $this->cspHandler->disableCsp();
+ }
+
$panel = $request->query->get('panel', 'request');
$page = $request->query->get('page', 'home');
@@ -134,6 +141,10 @@ class ProfilerController
$this->profiler->disable();
+ if (null !== $this->cspHandler) {
+ $this->cspHandler->disableCsp();
+ }
+
return new Response($this->twig->render('@WebProfiler/Profiler/info.html.twig', array(
'about' => $about,
'request' => $request,
@@ -185,7 +196,7 @@ class ProfilerController
// the profiler is not enabled
}
- return new Response($this->twig->render('@WebProfiler/Profiler/toolbar.html.twig', array(
+ return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/toolbar.html.twig', array(
'request' => $request,
'position' => $position,
'profile' => $profile,
@@ -193,7 +204,7 @@ class ProfilerController
'profiler_url' => $url,
'token' => $token,
'profiler_markup_version' => 2, // 1 = original toolbar, 2 = Symfony 2.8+ toolbar
- )), 200, array('Content-Type' => 'text/html'));
+ ));
}
/**
@@ -213,6 +224,10 @@ class ProfilerController
$this->profiler->disable();
+ if (null !== $this->cspHandler) {
+ $this->cspHandler->disableCsp();
+ }
+
if (null === $session = $request->getSession()) {
$ip =
$method =
@@ -268,6 +283,10 @@ class ProfilerController
$this->profiler->disable();
+ if (null !== $this->cspHandler) {
+ $this->cspHandler->disableCsp();
+ }
+
$profile = $this->profiler->loadProfile($token);
$ip = $request->query->get('ip');
@@ -364,6 +383,10 @@ class ProfilerController
$this->profiler->disable();
+ if (null !== $this->cspHandler) {
+ $this->cspHandler->disableCsp();
+ }
+
ob_start();
phpinfo();
$phpinfo = ob_get_clean();
@@ -384,4 +407,18 @@ class ProfilerController
return $this->templateManager;
}
+
+ private function renderWithCspNonces(Request $request, $template, $variables, $code = 200, $headers = array('Content-Type' => 'text/html'))
+ {
+ $response = new Response('', $code, $headers);
+
+ $nonces = $this->cspHandler ? $this->cspHandler->getNonces($request, $response) : array();
+
+ $variables['csp_script_nonce'] = isset($nonces['csp_script_nonce']) ? $nonces['csp_script_nonce'] : null;
+ $variables['csp_style_nonce'] = isset($nonces['csp_style_nonce']) ? $nonces['csp_style_nonce'] : null;
+
+ $response->setContent($this->twig->render($template, $variables));
+
+ return $response;
+ }
}
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php b/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php
new file mode 100644
index 0000000000..195f3f18ff
--- /dev/null
+++ b/src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php
@@ -0,0 +1,265 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\WebProfilerBundle\Csp;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Handles Content-Security-Policy HTTP header for the WebProfiler Bundle.
+ *
+ * @author Romain Neutron
+ *
+ * @internal
+ */
+class ContentSecurityPolicyHandler
+{
+ private $nonceGenerator;
+ private $cspDisabled = false;
+
+ public function __construct(NonceGenerator $nonceGenerator)
+ {
+ $this->nonceGenerator = $nonceGenerator;
+ }
+
+ /**
+ * Returns an array of nonces to be used in Twig templates and Content-Security-Policy headers.
+ *
+ * Nonce can be provided by;
+ * - The request - In case HTML content is fetched via AJAX and inserted in DOM, it must use the same nonce as origin
+ * - The response - A call to getNonces() has already been done previously. Same nonce are returned
+ * - They are otherwise randomly generated
+ *
+ * @return array
+ */
+ public function getNonces(Request $request, Response $response)
+ {
+ if ($request->headers->has('X-SymfonyProfiler-Script-Nonce') && $request->headers->has('X-SymfonyProfiler-Style-Nonce')) {
+ return array(
+ 'csp_script_nonce' => $request->headers->get('X-SymfonyProfiler-Script-Nonce'),
+ 'csp_style_nonce' => $request->headers->get('X-SymfonyProfiler-Style-Nonce'),
+ );
+ }
+
+ if ($response->headers->has('X-SymfonyProfiler-Script-Nonce') && $response->headers->has('X-SymfonyProfiler-Style-Nonce')) {
+ return array(
+ 'csp_script_nonce' => $response->headers->get('X-SymfonyProfiler-Script-Nonce'),
+ 'csp_style_nonce' => $response->headers->get('X-SymfonyProfiler-Style-Nonce'),
+ );
+ }
+
+ $nonces = array(
+ 'csp_script_nonce' => $this->generateNonce(),
+ 'csp_style_nonce' => $this->generateNonce(),
+ );
+
+ $response->headers->set('X-SymfonyProfiler-Script-Nonce', $nonces['csp_script_nonce']);
+ $response->headers->set('X-SymfonyProfiler-Style-Nonce', $nonces['csp_style_nonce']);
+
+ return $nonces;
+ }
+
+ /**
+ * Disables Content-Security-Policy.
+ *
+ * All related headers will be removed.
+ */
+ public function disableCsp()
+ {
+ $this->cspDisabled = true;
+ }
+
+ /**
+ * Cleanup temporary headers and updates Content-Security-Policy headers.
+ *
+ * @return array Nonces used by the bundle in Content-Security-Policy header
+ */
+ public function updateResponseHeaders(Request $request, Response $response)
+ {
+ if ($this->cspDisabled) {
+ $this->removeCspHeaders($response);
+
+ return array();
+ }
+
+ $nonces = $this->getNonces($request, $response);
+ $this->cleanHeaders($response);
+ $this->updateCspHeaders($response, $nonces);
+
+ return $nonces;
+ }
+
+ private function cleanHeaders(Response $response)
+ {
+ $response->headers->remove('X-SymfonyProfiler-Script-Nonce');
+ $response->headers->remove('X-SymfonyProfiler-Style-Nonce');
+ }
+
+ private function removeCspHeaders(Response $response)
+ {
+ $response->headers->remove('X-Content-Security-Policy');
+ $response->headers->remove('Content-Security-Policy');
+ }
+
+ /**
+ * Updates Content-Security-Policy headers in a response.
+ *
+ * @return array
+ */
+ private function updateCspHeaders(Response $response, array $nonces = array())
+ {
+ $nonces = array_replace(array(
+ 'csp_script_nonce' => $this->generateNonce(),
+ 'csp_style_nonce' => $this->generateNonce(),
+ ), $nonces);
+
+ $ruleIsSet = false;
+
+ $headers = $this->getCspHeaders($response);
+
+ foreach ($headers as $header => $directives) {
+ foreach (array('script-src' => 'csp_script_nonce', 'style-src' => 'csp_style_nonce') as $type => $tokenName) {
+ if ($this->authorizesInline($directives, $type)) {
+ continue;
+ }
+ if (!isset($headers[$header][$type])) {
+ if (isset($headers[$header]['default-src'])) {
+ $headers[$header][$type] = $headers[$header]['default-src'];
+ } else {
+ $headers[$header][$type] = array();
+ }
+ }
+ $ruleIsSet = true;
+ if (!in_array('\'unsafe-inline\'', $headers[$header][$type], true)) {
+ $headers[$header][$type][] = '\'unsafe-inline\'';
+ }
+ $headers[$header][$type][] = sprintf('\'nonce-%s\'', $nonces[$tokenName]);
+ }
+ }
+
+ if (!$ruleIsSet) {
+ return $nonces;
+ }
+
+ foreach ($headers as $header => $directives) {
+ $response->headers->set($header, $this->generateCspHeader($directives));
+ }
+
+ return $nonces;
+ }
+
+ /**
+ * Generates a valid Content-Security-Policy nonce.
+ *
+ * @return string
+ */
+ private function generateNonce()
+ {
+ return $this->nonceGenerator->generate();
+ }
+
+ /**
+ * Converts a directive set array into Content-Security-Policy header.
+ *
+ * @param array $directives The directive set
+ *
+ * @return string The Content-Security-Policy header
+ */
+ private function generateCspHeader(array $directives)
+ {
+ return array_reduce(array_keys($directives), function ($res, $name) use ($directives) {
+ return ($res !== '' ? $res.'; ' : '').sprintf('%s %s', $name, implode(' ', $directives[$name]));
+ }, '');
+ }
+
+ /**
+ * Converts a Content-Security-Policy header value into a directive set array.
+ *
+ * @param string $header The header value
+ *
+ * @return array The directive set
+ */
+ private function parseDirectives($header)
+ {
+ $directives = array();
+
+ foreach (explode(';', $header) as $directive) {
+ $parts = explode(' ', trim($directive));
+ if (count($parts) < 1) {
+ continue;
+ }
+ $name = array_shift($parts);
+ $directives[$name] = $parts;
+ }
+
+ return $directives;
+ }
+
+ /**
+ * Detects if the 'unsafe-inline' is prevented for a directive within the directive set.
+ *
+ * @param array $directivesSet The directive set
+ * @param string $type The name of the directive to check
+ *
+ * @return bool
+ */
+ private function authorizesInline(array $directivesSet, $type)
+ {
+ if (isset($directivesSet[$type])) {
+ $directives = $directivesSet[$type];
+ } elseif (isset($directivesSet['default-src'])) {
+ $directives = $directivesSet['default-src'];
+ } else {
+ return false;
+ }
+
+ return in_array('\'unsafe-inline\'', $directives, true) && !$this->hasHashOrNonce($directives);
+ }
+
+ private function hasHashOrNonce(array $directives)
+ {
+ foreach ($directives as $directive) {
+ if ('\'' !== substr($directive, -1)) {
+ continue;
+ }
+ if ('\'nonce-' === substr($directive, 0, 7)) {
+ return true;
+ }
+ if (in_array(substr($directive, 0, 8), array('\'sha256-', '\'sha384-', '\'sha512-'), true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Retrieves the Content-Security-Policy headers (either X-Content-Security-Policy or Content-Security-Policy) from
+ * a response.
+ *
+ * @return array An associative array of headers
+ */
+ private function getCspHeaders(Response $response)
+ {
+ $headers = array();
+
+ if ($response->headers->has('Content-Security-Policy')) {
+ $headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy'));
+ }
+
+ if ($response->headers->has('X-Content-Security-Policy')) {
+ $headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy'));
+ }
+
+ return $headers;
+ }
+}
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php b/src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php
new file mode 100644
index 0000000000..728043551f
--- /dev/null
+++ b/src/Symfony/Bundle/WebProfilerBundle/Csp/NonceGenerator.php
@@ -0,0 +1,27 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bundle\WebProfilerBundle\Csp;
+
+/**
+ * Generates Content-Security-Policy nonce.
+ *
+ * @author Romain Neutron
+ *
+ * @internal
+ */
+class NonceGenerator
+{
+ public function generate()
+ {
+ return bin2hex(random_bytes(16));
+ }
+}
diff --git a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php
index ee537af4f9..ebc1037d35 100644
--- a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php
+++ b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php
@@ -11,6 +11,7 @@
namespace Symfony\Bundle\WebProfilerBundle\EventListener;
+use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Flash\AutoExpireFlashBag;
@@ -40,8 +41,9 @@ class WebDebugToolbarListener implements EventSubscriberInterface
protected $mode;
protected $position;
protected $excludedAjaxPaths;
+ private $cspHandler;
- public function __construct(\Twig_Environment $twig, $interceptRedirects = false, $mode = self::ENABLED, $position = 'bottom', UrlGeneratorInterface $urlGenerator = null, $excludedAjaxPaths = '^/bundles|^/_wdt')
+ public function __construct(\Twig_Environment $twig, $interceptRedirects = false, $mode = self::ENABLED, $position = 'bottom', UrlGeneratorInterface $urlGenerator = null, $excludedAjaxPaths = '^/bundles|^/_wdt', ContentSecurityPolicyHandler $cspHandler = null)
{
$this->twig = $twig;
$this->urlGenerator = $urlGenerator;
@@ -49,6 +51,7 @@ class WebDebugToolbarListener implements EventSubscriberInterface
$this->mode = (int) $mode;
$this->position = $position;
$this->excludedAjaxPaths = $excludedAjaxPaths;
+ $this->cspHandler = $cspHandler;
}
public function isEnabled()
@@ -76,6 +79,8 @@ class WebDebugToolbarListener implements EventSubscriberInterface
return;
}
+ $nonces = $this->cspHandler ? $this->cspHandler->updateResponseHeaders($request, $response) : array();
+
// do not capture redirects or modify XML HTTP Requests
if ($request->isXmlHttpRequest()) {
return;
@@ -102,7 +107,7 @@ class WebDebugToolbarListener implements EventSubscriberInterface
return;
}
- $this->injectToolbar($response, $request);
+ $this->injectToolbar($response, $request, $nonces);
}
/**
@@ -110,7 +115,7 @@ class WebDebugToolbarListener implements EventSubscriberInterface
*
* @param Response $response A Response instance
*/
- protected function injectToolbar(Response $response, Request $request)
+ protected function injectToolbar(Response $response, Request $request, array $nonces)
{
$content = $response->getContent();
$pos = strripos($content, '