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 12namespace Symfony\Bundle\WebProfilerBundle\Csp; 13 14use Symfony\Component\HttpFoundation\Request; 15use 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 */ 24class 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 [ 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 [ 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 = [ 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 []; 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 $response->headers->remove('Content-Security-Policy-Report-Only'); 112 } 113 114 /** 115 * Updates Content-Security-Policy headers in a response. 116 * 117 * @return array 118 */ 119 private function updateCspHeaders(Response $response, array $nonces = []) 120 { 121 $nonces = array_replace([ 122 'csp_script_nonce' => $this->generateNonce(), 123 'csp_style_nonce' => $this->generateNonce(), 124 ], $nonces); 125 126 $ruleIsSet = false; 127 128 $headers = $this->getCspHeaders($response); 129 130 $types = [ 131 'script-src' => 'csp_script_nonce', 132 'script-src-elem' => 'csp_script_nonce', 133 'style-src' => 'csp_style_nonce', 134 'style-src-elem' => 'csp_style_nonce', 135 ]; 136 137 foreach ($headers as $header => $directives) { 138 foreach ($types as $type => $tokenName) { 139 if ($this->authorizesInline($directives, $type)) { 140 continue; 141 } 142 if (!isset($headers[$header][$type])) { 143 if (null === $fallback = $this->getDirectiveFallback($directives, $type)) { 144 continue; 145 } 146 147 $headers[$header][$type] = $fallback; 148 } 149 $ruleIsSet = true; 150 if (!\in_array('\'unsafe-inline\'', $headers[$header][$type], true)) { 151 $headers[$header][$type][] = '\'unsafe-inline\''; 152 } 153 $headers[$header][$type][] = sprintf('\'nonce-%s\'', $nonces[$tokenName]); 154 } 155 } 156 157 if (!$ruleIsSet) { 158 return $nonces; 159 } 160 161 foreach ($headers as $header => $directives) { 162 $response->headers->set($header, $this->generateCspHeader($directives)); 163 } 164 165 return $nonces; 166 } 167 168 /** 169 * Generates a valid Content-Security-Policy nonce. 170 * 171 * @return string 172 */ 173 private function generateNonce() 174 { 175 return $this->nonceGenerator->generate(); 176 } 177 178 /** 179 * Converts a directive set array into Content-Security-Policy header. 180 * 181 * @param array $directives The directive set 182 * 183 * @return string The Content-Security-Policy header 184 */ 185 private function generateCspHeader(array $directives) 186 { 187 return array_reduce(array_keys($directives), function ($res, $name) use ($directives) { 188 return ('' !== $res ? $res.'; ' : '').sprintf('%s %s', $name, implode(' ', $directives[$name])); 189 }, ''); 190 } 191 192 /** 193 * Converts a Content-Security-Policy header value into a directive set array. 194 * 195 * @param string $header The header value 196 * 197 * @return array The directive set 198 */ 199 private function parseDirectives($header) 200 { 201 $directives = []; 202 203 foreach (explode(';', $header) as $directive) { 204 $parts = explode(' ', trim($directive)); 205 if (\count($parts) < 1) { 206 continue; 207 } 208 $name = array_shift($parts); 209 $directives[$name] = $parts; 210 } 211 212 return $directives; 213 } 214 215 /** 216 * Detects if the 'unsafe-inline' is prevented for a directive within the directive set. 217 * 218 * @param array $directivesSet The directive set 219 * @param string $type The name of the directive to check 220 * 221 * @return bool 222 */ 223 private function authorizesInline(array $directivesSet, $type) 224 { 225 if (isset($directivesSet[$type])) { 226 $directives = $directivesSet[$type]; 227 } elseif (null === $directives = $this->getDirectiveFallback($directivesSet, $type)) { 228 return false; 229 } 230 231 return \in_array('\'unsafe-inline\'', $directives, true) && !$this->hasHashOrNonce($directives); 232 } 233 234 private function hasHashOrNonce(array $directives) 235 { 236 foreach ($directives as $directive) { 237 if ('\'' !== substr($directive, -1)) { 238 continue; 239 } 240 if ('\'nonce-' === substr($directive, 0, 7)) { 241 return true; 242 } 243 if (\in_array(substr($directive, 0, 8), ['\'sha256-', '\'sha384-', '\'sha512-'], true)) { 244 return true; 245 } 246 } 247 248 return false; 249 } 250 251 private function getDirectiveFallback(array $directiveSet, $type) 252 { 253 if (\in_array($type, ['script-src-elem', 'style-src-elem'], true) || !isset($directiveSet['default-src'])) { 254 // Let the browser fallback on it's own 255 return null; 256 } 257 258 return $directiveSet['default-src']; 259 } 260 261 /** 262 * Retrieves the Content-Security-Policy headers (either X-Content-Security-Policy or Content-Security-Policy) from 263 * a response. 264 * 265 * @return array An associative array of headers 266 */ 267 private function getCspHeaders(Response $response) 268 { 269 $headers = []; 270 271 if ($response->headers->has('Content-Security-Policy')) { 272 $headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy')); 273 } 274 275 if ($response->headers->has('Content-Security-Policy-Report-Only')) { 276 $headers['Content-Security-Policy-Report-Only'] = $this->parseDirectives($response->headers->get('Content-Security-Policy-Report-Only')); 277 } 278 279 if ($response->headers->has('X-Content-Security-Policy')) { 280 $headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy')); 281 } 282 283 return $headers; 284 } 285} 286