Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature #18568 [3.2][WebProfilerBundle] Fix bundle usage in Content-S…
…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
Showing
16 changed files
with
650 additions
and
48 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
265 changes: 265 additions & 0 deletions
265
src/Symfony/Bundle/WebProfilerBundle/Csp/ContentSecurityPolicyHandler.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
Oops, something went wrong.