8000 [WebProfilerBundle] Fix bundle usage in Content-Security-Policy context · symfony/symfony@ff1cccd · GitHub
[go: up one dir, main page]

Skip to content

Commit ff1cccd

Browse files
committed
[WebProfilerBundle] Fix bundle usage in Content-Security-Policy context
1 parent e7d7e1e commit ff1cccd

20 files changed

+636
-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
/**
@@ -155,10 +158,10 @@ public function infoAction(Request $request, $about)
155158

156159
$this->profiler->disable();
157160

158-
return new Response($this->twig->render('@WebProfiler/Profiler/info.html.twig', array(
161+
return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/info.html.twig', array(
159162
'about' => $about,
160163
'request' => $request,
161-
)), 200, array('Content-Type' => 'text/html'));
164+
));
162165
}
163166

164167
/**
@@ -206,15 +209,15 @@ public function toolbarAction(Request $request, $token)
206209
// the profiler is not enabled
207210
}
208211

209-
return new Response($this->twig->render('@WebProfiler/Profiler/toolbar.html.twig', array(
212+
return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/toolbar.html.twig', array(
210213
'request' => $request,
211214
'position' => $position,
212215
'profile' => $profile,
213216
'templates' => $this->getTemplateManager()->getTemplates($profile),
214217
'profiler_url' => $url,
215218
'token' => $token,
216219
'profiler_markup_version' => 2, // 1 = original toolbar, 2 = Symfony 2.8+ toolbar
217-
)), 200, array('Content-Type' => 'text/html'));
220+
));
218221
}
219222

220223
/**
@@ -295,7 +298,7 @@ public function searchResultsAction(Request $request, $token)
295298
$end = $request->query->get('end', null);
296299
$limit = $request->query->get('limit');
297300

298-
return new Response($this->twig->render('@WebProfiler/Profiler/results.html.twig', array(
301+
return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/results.html.twig', array(
299302
'request' => $request,
300303
'token' => $token,
301304
'profile' => $profile,
@@ -307,7 +310,7 @@ public function searchResultsAction(Request $request, $token)
307310
'end' => $end,
308311
'limit' => $limit,
309312
'panel' => null,
310-
)), 200, array('Content-Type' => 'text/html'));
313+
));
311314
}
312315

313316
/**
@@ -397,4 +400,18 @@ protected function getTemplateManager()
397400

398401
return $this->templateManager;
399402
}
403+
404+
private function renderWithCspNonces(Request $request, $template, $variables, $code = 200, $headers = array('Content-Type' => 'text/html'))
405+
{
406+
$response = new Response('', $code, $headers);
407+
408+
$nonces = $this->cspHandler ? $this->cspHandler->getNonces($request, $response) : array();
409+
410+
$variables['csp_script_nonce'] = isset($nonces['csp_script_nonce']) ? $nonces['csp_script_nonce'] : null;
411+
$variables['csp_style_nonce'] = isset($nonces['csp_style_nonce']) ? $nonces['csp_style_nonce'] : null;
412+
413+
$response->setContent($this->twig->render($template, $variables));
414+
415+
return $response;
416+
}
400417
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
foreach ($directives as $name => $values) {
167+
$directives[$name] = sprintf('%s %s', $name, implode(' ', $values));
168+
}
169+
170+
return implode('; ', $directives);
171+
}
172+
173+
/**
174+
* Converts a Content-Security-Policy header value into a directives set array.
175+
*
176+
* @param string $header The header value
177+
*
178+
* @return array The directives set
179+
*/
180+
private function parseDirectives($header) {
181+
$directives = array();
182+
183+
foreach (explode(';', $header) as $directive) {
184+
$parts = explode(' ', trim($directive));
185+
if (count($parts) < 1) {
186+
continue;
187+
}
188+
$name = array_shift($parts);
189+
$directives[$name] = $parts;
190+
}
191+
192+
return $directives;
193+
}
194+
195+
/**
196+
* Detects if the 'unsafe-inline' is prevented for a directive within the directives set.
197+
*
198+
* @param array $directivesSet The directives set
199+
* @param string $type The name of the directive to check
200+
*
201+
* @return bool
202+
*/
203+
private function authorizesInline(array $directivesSet, $type)
204+
{
205+
if (isset($directivesSet[$type])) {
206+
$directives = $directivesSet[$type];
207+
} elseif (isset($directivesSet['default-src'])) {
208+
$directives = $directivesSet['default-src'];
209+
} else {
210+
return false;
211+
}
212+
213+
return in_array('\'unsafe-inline\'', $directives, true) && !$this->hasHashOrNonce($directives);
214+
}
215+
216+
private function hasHashOrNonce(array $directives)
217+
{
218+
foreach ($directives as $directive) {
219+
if ('\'' !== substr($directive, -1)) {
220+
continue;
221+
}
222+
if ('\'nonce-' === substr($directive, 0, 7)) {
223+
return true;
224+
}
225+
if (in_array(substr($directive, 0, 8), array('\'sha256-', '\'sha384-', '\'sha512-'), true)) {
226+
return true;
227+
}
228+
}
229+
230+
return false;
231+
}
232+
233+
/**
234+
* Retrieves the Content-Security-Policy headers (either X-Content-Security-Policy or Content-Security-Policy) from
235+
* a response.
236+
*
237+
* @param Response $response The response
238+
*
239+
* @return array An associative array of headers
240+
*/
241+
private function getCspHeaders(Response $response)
242+
{
243+
$headers = array();
244+
245+
if ($response->headers->has('Content-Security-Policy')) {
246+
$headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy'));
247+
}
248+
249+
if ($response->headers->has('X-Content-Security-Policy')) {
250+
$headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy'));
251+
}
252+
253+
return $headers;
254+
}
255+
}
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)
0