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