Skip to content

Commit e305560

Browse files
committed
[WebProfilerBundle] Fix bundle usage in Content-Security-Policy context
1 parent 47cb0c3 commit e305560

20 files changed

+634
-69
lines changed

src/Symfony/Bundle/WebProfilerBundle/Controller/ProfilerController.php

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Bundle\WebProfilerBundle\Controller;
1313

14+
use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler;
1415
use Symfony\Bundle\WebProfilerBundle\Profiler\TemplateManager;
1516
use Symfony\Component\HttpFoundation\RedirectResponse;
1617
use Symfony\Component\HttpFoundation\Request;
@@ -33,6 +34,7 @@ class ProfilerController
3334
private $twig;
3435
private $templates;
3536
private $toolbarPosition;
37+
private $cspHandler;
3638

3739
/**
3840
* Constructor.
@@ -43,13 +45,14 @@ class ProfilerController
4345
* @param array $templates The templates
4446
* @param string $toolbarPosition The toolbar position (top, bottom, normal, or null -- use the configuration)
4547
*/
46-
public function __construct(UrlGeneratorInterface $generator, Profiler $profiler = null, \Twig_Environment $twig, array $templates, $toolbarPosition = 'normal')
48+
public function __construct(UrlGeneratorInterface $generator, Profiler $profiler = null, \Twig_Environment $twig, array $templates, $toolbarPosition = 'normal', ContentSecurityPolicyHandler $cspHandler = null)
4749
{
4850
$this->generator = $generator;
4951
$this->profiler = $profiler;
5052
$this->twig = $twig;
5153
$this->templates = $templates;
5254
$this->toolbarPosition = $toolbarPosition;
55+
$this->cspHandler = $cspHandler;
5356
}
5457

5558
/**
@@ -103,7 +106,7 @@ public function panelAction(Request $request, $token)
103106
throw new NotFoundHttpException(sprintf('Panel "%s" is not available for token "%s".', $panel, $token));
104107
}
105108

106-
return new Response($this->twig->render($this->getTemplateManager()->getName($profile, $panel), array(
109+
return $this->renderWithCspNonces($request, $this->getTemplateManager()->getName($profile, $panel), array(
107110
'token' => $token,
108111
'profile' => $profile,
109112
'collector' => $profile->getCollector($panel),
@@ -113,7 +116,7 @@ public function panelAction(Request $request, $token)
113116
'templates' => $this->getTemplateManager()->getTemplates($profile),
114117
'is_ajax' => $request->isXmlHttpRequest(),
115118
'profiler_markup_version' => 2, // 1 = original profiler, 2 = Symfony 2.8+ profiler
116-
)), 200, array('Content-Type' => 'text/html'));
119+
));
117120
}
118121

119122
/**
@@ -134,10 +137,10 @@ public function infoAction(Request $request, $about)
134137

135138
$this->profiler->disable();
136139

137-
return new Response($this->twig->render('@WebProfiler/Profiler/info.html.twig', array(
140+
return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/info.html.twig', array(
138141
'about' => $about,
139142
'request' => $request,
140-
)), 200, array('Content-Type' => 'text/html'));
143+
));
141144
}
142145

143146
/**
@@ -185,15 +188,15 @@ public function toolbarAction(Request $request, $token)
185188
// the profiler is not enabled
186189
}
187190

188-
return new Response($this->twig->render('@WebProfiler/Profiler/toolbar.html.twig', array(
191+
return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/toolbar.html.twig', array(
189192
'request' => $request,
190193
'position' => $position,
191194
'profile' => $profile,
192195
'templates' => $this->getTemplateManager()->getTemplates($profile),
193196
'profiler_url' => $url,
194197
'token' => $token,
195198
'profiler_markup_version' => 2, // 1 = original toolbar, 2 = Symfony 2.8+ toolbar
196-
)), 200, array('Content-Type' => 'text/html'));
199+
));
197200
}
198201

199202
/**
@@ -278,7 +281,7 @@ public function searchResultsAction(Request $request, $token)
278281
$end = $request->query->get('end', null);
279282
$limit = $request->query->get('limit');
280283

281-
return new Response($this->twig->render('@WebProfiler/Profiler/results.html.twig', array(
284+
return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/results.html.twig', array(
282285
'request' => $request,
283286
'token' => $token,
284287
'profile' => $profile,
@@ -291,7 +294,7 @@ public function searchResultsAction(Request $request, $token)
291294
'end' => $end,
292295
'limit' => $limit,
293296
'panel' => null,
294-
)), 200, array('Content-Type' => 'text/html'));
297+
));
295298
}
296299

297300
/**
@@ -384,4 +387,18 @@ protected function getTemplateManager()
384387

385388
return $this->templateManager;
386389
}
390+
391+
private function renderWithCspNonces(Request $request, $template, $variables, $code = 200, $headers = array('Content-Type' => 'text/html'))
392+
{
393+
$response = new Response('', $code, $headers);
394+
395+
$nonces = $this->cspHandler ? $this->cspHandler->getNonces($request, $response) : array();
396+
397+
$variables['csp_script_nonce'] = isset($nonces['csp_script_nonce']) ? $nonces['csp_script_nonce'] : null;
398+
$variables['csp_style_nonce'] = isset($nonces['csp_style_nonce']) ? $nonces['csp_style_nonce'] : null;
399+
400+
$response->setContent($this->twig->render($template, $variables));
401+
402+
return $response;
403+
}
387404
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\WebProfilerBundle\Csp;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\Response;
16+
17+
/**
18+
* Handles Content-Security-Policy HTTP header for the WebProfiler Bundle.
19+
*
20+
* @author Romain Neutron <imprec@gmail.com>
21+
*/
22+
class ContentSecurityPolicyHandler
23+
{
24+
private $nonceGenerator;
25+
26+
public function __construct(NonceGenerator $nonceGenerator)
27+
{
28+
return $this->nonceGenerator = $nonceGenerator;
29+
}
30+
31+
/**
32+
* Returns an array of nonces to be used in Twig templates and Content-Security-Policy headers.
33+
*
34+
* Nonce can be provided by;
35+
* - The request - In case HTML content is fetched via AJAX and inserted in DOM, it must use the same nonce as origin
36+
* - The response - A call to getNonces() has already been done previously. Same nonce are returned
37+
* - They are otherwise randomly generated
38+
*
39+
* @param Request $request
40+
* @param Response $response
41+
*
42+
* @return array
43+
*/
44+
public function getNonces(Request $request, Response $response)
45+
{
46+
if ($request->headers->has('X-SymfonyProfiler-Script-Nonce') && $request->headers->has('X-SymfonyProfiler-Style-Nonce')) {
47+
return array(
48+
'csp_script_nonce' => $request->headers->get('X-SymfonyProfiler-Script-Nonce'),
49+
'csp_style_nonce' => $request->headers->get('X-SymfonyProfiler-Style-Nonce'),
50+
);
51+
}
52+
53+
if ($response->headers->has('X-SymfonyProfiler-Script-Nonce') && $response->headers->has('X-SymfonyProfiler-Style-Nonce')) {
54+
return array(
55+
'csp_script_nonce' => $response->headers->get('X-SymfonyProfiler-Script-Nonce'),
56+
'csp_style_nonce' => $response->headers->get('X-SymfonyProfiler-Style-Nonce'),
57+
);
58+
}
59+
60+
$nonces = array(
61+
'csp_script_nonce' => $this->generateNonce(),
62+
'csp_style_nonce' => $this->generateNonce(),
63+
);
64+
65+
$response->headers->set('X-SymfonyProfiler-Script-Nonce', $nonces['csp_script_nonce']);
66+
$response->headers->set('X-SymfonyProfiler-Style-Nonce', $nonces['csp_style_nonce']);
67+
68+
return $nonces;
69+
}
70+
71+
/**
72+
* Cleanup temporary headers and updates Content-Security-Policy headers.
73+
*
74+
* This method should be called on KernelEvents::RESPONSE
75+
*
76+
* @param Request $request
77+
* @param Response $response
78+
*
79+
* @return array Nonce used by the bundle in Content-Security-Policy header
80+
*/
81+
public function onKernelResponse(Request $request, Response $response)
82+
{
83+
$nonces = $this->getNonces($request, $response);
84+
$this->cleanHeaders($response);
85+
$this->updateCspHeaders($response, $nonces);
86+
87+
return $nonces;
88+
}
89+
90+
/**
91+
* @param Response $response
92+
*/
93+
private function cleanHeaders(Response $response)
94+
{
95+
$response->headers->remove('X-SymfonyProfiler-Script-Nonce');
96+
$response->headers->remove('X-SymfonyProfiler-Style-Nonce');
97+
}
98+
99+
/**
100+
* Updates Content-Security-Policy headers in a response.
101+
*
102+
* @param Response $response
103+
* @param array $nonces
104+
*
105+
* @return array
106+
*/
107+
private function updateCspHeaders(Response $response, array $nonces = array()) {
108+
$nonces = array_replace(array(
109+
'csp_script_nonce' => $this->generateNonce(),
110+
'csp_style_nonce' => $this->generateNonce(),
111+
), $nonces);
112+
113+
$ruleIsSet = false;
114+
115+
$headers = $this->getCspHeaders($response);
116+
117+
foreach ($headers as $header => $directives) {
118+
foreach (array('script-src' => 'csp_script_nonce', 'style-src' => 'csp_style_nonce') as $type => $tokenName) {
119+
if (!$this->authorizesInline($directives, $type)) {
120+
if (!isset($headers[$header][$type])) {
121+
if (isset($headers[$header]['default-src'])) {
122+
$headers[$header][$type] = $headers[$header]['default-src'];
123+
} else {
124+
$headers[$header][$type] = array();
125+
}
126+
}
127+
$ruleIsSet = true;
128+
if (!in_array('\'unsafe-inline\'', $headers[$header][$type], true)) {
129+
$headers[$header][$type][] = '\'unsafe-inline\'';
130+
}
131+
$headers[$header][$type][] = sprintf('\'nonce-%s\'', $nonces[$tokenName]);
132+
}
133+
}
134+
}
135+
136+
if (!$ruleIsSet) {
137+
return $nonces;
138+
}
139+
140+
foreach ($headers as $header => $directives) {
141+
$response->headers->set($header, $this->generateCspHeader($directives));
142+
}
143+
144+
return $nonces;
145+
}
146+
147+
/**
148+
* Generates a valid Content-Security-Policy nonce.
149+
*
150+
* @return string
151+
*/
152+
private function generateNonce()
153+
{
154+
return $this->nonceGenerator->generate();
155+
}
156+
157+
/**
158+
* Converts a directives set array into Content-Security-Policy header.
159+
*
160+
* @param array $directives The directives set
161+
*
162+
* @return string The Content-Security-Policy header
163+
*/
164+
private function generateCspHeader(array $directives)
165+
{
166+
return array_reduce(array_keys($directives), function ($res, $name) use ($directives) {
167+
return ($res !== '' ? $res.'; ' : '').sprintf('%s %s', $name, implode(' ', $directives[$name]));
168+
}, '');
169+
}
170+
171+
/**
172+
* Converts a Content-Security-Policy header value into a directives set array.
173+
*
174+
* @param string $header The header value
175+
*
176+
* @return array The directives set
177+
*/
178+
private function parseDirectives($header) {
179+
$directives = array();
180+
181+
foreach (explode(';', $header) as $directive) {
182+
$parts = explode(' ', trim($directive));
183+
if (count($parts) < 1) {
184+
continue;
185+
}
186+
$name = array_shift($parts);
187+
$directives[$name] = $parts;
188+
}
189+
190+
return $directives;
191+
}
192+
193+
/**
194+
* Detects if the 'unsafe-inline' is prevented for a directive within the directives set.
195+
*
196+
* @param array $directivesSet The directives set
197+
* @param string $type The name of the directive to check
198+
*
199+
* @return bool
200+
*/
201+
private function authorizesInline(array $directivesSet, $type)
202+
{
203+
if (isset($directivesSet[$type])) {
204+
$directives = $directivesSet[$type];
205+
} elseif (isset($directivesSet['default-src'])) {
206+
$directives = $directivesSet['default-src'];
207+
} else {
208+
return false;
209+
}
210+
211+
return in_array('\'unsafe-inline\'', $directives, true) && !$this->hasHashOrNonce($directives);
212+
}
213+
214+
private function hasHashOrNonce(array $directives)
215+
{
216+
foreach ($directives as $directive) {
217+
if ('\'' !== substr($directive, -1)) {
218+
continue;
219+
}
220+
if ('\'nonce-' === substr($directive, 0, 7)) {
221+
return true;
222+
}
223+
if (in_array(substr($directive, 0, 8), array('\'sha256-', '\'sha384-', '\'sha512-'), true)) {
224+
return true;
225+
}
226+
}
227+
228+
return false;
229+
}
230+
231+
/**
232+
* Retrieves the Content-Security-Policy headers (either X-Content-Security-Policy or Content-Security-Policy) from
233+
* a response.
234+
*
235+
* @param Response $response The response
236+
*
237+
* @return array An associative array of headers
238+
*/
239+
private function getCspHeaders(Response $response)
240+
{
241+
$headers = array();
242+
243+
if ($response->headers->has('Content-Security-Policy')) {
244+
$headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy'));
245+
}
246+
247+
if ($response->headers->has('X-Content-Security-Policy')) {
248+
$headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy'));
249+
}
250+
251+
return $headers;
252+
}
253+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\WebProfilerBundle\Csp;
13+
14+
/**
15+
* Generates Content-Security-Policy nonce.
16+
*
17+
* @author Romain Neutron <imprec@gmail.com>
18+
*/
19+
class NonceGenerator
20+
{
21+
public function generate()
22+
{
23+
return bin2hex(random_bytes(16));
24+
}
25+
}

0 commit comments

Comments
 (0)