Skip to content

Commit

Permalink
feature #18568 [3.2][WebProfilerBundle] Fix bundle usage in Content-S…
Browse files Browse the repository at this point in the history
…ecurity-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
  • Loading branch information
fabpot committed Jun 9, 2016
2 parents ce28a86 + 571a1f2 commit 856c9f6
Show file tree
Hide file tree
Showing 16 changed files with 650 additions and 48 deletions.
Expand Up @@ -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 }) }}
Expand Down
Expand Up @@ -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;
Expand All @@ -33,6 +34,7 @@ class ProfilerController
private $twig;
private $templates;
private $toolbarPosition;
private $cspHandler;

/**
* Constructor.
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -185,15 +196,15 @@ 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,
'templates' => $this->getTemplateManager()->getTemplates($profile),
'profiler_url' => $url,
'token' => $token,
'profiler_markup_version' => 2, // 1 = original toolbar, 2 = Symfony 2.8+ toolbar
)), 200, array('Content-Type' => 'text/html'));
));
}

/**
Expand All @@ -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 =
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -364,6 +383,10 @@ public function phpinfoAction()

$this->profiler->disable();

if (null !== $this->cspHandler) {
$this->cspHandler->disableCsp();
}

ob_start();
phpinfo();
$phpinfo = ob_get_clean();
Expand All @@ -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;
}
}
@@ -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;
}
}

0 comments on commit 856c9f6

Please sign in to comment.