* * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace CodeIgniter\HTTP; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\App; use Config\ContentSecurityPolicy as ContentSecurityPolicyConfig; /** * Provides tools for working with the Content-Security-Policy header * to help defeat XSS attacks. * * @see http://www.w3.org/TR/CSP/ * @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/ * @see http://content-security-policy.com/ * @see https://www.owasp.org/index.php/Content_Security_Policy * @see \CodeIgniter\HTTP\ContentSecurityPolicyTest */ class ContentSecurityPolicy { private const DIRECTIVES_ALLOWING_SOURCE_LISTS = [ 'base-uri' => 'baseURI', 'child-src' => 'childSrc', 'connect-src' => 'connectSrc', 'default-src' => 'defaultSrc', 'font-src' => 'fontSrc', 'form-action' => 'formAction', 'frame-ancestors' => 'frameAncestors', 'frame-src' => 'frameSrc', 'img-src' => 'imageSrc', 'media-src' => 'mediaSrc', 'object-src' => 'objectSrc', 'plugin-types' => 'pluginTypes', 'script-src' => 'scriptSrc', 'style-src' => 'styleSrc', 'sandbox' => 'sandbox', 'manifest-src' => 'manifestSrc', 'script-src-elem' => 'scriptSrcElem', 'script-src-attr' => 'scriptSrcAttr', 'style-src-elem' => 'styleSrcElem', 'style-src-attr' => 'styleSrcAttr', 'worker-src' => 'workerSrc', ]; /** * Map of CSP directives to this class's properties. * * @var array */ protected array $directives = [ ...self::DIRECTIVES_ALLOWING_SOURCE_LISTS, 'report-uri' => 'reportURI', 'report-to' => 'reportTo', ]; /** * The `base-uri` directive restricts the URLs that can be used to specify the document base URL. * * @var array|string|null */ protected $baseURI = []; /** * The `child-src` directive governs the creation of nested browsing contexts as well * as Worker execution contexts. * * @var array|string */ protected $childSrc = []; /** * The `connect-src` directive restricts which URLs the protected resource can load using script interfaces. * * @var array|string */ protected $connectSrc = []; /** * The `default-src` directive sets a default source list for a number of directives. * * @var array|string|null */ protected $defaultSrc = []; /** * The `font-src` directive restricts from where the protected resource can load fonts. * * @var array|string */ protected $fontSrc = []; /** * The `form-action` directive restricts which URLs can be used as the action of HTML form elements. * * @var array|string */ protected $formAction = []; /** * The `frame-ancestors` directive indicates whether the user agent should allow embedding * the resource using a `frame`, `iframe`, `object`, `embed` or `applet` element, * or equivalent functionality in non-HTML resources. * * @var array|string */ protected $frameAncestors = []; /** * The `frame-src` directive restricts the URLs which may be loaded into child navigables. * * @var array|string */ protected $frameSrc = []; /** * The `img-src` directive restricts from where the protected resource can load images. * * @var array|string */ protected $imageSrc = []; /** * The `media-src` directive restricts from where the protected resource can load video, * audio, and associated text tracks. * * @var array|string */ protected $mediaSrc = []; /** * The `object-src` directive restricts from where the protected resource can load plugins. * * @var array|string */ protected $objectSrc = []; /** * The `plugin-types` directive restricts the set of plugins that can be invoked by the * protected resource by limiting the types of resources that can be embedded. * * @var array|string */ protected $pluginTypes = []; /** * The `script-src` directive restricts which scripts the protected resource can execute. * * @var array|string */ protected $scriptSrc = []; /** * The `style-src` directive restricts which styles the user may applies to the protected resource. * * @var array|string */ protected $styleSrc = []; /** * The `sandbox` directive specifies an HTML sandbox policy that the user agent applies to the protected resource. * * @var array|string */ protected $sandbox = []; /** * The `report-uri` directive specifies a URL to which the user agent sends reports about policy violation. * * @var string|null */ protected $reportURI; /** * The `report-to` directive specifies a named group in a Reporting API * endpoint to which the user agent sends reports about policy violation. */ protected ?string $reportTo = null; // -------------------------------------------------------------- // CSP Level 3 Directives // -------------------------------------------------------------- /** * The `manifest-src` directive restricts the URLs from which application manifests may be loaded. * * @var array|string */ protected $manifestSrc = []; /** * The `script-src-elem` directive applies to all script requests and script blocks. * * @var array|string */ protected array|string $scriptSrcElem = []; /** * The `script-src-attr` directive applies to event handlers and, if present, * it will override the `script-src` directive for relevant checks. * * @var array|string */ protected array|string $scriptSrcAttr = []; /** * The `style-src-elem` directive governs the behaviour of styles except * for styles defined in inline attributes. * * @var array|string */ protected array|string $styleSrcElem = []; /** * The `style-src-attr` directive governs the behaviour of style attributes. * * @var array|string */ protected array|string $styleSrcAttr = []; /** * The `worker-src` directive restricts the URLs which may be loaded as a `Worker`, * `SharedWorker`, or `ServiceWorker`. * * @var array|string */ protected array|string $workerSrc = []; /** * Instructs user agents to rewrite URL schemes by changing HTTP to HTTPS. * * @var bool */ protected $upgradeInsecureRequests = false; /** * Set to `true` to make all directives report-only instead of enforced. * * @var bool */ protected $reportOnly = false; /** * Set of valid keyword-sources. * * @see https://www.w3.org/TR/CSP3/#source-expression * * @var list */ protected $validSources = [ // CSP2 keywords 'self', 'none', 'unsafe-inline', 'unsafe-eval', // CSP3 keywords 'strict-dynamic', 'unsafe-hashes', 'report-sample', 'unsafe-allow-redirects', 'wasm-unsafe-eval', 'trusted-types-eval', 'report-sha256', 'report-sha384', 'report-sha512', ]; /** * Set of nonces generated. * * @var list * * @deprecated 4.7.0 Never used. */ protected $nonces = []; /** * Nonce for style tags. * * @var string|null */ protected $styleNonce; /** * Nonce for script tags. * * @var string|null */ protected $scriptNonce; /** * Nonce placeholder for style tags. * * @var string */ protected $styleNonceTag = '{csp-style-nonce}'; /** * Nonce placeholder for script tags. * * @var string */ protected $scriptNonceTag = '{csp-script-nonce}'; /** * Replace nonce tags automatically? * * @var bool */ protected $autoNonce = true; /** * An array of header info since we have to build * ourselves before passing to a Response object. * * @var array */ protected $tempHeaders = []; /** * An array of header info to build that should only be reported. * * @var array */ protected $reportOnlyHeaders = []; /** * Whether Content Security Policy is being enforced. * * @var bool */ protected $CSPEnabled = false; /** * Map of reporting endpoints to their URLs. * * @var array */ private array $reportingEndpoints = []; /** * Stores our default values from the Config file. */ public function __construct(ContentSecurityPolicyConfig $config) { $this->CSPEnabled = config(App::class)->CSPEnabled; foreach (get_object_vars($config) as $setting => $value) { if (! property_exists($this, $setting)) { continue; } if ( in_array($setting, self::DIRECTIVES_ALLOWING_SOURCE_LISTS, true) && is_array($value) && array_is_list($value) ) { // Config sets these directives as `list|string` // but we need them as `array` internally. $this->{$setting} = array_combine($value, array_fill(0, count($value), $this->reportOnly)); continue; } $this->{$setting} = $value; } if (! is_array($this->styleSrc)) { $this->styleSrc = [$this->styleSrc => $this->reportOnly]; } if (! is_array($this->scriptSrc)) { $this->scriptSrc = [$this->scriptSrc => $this->reportOnly]; } } /** * Whether Content Security Policy is being enforced. */ public function enabled(): bool { return $this->CSPEnabled; } /** * Get the nonce for the style tag. */ public function getStyleNonce(): string { if ($this->styleNonce === null) { $this->styleNonce = base64_encode(random_bytes(12)); $this->addStyleSrc('nonce-' . $this->styleNonce); } return $this->styleNonce; } /** * Get the nonce for the script tag. */ public function getScriptNonce(): string { if ($this->scriptNonce === null) { $this->scriptNonce = base64_encode(random_bytes(12)); $this->addScriptSrc('nonce-' . $this->scriptNonce); } return $this->scriptNonce; } /** * Compiles and sets the appropriate headers in the request. * * Should be called just prior to sending the response to the user agent. * * @return void */ public function finalize(ResponseInterface $response) { if ($this->autoNonce) { $this->generateNonces($response); } $this->buildHeaders($response); } /** * If TRUE, nothing will be restricted. Instead all violations will * be reported to the reportURI for monitoring. This is useful when * you are just starting to implement the policy, and will help * determine what errors need to be addressed before you turn on * all filtering. * * @return $this */ public function reportOnly(bool $value = true) { $this->reportOnly = $value; return $this; } /** * Adds a new value to the `base-uri` directive. * * `base-uri` restricts the URLs that can appear in a page's element. * * @see http://www.w3.org/TR/CSP/#directive-base-uri * * @param list|string $uri * * @return $this */ public function addBaseURI($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'baseURI', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `child-src` directive. * * `child-src` lists the URLs for workers and embedded frame contents. * For example: child-src https://youtube.com would enable embedding * videos from YouTube but not from other origins. * * @see http://www.w3.org/TR/CSP/#directive-child-src * * @param list|string $uri * * @return $this */ public function addChildSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'childSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `connect-src` directive. * * `connect-src` limits the origins to which you can connect * (via XHR, WebSockets, and EventSource). * * @see http://www.w3.org/TR/CSP/#directive-connect-src * * @param list|string $uri * * @return $this */ public function addConnectSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'connectSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `default-src` directive. * * `default-src` is the URI that is used for many of the settings when * no other source has been set. * * @see http://www.w3.org/TR/CSP/#directive-default-src * * @param list|string $uri * * @return $this */ public function setDefaultSrc($uri, ?bool $explicitReporting = null) { $this->defaultSrc = [(string) $uri => $explicitReporting ?? $this->reportOnly]; return $this; } /** * Adds a new value to the `font-src` directive. * * `font-src` specifies the origins that can serve web fonts. * * @see http://www.w3.org/TR/CSP/#directive-font-src * * @param list|string $uri * * @return $this */ public function addFontSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'fontSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `form-action` directive. * * @see http://www.w3.org/TR/CSP/#directive-form-action * * @param list|string $uri * * @return $this */ public function addFormAction($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'formAction', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `frame-ancestors` directive. * * @see http://www.w3.org/TR/CSP/#directive-frame-ancestors * * @param list|string $uri * * @return $this */ public function addFrameAncestor($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'frameAncestors', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `frame-src` directive. * * @see http://www.w3.org/TR/CSP/#directive-frame-src * * @param list|string $uri * * @return $this */ public function addFrameSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'frameSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `img-src` directive. * * @see http://www.w3.org/TR/CSP/#directive-img-src * * @param list|string $uri * * @return $this */ public function addImageSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'imageSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `media-src` directive. * * @see http://www.w3.org/TR/CSP/#directive-media-src * * @param list|string $uri * * @return $this */ public function addMediaSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'mediaSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `manifest-src` directive. * * @see https://www.w3.org/TR/CSP/#directive-manifest-src * * @param list|string $uri * * @return $this */ public function addManifestSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'manifestSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `object-src` directive. * * @see http://www.w3.org/TR/CSP/#directive-object-src * * @param list|string $uri * * @return $this */ public function addObjectSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'objectSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `plugin-types` directive. * * @see http://www.w3.org/TR/CSP/#directive-plugin-types * * @param list|string $mime * * @return $this */ public function addPluginType($mime, ?bool $explicitReporting = null) { $this->addOption($mime, 'pluginTypes', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `sandbox` directive. * * @see http://www.w3.org/TR/CSP/#directive-sandbox * * @param list|string $flags * * @return $this */ public function addSandbox($flags, ?bool $explicitReporting = null) { $this->addOption($flags, 'sandbox', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `script-src` directive. * * @see http://www.w3.org/TR/CSP/#directive-script-src * * @param list|string $uri * * @return $this */ public function addScriptSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'scriptSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `script-src-elem` directive. * * @see https://www.w3.org/TR/CSP/#directive-script-src-elem * * @param list|string $uri */ public function addScriptSrcElem(array|string $uri, ?bool $explicitReporting = null): static { $this->addOption($uri, 'scriptSrcElem', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `script-src-attr` directive. * * @see https://www.w3.org/TR/CSP/#directive-script-src-attr * * @param list|string $uri */ public function addScriptSrcAttr(array|string $uri, ?bool $explicitReporting = null): static { $this->addOption($uri, 'scriptSrcAttr', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `style-src` directive. * * @see http://www.w3.org/TR/CSP/#directive-style-src * * @param list|string $uri * * @return $this */ public function addStyleSrc($uri, ?bool $explicitReporting = null) { $this->addOption($uri, 'styleSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `style-src-elem` directive. * * @see https://www.w3.org/TR/CSP/#directive-style-src-elem * * @param list|string $uri */ public function addStyleSrcElem(array|string $uri, ?bool $explicitReporting = null): static { $this->addOption($uri, 'styleSrcElem', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `style-src-attr` directive. * * @see https://www.w3.org/TR/CSP/#directive-style-src-attr * * @param list|string $uri */ public function addStyleSrcAttr(array|string $uri, ?bool $explicitReporting = null): static { $this->addOption($uri, 'styleSrcAttr', $explicitReporting ?? $this->reportOnly); return $this; } /** * Adds a new value to the `worker-src` directive. * * @see https://www.w3.org/TR/CSP/#directive-worker-src * * @param list|string $uri */ public function addWorkerSrc($uri, ?bool $explicitReporting = null): static { $this->addOption($uri, 'workerSrc', $explicitReporting ?? $this->reportOnly); return $this; } /** * Sets whether the user agents should rewrite URL schemes, changing HTTP to HTTPS. * * @return $this */ public function upgradeInsecureRequests(bool $value = true) { $this->upgradeInsecureRequests = $value; return $this; } /** * Specifies a URL where a browser will send reports when a content * security policy is violated. * * @see http://www.w3.org/TR/CSP/#directive-report-uri * * @param string $uri URL to send reports. Set `''` if you want to remove * this directive at runtime. * * @return $this */ public function setReportURI(string $uri) { $this->reportURI = $uri; return $this; } /** * Specifies a named group in a Reporting API endpoint to which the user * agent sends reports about policy violation. * * @see https://www.w3.org/TR/CSP/#directive-report-to * * @param string $endpoint The name of the reporting endpoint. Set `''` if you * want to remove this directive at runtime. */ public function setReportToEndpoint(string $endpoint): static { if ($endpoint === '') { $this->reportURI = null; $this->reportTo = null; return $this; } if (! array_key_exists($endpoint, $this->reportingEndpoints)) { throw new InvalidArgumentException(sprintf('The reporting endpoint "%s" has not been defined.', $endpoint)); } $this->reportURI = $this->reportingEndpoints[$endpoint]; // for BC with browsers that do not support `report-to` $this->reportTo = $endpoint; return $this; } /** * Adds reporting endpoints to the `Reporting-Endpoints` header. * * @param array $endpoint */ public function addReportingEndpoints(array $endpoint): static { foreach ($endpoint as $name => $url) { $this->reportingEndpoints[$name] = $url; } return $this; } /** * DRY method to add an string or array to a class property. * * @param list|string $options * * @return void */ protected function addOption($options, string $target, ?bool $explicitReporting = null) { // Ensure we have an array to work with... if (is_string($this->{$target})) { $this->{$target} = [$this->{$target} => $this->reportOnly]; } $options = is_array($options) ? $options : [$options]; foreach ($options as $option) { $this->{$target}[$option] = $explicitReporting ?? $this->reportOnly; } } /** * Scans the body of the request message and replaces any nonce * placeholders with actual nonces, that we'll then add to our * headers. * * @return void */ protected function generateNonces(ResponseInterface $response) { $body = (string) $response->getBody(); if ($body === '') { return; } // Replace style and script placeholders with nonces $pattern = sprintf('/(%s|%s)/', preg_quote($this->styleNonceTag, '/'), preg_quote($this->scriptNonceTag, '/')); $body = preg_replace_callback($pattern, function ($match): string { $nonce = $match[0] === $this->styleNonceTag ? $this->getStyleNonce() : $this->getScriptNonce(); return "nonce=\"{$nonce}\""; }, $body); $response->setBody($body); } /** * Based on the current state of the elements, will add the appropriate * Content-Security-Policy and Content-Security-Policy-Report-Only headers * with their values to the response object. * * @return void */ protected function buildHeaders(ResponseInterface $response) { $response->setHeader('Content-Security-Policy', []); $response->setHeader('Content-Security-Policy-Report-Only', []); $response->setHeader('Reporting-Endpoints', []); if (in_array($this->baseURI, ['', null, []], true)) { $this->baseURI = 'self'; } if (in_array($this->defaultSrc, ['', null, []], true)) { $this->defaultSrc = 'self'; } foreach ($this->directives as $name => $property) { if ($name === 'report-uri' && (string) $this->reportURI === '') { continue; } if ($name === 'report-to' && (string) $this->reportTo === '') { continue; } if ($this->{$property} !== null) { $this->addToHeader($name, $this->{$property}); } } // Compile our own header strings here since if we just // append it to the response, it will be joined with // commas, not semi-colons as we need. if ($this->reportingEndpoints !== []) { $endpoints = []; foreach ($this->reportingEndpoints as $name => $url) { $endpoints[] = trim("{$name}=\"{$url}\""); } $response->appendHeader('Reporting-Endpoints', implode(', ', $endpoints)); $this->reportingEndpoints = []; } if ($this->tempHeaders !== []) { $header = []; foreach ($this->tempHeaders as $name => $value) { $header[] = trim("{$name} {$value}"); } if ($this->upgradeInsecureRequests) { $header[] = 'upgrade-insecure-requests'; } $response->appendHeader('Content-Security-Policy', implode('; ', $header)); $this->tempHeaders = []; } if ($this->reportOnlyHeaders !== []) { $header = []; foreach ($this->reportOnlyHeaders as $name => $value) { $header[] = trim("{$name} {$value}"); } $response->appendHeader('Content-Security-Policy-Report-Only', implode('; ', $header)); $this->reportOnlyHeaders = []; } } /** * Adds a directive and its options to the appropriate header. The $values * array might have options that are geared toward either the regular or the * reportOnly header, since it's viable to have both simultaneously. * * @param array|string $values * * @return void */ protected function addToHeader(string $name, $values = null) { if (is_string($values)) { $values = [$values => $this->reportOnly]; } $sources = []; $reportSources = []; foreach ($values as $value => $reportOnly) { if ( in_array($value, $this->validSources, true) || str_starts_with($value, 'nonce-') || str_starts_with($value, 'sha256-') || str_starts_with($value, 'sha384-') || str_starts_with($value, 'sha512-') ) { $value = "'{$value}'"; } if ($reportOnly) { $reportSources[] = $value; } else { $sources[] = $value; } } if ($sources !== []) { $this->tempHeaders[$name] = implode(' ', $sources); } if ($reportSources !== []) { $this->reportOnlyHeaders[$name] = implode(' ', $reportSources); } } public function clearDirective(string $directive): void { if (! array_key_exists($directive, $this->directives)) { return; } if ($directive === 'report-uri') { $this->reportURI = null; return; } if ($directive === 'report-to') { $this->reportURI = null; $this->reportTo = null; return; } $this->{$this->directives[$directive]} = []; } }