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

Skip to content

Commit 20b4c7f

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

18 files changed

+333
-50
lines changed
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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+
/**
25+
* Returns an array of nonces to be used in Twig templates and Content-Security-Policy headers.
26+
*
27+
* Nonce can be provided by;
28+
* - The request - In case HTML content is fetched via AJAX and inserted in DOM, it must use the same nonce as origin
29+
* - The response - A call to getNonces() has already been done previously. Same nonce are returned
30+
* - They are otherwise randomly generated
31+
*
32+
* @param Request $request
33+
* @param Response $response
34+
*
35+
* @return array
36+
*/
37+
public function getNonces(Request $request, Response $response)
38+
{
39+
if ($request->headers->has('X-WebProfiler-Script-Nonce') && $request->headers->has('X-WebProfiler-Style-Nonce')) {
40+
return [
41+
'csp_script_nonce' => $request->headers->get('X-WebProfiler-Script-Nonce'),
42+
'csp_style_nonce' => $request->headers->get('X-WebProfiler-Style-Nonce'),
43+
];
44+
}
45+
46+
if ($response->headers->has('X-WebProfiler-Script-Nonce') && $response->headers->has('X-WebProfiler-Style-Nonce')) {
47+
return [
48+
'csp_script_nonce' => $response->headers->get('X-WebProfiler-Script-Nonce'),
49+
'csp_style_nonce' => $response->headers->get('X-WebProfiler-Style-Nonce'),
50+
];
51+
}
52+
53+
$nonces = [
54+
'csp_script_nonce' => $this->generateNonce(),
55+
'csp_style_nonce' => $this->generateNonce(),
56+
];
57+
58+
$response->headers->set('X-WebProfiler-Script-Nonce', $nonces['csp_script_nonce']);
59+
$response->headers->set('X-WebProfiler-Style-Nonce', $nonces['csp_style_nonce']);
60+
61+
return $nonces;
62+
}
63+
64+
/**
65+
* Cleanup temporary headers and updates Content-Security-Policy headers.
66+
*
67+
* This method should be called on KernelEvents::RESPONSE
68+
*
69+
* @param Request $request
70+
* @param Response $response
71+
*
72+
* @return array Nonce used by the bundle in Content-Security-Policy header
73+
*/
74+
public function onKernelResponse(Request $request, Response $response)
75+
{
76+
$nonces = $this->getNonces($request, $response);
77+
$this->cleanHeaders($response);
78+
$this->updateCSPheaders($response, $nonces);
79+
80+
return $nonces;
81+
}
82+
83+
/**
84+
* @param Response $response
85+
*/
86+
private function cleanHeaders(Response $response)
87+
{
88+
$response->headers->remove('X-WebProfiler-Script-Nonce');
89+
$response->headers->remove('X-WebProfiler-Style-Nonce');
90+
}
91+
92+
/**
93+
* Updates Content-Security-Policy headers in a response.
94+
*
95+
* @param Response $response
96+
* @param array $nonces
97+
*
98+
* @return array
99+
*/
100+
private function updateCSPheaders(Response $response, array $nonces = array()) {
101+
$nonces = array_replace([
102+
'csp_script_nonce' => $this->generateNonce(),
103+
'csp_style_nonce' => $this->generateNonce(),
104+
], $nonces);
105+
106+
$scriptRule = false;
107+
$styleRule = false;
108+
109+
$headers = $this->getCSPHeaders($response);
110+
111+
foreach($headers as $header => $directives) {
112+
if ($this->CSPpreventsUnsafeInline($directives, 'script-src')) {
113+
$scriptRule = true;
114+
$headers[$header]['script-src'][] = sprintf('\'nonce-%s\'', $nonces['csp_script_nonce']);
115+
}
116+
if ($this->CSPpreventsUnsafeInline($directives, 'style-src')) {
117+
$styleRule = true;
118+
$headers[$header]['style-src'][] = sprintf('\'nonce-%s\'', $nonces['csp_style_nonce']);
119+
}
120+
}
121+
122+
if (!$styleRule && !$scriptRule) {
123+
return $nonces;
124+
}
125+
126+
foreach ($headers as $header => $directives) {
127+
$response->headers->set($header, $this->generateCSPHeader($directives));
128+
}
129+
130+
return $nonces;
131+
}
132+
133+
/**
134+
* Generates a valid CSP nonce.
135+
*
136+
* @return string
137+
*/
138+
private function generateNonce()
139+
{
140+
return bin2hex(random_bytes(16));
141+
}
142+
143+
/**
144+
* Converts a directives set array into Content-Security-Policy header.
145+
*
146+
* @param array $directives The directives set
147+
*
148+
* @return string The Content-Security-Policy header
149+
*/
150+
private function generateCSPHeader(array $directives)
151+
{
152+
foreach ($directives as $name => $values) {
153+
$directives[$name] = sprintf('%s %s', $name, implode(' ', $values));
154+
}
155+
156+
return implode('; ', $directives);
157+
}
158+
159+
/**
160+
* Converts a Content-Security-Policy header value into a directives set array.
161+
*
162+
* @param string $header The header value
163+
*
164+
* @return array The directives set
165+
*/
166+
private function parseDirectives($header) {
167+
$directives = [];
168+
169+
foreach (explode(';', $header) as $directive) {
170+
$parts = explode(' ', trim($directive));
171+
if (count($parts) < 1) {
172+
continue;
173+
}
174+
$name = array_shift($parts);
175+
$directives[$name] = $parts;
176+
}
177+
178+
return $directives;
179+
}
180+
181+
/**
182+
* Detects if the 'unsafe-inline' is prevented for a directive within the directives set.
183+
*
184+
* @param $directives The directives set
185+
* @param $type The name of the directive to check
186+
*
187+
* @return bool
188+
*/
189+
private function CSPpreventsUnsafeInline($directives, $type) {
190+
if (!isset($directives[$type])) {
191+
return false;
192+
}
193+
194+
return !in_array('\'unsafe-inline\'', $directives[$type], true);
195+
}
196+
197+
/**
198+
* Retrieves the Content-Security-Policy headers (either X-Content-Security-Policy or Content-Security-Policy) from
199+
* a response.
200+
*
201+
* @param Response $response The response
202+
*
203+
* @return array An associative array of headers
204+
*/
205+
private function getCSPHeaders(Response $response)
206+
{
207+
$headers = [];
208+
209+
if ($response->headers->has('Content-Security-Policy')) {
210+
$headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy'));
211+
}
212+
213+
if ($response->headers->has('X-Content-Security-Policy')) {
214+
$headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy'));
215+
}
216+
217+
return $headers;
218+
}
219+
}

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) : [];
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
}

0 commit comments

Comments
 (0)
0