Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
20 / 20
CRAP
100.00% covered (success)
100.00%
1 / 1
CSP
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
20 / 20
32
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 addValues
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 sanitizeValue
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 setDirective
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setDirectives
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getDirective
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDirectives
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeDirective
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addNonce
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getNonceAttr
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getScriptNonceAttr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStyleNonceAttr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStyleHashes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStyleContents
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getScriptHashes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getScriptContents
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 makeHashes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 makeHash
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework HTTP Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\HTTP;
11
12use InvalidArgumentException;
13use LogicException;
14
15/**
16 * Class CSP.
17 *
18 * @see https://content-security-policy.com/
19 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
20 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
21 *
22 * @package http
23 */
24class CSP implements \Stringable
25{
26    /**
27     * Restricts the URLs which can be used in a document's `<base>` element.
28     *
29     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/base-uri
30     *
31     * @var string
32     */
33    public const baseUri = 'base-uri';
34    /**
35     * Defines the valid sources for web workers and nested browsing contexts
36     * loaded using elements such as `<frame>` and `<iframe>`.
37     *
38     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/child-src
39     *
40     * @var string
41     */
42    public const childSrc = 'child-src';
43    /**
44     * Restricts the URLs which can be loaded using script interfaces.
45     *
46     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
47     *
48     * @var string
49     */
50    public const connectSrc = 'connect-src';
51    /**
52     * Serves as a fallback for the other fetch directives.
53     *
54     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
55     *
56     * @var string
57     */
58    public const defaultSrc = 'default-src';
59    /**
60     * Specifies valid sources for fonts loaded using `@font-face`.
61     *
62     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/font-src
63     *
64     * @var string
65     */
66    public const fontSrc = 'font-src';
67    /**
68     * Restricts the URLs which can be used as the target of a form submissions
69     * from a given context.
70     *
71     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/form-action
72     *
73     * @var string
74     */
75    public const formAction = 'form-action';
76    /**
77     * Specifies valid parents that may embed a page using `<frame>`, `<iframe>`,
78     * `<object>`, `<embed>`, or `<applet>`.
79     *
80     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors
81     *
82     * @var string
83     */
84    public const frameAncestors = 'frame-ancestors';
85    /**
86     * Specifies valid sources for nested browsing contexts loading using
87     * elements such as `<frame>` and `<iframe>`.
88     *
89     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src
90     *
91     * @var string
92     */
93    public const frameSrc = 'frame-src';
94    /**
95     * Specifies valid sources of images and favicons.
96     *
97     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src
98     *
99     * @var string
100     */
101    public const imgSrc = 'img-src';
102    /**
103     * Specifies valid sources of application manifest files.
104     *
105     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/manifest-src
106     *
107     * @var string
108     */
109    public const manifestSrc = 'manifest-src';
110    /**
111     * Specifies valid sources for loading media using the `<audio>`, `<video>`
112     * and `<track>` elements.
113     *
114     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/media-src
115     *
116     * @var string
117     */
118    public const mediaSrc = 'media-src';
119    /**
120     * Restricts the URLs to which a document can initiate navigation by any
121     * means, including `<form>` (if form-action is not specified), `<a>`,
122     * `window.location`, `window.open`, etc.
123     *
124     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/navigate-to
125     *
126     * @var string
127     */
128    public const navigateTo = 'navigate-to';
129    /**
130     * Specifies valid sources for the `<object>`, `<embed>`, and `<applet>`
131     * elements.
132     *
133     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/object-src
134     *
135     * @var string
136     */
137    public const objectSrc = 'object-src';
138    /**
139     * Restricts the set of plugins that can be embedded into a document by
140     * limiting the types of resources which can be loaded.
141     *
142     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/plugin-types
143     * @deprecated
144     *
145     * @var string
146     */
147    public const pluginTypes = 'plugin-types';
148    /**
149     * Specifies valid sources to be prefetched or prerendered.
150     *
151     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/prefetch-src
152     * @deprecated
153     *
154     * @var string
155     */
156    public const prefetchSrc = 'prefetch-src';
157    /**
158     * Fires a SecurityPolicyViolationEvent.
159     *
160     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-to
161     *
162     * @var string
163     */
164    public const reportTo = 'report-to';
165    /**
166     * Instructs the user agent to report attempts to violate the Content
167     * Security Policy. These violation reports consist of JSON documents sent
168     * via an HTTP POST request to the specified URI.
169     *
170     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri
171     * @deprecated
172     *
173     * @var string
174     */
175    public const reportUri = 'report-uri';
176    /**
177     * Enables a sandbox for the requested resource similar to the `<iframe>`
178     * sandbox attribute.
179     *
180     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox
181     *
182     * @var string
183     */
184    public const sandbox = 'sandbox';
185    /**
186     * Specifies valid sources for JavaScript and WebAssembly resources.
187     *
188     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
189     *
190     * @var string
191     */
192    public const scriptSrc = 'script-src';
193    /**
194     * Specifies valid sources for JavaScript inline event handlers.
195     *
196     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src-attr
197     *
198     * @var string
199     */
200    public const scriptSrcAttr = 'script-src-attr';
201    /**
202     * Specifies valid sources for JavaScript `<script>` elements.
203     *
204     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src-elem
205     *
206     * @var string
207     */
208    public const scriptSrcElem = 'script-src-elem';
209    /**
210     * Specifies valid sources for stylesheets.
211     *
212     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src
213     *
214     * @var string
215     */
216    public const styleSrc = 'style-src';
217    /**
218     * Specifies valid sources for inline styles applied to individual DOM
219     * elements.
220     *
221     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src-attr
222     *
223     * @var string
224     */
225    public const styleSrcAttr = 'style-src-attr';
226    /**
227     * Specifies valid sources for stylesheets `<style>` elements and `<link>`
228     * elements with `rel="stylesheet"`.
229     *
230     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src-elem
231     *
232     * @var string
233     */
234    public const styleSrcElem = 'style-src-elem';
235    /**
236     * Instructs user agents to treat all of a site's insecure URLs (those
237     * served over HTTP) as though they have been replaced with secure URLs
238     * (those served over HTTPS). This directive is intended for websites with
239     * large numbers of insecure legacy URLs that need to be rewritten.
240     *
241     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/upgrade-insecure-requests
242     *
243     * @var string
244     */
245    public const upgradeInsecureRequests = 'upgrade-insecure-requests';
246    /**
247     * Specifies valid sources for Worker, SharedWorker, or ServiceWorker
248     * scripts.
249     *
250     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/worker-src
251     *
252     * @var string
253     */
254    public const workerSrc = 'worker-src';
255    /**
256     * @var array<string,array<string>>
257     */
258    protected array $directives = [];
259
260    /**
261     * @param array<string,array<string>> $directives
262     */
263    public function __construct(array $directives = [])
264    {
265        if ($directives) {
266            $this->setDirectives($directives);
267        }
268    }
269
270    public function __toString() : string
271    {
272        return $this->render();
273    }
274
275    public function render() : string
276    {
277        if (empty($this->directives)) {
278            throw new LogicException('No CSP directive has been set');
279        }
280        $directives = [];
281        foreach ($this->directives as $name => $values) {
282            $values = \implode(' ', $values);
283            $directive = $name . ' ' . $values;
284            $directives[] = \trim($directive);
285        }
286        return \implode('; ', $directives) . ';';
287    }
288
289    /**
290     * @param string $directive
291     * @param array<string>|string $values
292     *
293     * @return static
294     */
295    public function addValues(string $directive, array | string $values) : static
296    {
297        $directive = \strtolower($directive);
298        $values = (array) $values;
299        $this->directives[$directive] ??= [];
300        foreach ($values as $value) {
301            $this->directives[$directive][] = $this->sanitizeValue($value);
302        }
303        return $this;
304    }
305
306    protected function sanitizeValue(string $value) : string
307    {
308        if (\in_array($value, [
309                'none',
310                'self',
311                'strict-dynamic',
312                'unsafe-eval',
313                'unsafe-hashes',
314                'unsafe-inline',
315            ])
316            || \str_starts_with($value, 'nonce-')
317            || \str_starts_with($value, 'sha256-')
318            || \str_starts_with($value, 'sha384-')
319            || \str_starts_with($value, 'sha512-')
320        ) {
321            return "'{$value}'";
322        }
323        return \trim($value);
324    }
325
326    /**
327     * @param string $name
328     * @param array<string>|string $values
329     *
330     * @return static
331     */
332    public function setDirective(string $name, array | string $values) : static
333    {
334        $values = (array) $values;
335        foreach ($values as &$value) {
336            $value = $this->sanitizeValue($value);
337        }
338        unset($value);
339        $this->directives[\strtolower($name)] = $values;
340        return $this;
341    }
342
343    /**
344     * @param array<string,array<string>> $directives
345     *
346     * @return static
347     */
348    public function setDirectives(array $directives) : static
349    {
350        foreach ($directives as $name => $values) {
351            $this->setDirective($name, $values);
352        }
353        return $this;
354    }
355
356    /**
357     * @param string $name
358     *
359     * @return array<string>|null
360     */
361    public function getDirective(string $name) : array | null
362    {
363        return $this->directives[\strtolower($name)] ?? null;
364    }
365
366    /**
367     * @return array<string,array<string>>
368     */
369    public function getDirectives() : array
370    {
371        return $this->directives;
372    }
373
374    public function removeDirective(string $name) : static
375    {
376        unset($this->directives[\strtolower($name)]);
377        return $this;
378    }
379
380    /**
381     * @param string $type
382     *
383     * @see https://content-security-policy.com/nonce/
384     *
385     * @return string
386     */
387    protected function addNonce(string $type) : string
388    {
389        $nonce = \bin2hex(\random_bytes(8));
390        $this->addValues($type, "'nonce-{$nonce}'");
391        return $nonce;
392    }
393
394    protected function getNonceAttr(string $type) : string
395    {
396        $nonce = match ($type) {
397            static::scriptSrc => $this->addNonce(static::scriptSrc),
398            static::styleSrc => $this->addNonce(static::styleSrc),
399            default => throw new InvalidArgumentException(
400                'Invalid CSP directive: ' . $type
401            ),
402        };
403        return ' nonce="' . $nonce . '"';
404    }
405
406    /**
407     * Creates a nonce, adds it to the script-src directive, and returns the
408     * attribute to be inserted into the script tag.
409     *
410     * @return string the nonce attribute
411     */
412    public function getScriptNonceAttr() : string
413    {
414        return $this->getNonceAttr(static::scriptSrc);
415    }
416
417    /**
418     * Creates a nonce, adds it to the style-src directive, and returns the
419     * attribute to be inserted into the style tag.
420     *
421     * @return string the nonce attribute
422     */
423    public function getStyleNonceAttr() : string
424    {
425        return $this->getNonceAttr(static::styleSrc);
426    }
427
428    /**
429     * @param string $html
430     *
431     * @return array<string>
432     */
433    public static function getStyleHashes(string $html) : array
434    {
435        return static::makeHashes(static::getStyleContents($html));
436    }
437
438    /**
439     * @see https://stackoverflow.com/a/72636724
440     * @see https://stackoverflow.com/a/50124875
441     *
442     * @param string $html
443     *
444     * @return array<string>
445     */
446    public static function getStyleContents(string $html) : array
447    {
448        \preg_match_all(
449            '#<style[\w="\'\s-]*>([^<]+)</style>#i',
450            $html,
451            $matches
452        );
453        return $matches[1];
454    }
455
456    /**
457     * @param string $html
458     *
459     * @return array<string>
460     */
461    public static function getScriptHashes(string $html) : array
462    {
463        return static::makeHashes(static::getScriptContents($html));
464    }
465
466    /**
467     * @param string $html
468     *
469     * @return array<string>
470     */
471    public static function getScriptContents(string $html) : array
472    {
473        \preg_match_all(
474            '#<script[\w="\'\s-]*>([^<]+)</script>#i',
475            $html,
476            $matches
477        );
478        return $matches[1];
479    }
480
481    /**
482     * @param array<string> $contents
483     * @param string $algo
484     *
485     * @return array<string>
486     */
487    public static function makeHashes(
488        array $contents,
489        string $algo = 'sha256'
490    ) : array {
491        $hashes = [];
492        foreach ($contents as $content) {
493            $hashes[] = static::makeHash($algo, $content);
494        }
495        return $hashes;
496    }
497
498    /**
499     * @see https://content-security-policy.com/hash/
500     * @see https://security.stackexchange.com/q/58789
501     *
502     * @param string $algo
503     * @param string $content
504     *
505     * @return string
506     */
507    public static function makeHash(string $algo, string $content) : string
508    {
509        $content = \hash($algo, $content, true);
510        $content = \base64_encode($content);
511        return $algo . '-' . $content;
512    }
513}