Skip to content

Commit cda45ee

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

File tree

16 files changed

+635
-61
lines changed

16 files changed

+635
-61
lines changed

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

Lines changed: 25 additions & 3 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
/**
@@ -87,6 +90,7 @@ public function panelAction(Request $request, $token)
8790
}
8891

8992
$this->profiler->disable();
93+
$this->cspHandler and $this->cspHandler->forceDisableCsp();
9094

9195
$panel = $request->query->get('panel', 'request');
9296
$page = $request->query->get('page', 'home');
@@ -133,6 +137,7 @@ public function infoAction(Request $request, $about)
133137
}
134138

135139
$this->profiler->disable();
140+
$this->cspHandler and $this->cspHandler->forceDisableCsp();
136141

137142
return new Response($this->twig->render('@WebProfiler/Profiler/info.html.twig', array(
138143
'about' => $about,
@@ -185,15 +190,15 @@ public function toolbarAction(Request $request, $token)
185190
// the profiler is not enabled
186191
}
187192

188-
return new Response($this->twig->render('@WebProfiler/Profiler/toolbar.html.twig', array(
193+
return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/toolbar.html.twig', array(
189194
'request' => $request,
190195
'position' => $position,
191196
'profile' => $profile,
192197
'templates' => $this->getTemplateManager()->getTemplates($profile),
193198
'profiler_url' => $url,
194199
'token' => $token,
195200
'profiler_markup_version' => 2, // 1 = original toolbar, 2 = Symfony 2.8+ toolbar
196-
)), 200, array('Content-Type' => 'text/html'));
201+
));
197202
}
198203

199204
/**
@@ -212,6 +217,7 @@ public function searchBarAction(Request $request)
212217
}
213218

214219
$this->profiler->disable();
220+
$this->cspHandler and $this->cspHandler->forceDisableCsp();
215221

216222
if (null === $session = $request->getSession()) {
217223
$ip =
@@ -267,6 +273,7 @@ public function searchResultsAction(Request $request, $token)
267273
}
268274

269275
$this->profiler->disable();
276+
$this->cspHandler and $this->cspHandler->forceDisableCsp();
270277

271278
$profile = $this->profiler->loadProfile($token);
272279

@@ -363,6 +370,7 @@ public function phpinfoAction()
363370
}
364371

365372
$this->profiler->disable();
373+
$this->cspHandler and $this->cspHandler->forceDisableCsp();
366374

367375
ob_start();
368376
phpinfo();
@@ -384,4 +392,18 @@ protected function getTemplateManager()
384392

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

0 commit comments

Comments
 (0)