8000 feature #18568 [3.2][WebProfilerBundle] Fix bundle usage in Content-S… · symfony/symfony@856c9f6 · GitHub
[go: up one dir, main page]

Skip to content

Commit 856c9f6

Browse files
committed
feature #18568 [3.2][WebProfilerBundle] Fix bundle usage in Content-Security-Policy context without unsafe-inline (romainneutron)
This PR was merged into the 3.2-dev branch. Discussion ---------- [3.2][WebProfilerBundle] Fix bundle usage in Content-Security-Policy context without unsafe-inline | Q | A | ------------- | --- | Branch? | 3.2 | Bug fix? | yes | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | #15397 | License | MIT | Doc PR | N/A Hello, this PR fixes the compatibility of the WebprofilerBundle in a context where Content-Security-Policy headers are could prevent `unsafe-inline` of `script-src` or `style-src` directives. This PR has been originally proposed in 2.8 in #18434 Commits ------- 571a1f2 [WebProfilerBundle] Fix bundle usage in Content-Security-Policy context without unsafe-inline
2 parents ce28a86 + 571a1f2 commit 856c9f6

16 files changed

+650
-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: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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+
{
120+
$nonces = array_replace(array(
121+
'csp_script_nonce' => $this->generateNonce(),
122+
'csp_style_nonce' => $this->generateNonce(),
123+
), $nonces);
124+
125+
$ruleIsSet = false;
126+
127+
$headers = $this->getCspHeaders($response);
128+
129+
foreach ($headers as $header => $directives) {
130+
foreach (array('script-src' => 'csp_script_nonce', 'style-src' => 'csp_style_nonce') as $type => $tokenName) {
131+
if ($this->authorizesInline($directives, $type)) {
132+
continue;
133+
}
134+
if (!isset($headers[$header][$type])) {
135+
if (isset($headers[$header]['default-src'])) {
136+
$headers[$header][$type] = $headers[$header]['default-src'];
137+
} else {
138+
$headers[$header][$type] = array();
139+
}
140+
}
141+
$ruleIsSet = true;
142+
if (!in_array('\'unsafe-inline\'', $headers[$header][$type], true)) {
143+
$headers[$header][$type][] = '\'unsafe-inline\'';
144+
}
145+
$headers[$header][$type][] = sprintf('\'nonce-%s\'', $nonces[$tokenName]);
146+
}
147+
}
148+
149+
if (!$ruleIsSet) {
150+
return $nonces;
151+
}
152+
153+
foreach ($headers as $header => $directives) {
154+
$response->headers->set($header, $this->generateCspHeader($directives));
155+
}
156+
157+
return $nonces;
158+
}
159+
160+
/**
161+
* Generates a valid Content-Security-Policy nonce.
162+
*
163+
* @return string
164+
*/
165+
private function generateNonce()
166+
{
167+
return $this->nonceGenerator->generate();
168+
}
169+
170+
/**
171+
* Converts a directive set array into Content-Security-Policy header.
172+
*
173+
* @param array $directives The directive set
174+
*
175+
* @return string The Content-Security-Policy header
176+
*/
177+
private function generateCspHeader(array $directives)
178+
{
179+
return array_reduce(array_keys($directives), function ($res, $name) use ($directives) {
180+
return ($res !== '' ? $res.'; ' : '').sprintf('%s %s', $name, implode(' ', $directiv 97AE es[$name]));
181+
}, '');
182+
}
183+
184+
/**
185+
* Converts a Content-Security-Policy header value into a directive set array.
186+
*
187+
* @param string $header The header value
188+
*
189+
* @return array The directive set
190+
*/
191+
private function parseDirectives($header)
192+
{
193+
$directives = array();
194+
195+
foreach (explode(';', $header) as $directive) {
196+
$parts = explode(' ', trim($directive));
197+
if (count($parts) < 1) {
198+
continue;
199+
}
200+
$name = array_shift($parts);
201+
$directives[$name] = $parts;
202+
}
203+
204+
return $directives;
205+
}
206+
207+
/**
208+
* Detects if the 'unsafe-inline' is prevented for a directive within the directive set.
209+
*
210+
* @param array $directivesSet The directive set
211+
* @param string $type The name of the directive to check
212+
*
213+
* @return bool
214+
*/
215+
private function authorizesInline(array $directivesSet, $type)
216+
{
217+
if (isset($directivesSet[$type])) {
218+
$directives = $directivesSet[$type];
219+
} elseif (isset($directivesSet['default-src'])) {
220+
$directives = $directivesSet['default-src'];
221+
} else {
222+
return false;
223+
}
224+
225+
return in_array('\'unsafe-inline\'', $directives, true) && !$this->hasHashOrNonce($directives);
226+
}
227+
228+
private function hasHashOrNonce(array $directives)
229+
{
230+
foreach ($directives as $directive) {
231+
if ('\'' !== substr($directive, -1)) {
232+
continue;
233+
}
234+
if ('\'nonce-' === substr($directive, 0, 7)) {
235+
return true;
236+
}
237+
if (in_array(substr($directive, 0, 8), array('\'sha256-', '\'sha384-', '\'sha512-'), true)) {
238+
return true;
239+
}
240+
}
241+
242+
return false;
243+
}
244+
245+
/**
246+
* Retrieves the Content-Security-Policy headers (either X-Content-Security-Policy or Content-Security-Policy) from
247+
* a response.
248+
*
249+
* @return array An associative array of headers
250+
*/
251+
private function getCspHeaders(Response $response)
252+
{
253+
$headers = array();
254+
255+
if ($response->headers->has('Content-Security-Policy')) {
256+
$headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy'));
257+
}
258+
259+
if ($response->headers->has('X-Content-Security-Policy')) {
260+
$headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy'));
261+
}
262+
263+
return $headers;
264+
}
265+
}

0 commit comments

Comments
 (0)
0