Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
78 / 78 |
|
100.00% |
20 / 20 |
CRAP | |
100.00% |
1 / 1 |
CSP | |
100.00% |
78 / 78 |
|
100.00% |
20 / 20 |
32 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
__toString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
render | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
addValues | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
sanitizeValue | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
6 | |||
setDirective | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
setDirectives | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getDirective | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDirectives | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
removeDirective | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
addNonce | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getNonceAttr | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getScriptNonceAttr | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getStyleNonceAttr | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getStyleHashes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getStyleContents | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
getScriptHashes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getScriptContents | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
makeHashes | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
makeHash | |
100.00% |
3 / 3 |
|
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 | */ |
10 | namespace Framework\HTTP; |
11 | |
12 | use InvalidArgumentException; |
13 | use 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 | */ |
24 | class 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 | } |