ContentSecurityPolicyHandler.php 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Bundle\WebProfilerBundle\Csp;
  11. use Symfony\Component\HttpFoundation\Request;
  12. use Symfony\Component\HttpFoundation\Response;
  13. /**
  14. * Handles Content-Security-Policy HTTP header for the WebProfiler Bundle.
  15. *
  16. * @author Romain Neutron <imprec@gmail.com>
  17. *
  18. * @internal
  19. */
  20. class ContentSecurityPolicyHandler
  21. {
  22. private $nonceGenerator;
  23. private $cspDisabled = false;
  24. public function __construct(NonceGenerator $nonceGenerator)
  25. {
  26. $this->nonceGenerator = $nonceGenerator;
  27. }
  28. /**
  29. * Returns an array of nonces to be used in Twig templates and Content-Security-Policy headers.
  30. *
  31. * Nonce can be provided by;
  32. * - The request - In case HTML content is fetched via AJAX and inserted in DOM, it must use the same nonce as origin
  33. * - The response - A call to getNonces() has already been done previously. Same nonce are returned
  34. * - They are otherwise randomly generated
  35. */
  36. public function getNonces(Request $request, Response $response): array
  37. {
  38. if ($request->headers->has('X-SymfonyProfiler-Script-Nonce') && $request->headers->has('X-SymfonyProfiler-Style-Nonce')) {
  39. return [
  40. 'csp_script_nonce' => $request->headers->get('X-SymfonyProfiler-Script-Nonce'),
  41. 'csp_style_nonce' => $request->headers->get('X-SymfonyProfiler-Style-Nonce'),
  42. ];
  43. }
  44. if ($response->headers->has('X-SymfonyProfiler-Script-Nonce') && $response->headers->has('X-SymfonyProfiler-Style-Nonce')) {
  45. return [
  46. 'csp_script_nonce' => $response->headers->get('X-SymfonyProfiler-Script-Nonce'),
  47. 'csp_style_nonce' => $response->headers->get('X-SymfonyProfiler-Style-Nonce'),
  48. ];
  49. }
  50. $nonces = [
  51. 'csp_script_nonce' => $this->generateNonce(),
  52. 'csp_style_nonce' => $this->generateNonce(),
  53. ];
  54. $response->headers->set('X-SymfonyProfiler-Script-Nonce', $nonces['csp_script_nonce']);
  55. $response->headers->set('X-SymfonyProfiler-Style-Nonce', $nonces['csp_style_nonce']);
  56. return $nonces;
  57. }
  58. /**
  59. * Disables Content-Security-Policy.
  60. *
  61. * All related headers will be removed.
  62. */
  63. public function disableCsp()
  64. {
  65. $this->cspDisabled = true;
  66. }
  67. /**
  68. * Cleanup temporary headers and updates Content-Security-Policy headers.
  69. *
  70. * @return array Nonces used by the bundle in Content-Security-Policy header
  71. */
  72. public function updateResponseHeaders(Request $request, Response $response): array
  73. {
  74. if ($this->cspDisabled) {
  75. $this->removeCspHeaders($response);
  76. return [];
  77. }
  78. $nonces = $this->getNonces($request, $response);
  79. $this->cleanHeaders($response);
  80. $this->updateCspHeaders($response, $nonces);
  81. return $nonces;
  82. }
  83. private function cleanHeaders(Response $response)
  84. {
  85. $response->headers->remove('X-SymfonyProfiler-Script-Nonce');
  86. $response->headers->remove('X-SymfonyProfiler-Style-Nonce');
  87. }
  88. private function removeCspHeaders(Response $response)
  89. {
  90. $response->headers->remove('X-Content-Security-Policy');
  91. $response->headers->remove('Content-Security-Policy');
  92. $response->headers->remove('Content-Security-Policy-Report-Only');
  93. }
  94. /**
  95. * Updates Content-Security-Policy headers in a response.
  96. */
  97. private function updateCspHeaders(Response $response, array $nonces = []): array
  98. {
  99. $nonces = array_replace([
  100. 'csp_script_nonce' => $this->generateNonce(),
  101. 'csp_style_nonce' => $this->generateNonce(),
  102. ], $nonces);
  103. $ruleIsSet = false;
  104. $headers = $this->getCspHeaders($response);
  105. $types = [
  106. 'script-src' => 'csp_script_nonce',
  107. 'script-src-elem' => 'csp_script_nonce',
  108. 'style-src' => 'csp_style_nonce',
  109. 'style-src-elem' => 'csp_style_nonce',
  110. ];
  111. foreach ($headers as $header => $directives) {
  112. foreach ($types as $type => $tokenName) {
  113. if ($this->authorizesInline($directives, $type)) {
  114. continue;
  115. }
  116. if (!isset($headers[$header][$type])) {
  117. if (null === $fallback = $this->getDirectiveFallback($directives, $type)) {
  118. continue;
  119. }
  120. if (['\'none\''] === $fallback) {
  121. // Fallback came from "default-src: 'none'"
  122. // 'none' is invalid if it's not the only expression in the source list, so we leave it out
  123. $fallback = [];
  124. }
  125. $headers[$header][$type] = $fallback;
  126. }
  127. $ruleIsSet = true;
  128. if (!\in_array('\'unsafe-inline\'', $headers[$header][$type], true)) {
  129. $headers[$header][$type][] = '\'unsafe-inline\'';
  130. }
  131. $headers[$header][$type][] = sprintf('\'nonce-%s\'', $nonces[$tokenName]);
  132. }
  133. }
  134. if (!$ruleIsSet) {
  135. return $nonces;
  136. }
  137. foreach ($headers as $header => $directives) {
  138. $response->headers->set($header, $this->generateCspHeader($directives));
  139. }
  140. return $nonces;
  141. }
  142. /**
  143. * Generates a valid Content-Security-Policy nonce.
  144. */
  145. private function generateNonce(): string
  146. {
  147. return $this->nonceGenerator->generate();
  148. }
  149. /**
  150. * Converts a directive set array into Content-Security-Policy header.
  151. */
  152. private function generateCspHeader(array $directives): string
  153. {
  154. return array_reduce(array_keys($directives), function ($res, $name) use ($directives) {
  155. return ('' !== $res ? $res.'; ' : '').sprintf('%s %s', $name, implode(' ', $directives[$name]));
  156. }, '');
  157. }
  158. /**
  159. * Converts a Content-Security-Policy header value into a directive set array.
  160. */
  161. private function parseDirectives(string $header): array
  162. {
  163. $directives = [];
  164. foreach (explode(';', $header) as $directive) {
  165. $parts = explode(' ', trim($directive));
  166. if (\count($parts) < 1) {
  167. continue;
  168. }
  169. $name = array_shift($parts);
  170. $directives[$name] = $parts;
  171. }
  172. return $directives;
  173. }
  174. /**
  175. * Detects if the 'unsafe-inline' is prevented for a directive within the directive set.
  176. */
  177. private function authorizesInline(array $directivesSet, string $type): bool
  178. {
  179. if (isset($directivesSet[$type])) {
  180. $directives = $directivesSet[$type];
  181. } elseif (null === $directives = $this->getDirectiveFallback($directivesSet, $type)) {
  182. return false;
  183. }
  184. return \in_array('\'unsafe-inline\'', $directives, true) && !$this->hasHashOrNonce($directives);
  185. }
  186. private function hasHashOrNonce(array $directives): bool
  187. {
  188. foreach ($directives as $directive) {
  189. if ('\'' !== substr($directive, -1)) {
  190. continue;
  191. }
  192. if ('\'nonce-' === substr($directive, 0, 7)) {
  193. return true;
  194. }
  195. if (\in_array(substr($directive, 0, 8), ['\'sha256-', '\'sha384-', '\'sha512-'], true)) {
  196. return true;
  197. }
  198. }
  199. return false;
  200. }
  201. private function getDirectiveFallback(array $directiveSet, $type)
  202. {
  203. if (\in_array($type, ['script-src-elem', 'style-src-elem'], true) || !isset($directiveSet['default-src'])) {
  204. // Let the browser fallback on it's own
  205. return null;
  206. }
  207. return $directiveSet['default-src'];
  208. }
  209. /**
  210. * Retrieves the Content-Security-Policy headers (either X-Content-Security-Policy or Content-Security-Policy) from
  211. * a response.
  212. */
  213. private function getCspHeaders(Response $response): array
  214. {
  215. $headers = [];
  216. if ($response->headers->has('Content-Security-Policy')) {
  217. $headers['Content-Security-Policy'] = $this->parseDirectives($response->headers->get('Content-Security-Policy'));
  218. }
  219. if ($response->headers->has('Content-Security-Policy-Report-Only')) {
  220. $headers['Content-Security-Policy-Report-Only'] = $this->parseDirectives($response->headers->get('Content-Security-Policy-Report-Only'));
  221. }
  222. if ($response->headers->has('X-Content-Security-Policy')) {
  223. $headers['X-Content-Security-Policy'] = $this->parseDirectives($response->headers->get('X-Content-Security-Policy'));
  224. }
  225. return $headers;
  226. }
  227. }