Skip to content

Commit ef3827f

Browse files
committed
[WebProfilerBundle] Fix bundle usage in Content-Security-Policy context without unsafe-inline
1 parent ce28a86 commit ef3827f

File tree

16 files changed

+649
-48
lines changed

16 files changed

+649
-48
lines changed

src/Symfony/Bundle/DebugBundle/Resources/views/Profiler/dump.html.twig

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
{{ dump.data|raw }}
2828
</div>
2929
{% endfor %}
30-
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" onload="var h = this.parentNode.innerHTML, rx=/<script>(.*?)<\/script>/g, s; while (s = rx.exec(h)) {eval(s[1]);};" />
3130
{% endset %}
3231

3332
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }}

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

Lines changed: 40 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
/**
@@ -88,6 +91,10 @@ public function panelAction(Request $request, $token)
8891

8992
$this->profiler->disable();
9093

94+
if (null !== $this->cspHandler) {
95+
$this->cspHandler->disableCsp();
96+
}
97+
9198
$panel = $request->query->get('panel', 'request');
9299
$page = $request->query->get('page', 'home');
93100

@@ -134,6 +141,10 @@ public function infoAction(Request $request, $about)
134141

135142
$this->profiler->disable();
136143

144+
if (null !== $this->cspHandler) {
145+
$this->cspHandler->disableCsp();
146+
}
147+
137148
return new Response($this->twig->render('@WebProfiler/Profiler/info.html.twig', array(
138149
'about' => $about,
139150
'request' => $request,
@@ -185,15 +196,15 @@ public function toolbarAction(Request $request, $token)
185196
// the profiler is not enabled
186197
}
187198

188-
return new Response($this->twig->render('@WebProfiler/Profiler/toolbar.html.twig', array(
199+
return $this->renderWithCspNonces($request, '@WebProfiler/Profiler/toolbar.html.twig', array(
189200
'request' => $request,
190201
'position' => $position,
191202
'profile' => $profile,
192203
'templates' => $this->getTemplateManager()->getTemplates($profile),
193204
'profiler_url' => $url,
194205
'token' => $token,
195206
'profiler_markup_version' => 2, // 1 = original toolbar, 2 = Symfony 2.8+ toolbar
196-
)), 200, array('Content-Type' => 'text/html'));
207+
));
197208
}
198209

199210
/**
@@ -213,6 +224,10 @@ public function searchBarAction(Request $request)
213224

214225
$this->profiler->disable();
215226

227+
if (null !== $this->cspHandler) {
228+
$this->cspHandler->disableCsp();
229+
}
230+
216231
if (null === $session = $request->getSession()) {
217232
$ip =
218233
$method =
@@ -268,6 +283,10 @@ public function searchResultsAction(Request $request, $token)
268283

269284
$this->profiler->disable();
270285

286+
if (null !== $this->cspHandler) {
287+
$this->cspHandler->disableCsp();
288+
}
289+
271290
$profile = $this->profiler->loadProfile($token);
272291

273292
$ip = $request->query->get('ip');
@@ -364,6 +383,10 @@ public function phpinfoAction()
364383

365384
$this->profiler->disable();
366385

386+
if (null !== $this->cspHandler) {
387+
$this->cspHandler->disableCsp();
388+
}
389+
367390
ob_start();
368391
phpinfo();
369392
$phpinfo = ob_get_clean();
@@ -384,4 +407,18 @@ protected function getTemplateManager()
384407

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

0 commit comments

Comments
 (0)