diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php index 6fa5bd7b4cc7..aa18ba9f1060 100644 --- a/app/Config/ContentSecurityPolicy.php +++ b/app/Config/ContentSecurityPolicy.php @@ -164,4 +164,25 @@ class ContentSecurityPolicy extends BaseConfig * @var string|string[]|null */ public $sandbox; + + /** + * Nonce tag for style + * + * @var string + */ + public $styleNonceTag = '{csp-style-nonce}'; + + /** + * Nonce tag for script + * + * @var string + */ + public $scriptNonceTag = '{csp-script-nonce}'; + + /** + * Replace nonce tag automatically + * + * @var bool + */ + public $autoNonce = true; } diff --git a/env b/env index c60b367265e7..83def018081f 100644 --- a/env +++ b/env @@ -60,7 +60,7 @@ # contentsecuritypolicy.scriptSrc = 'self' # contentsecuritypolicy.styleSrc = 'self' # contentsecuritypolicy.imageSrc = 'self' -# contentsecuritypolicy.base_uri = null +# contentsecuritypolicy.baseURI = null # contentsecuritypolicy.childSrc = null # contentsecuritypolicy.connectSrc = 'self' # contentsecuritypolicy.fontSrc = null @@ -73,6 +73,9 @@ # contentsecuritypolicy.reportURI = null # contentsecuritypolicy.sandbox = false # contentsecuritypolicy.upgradeInsecureRequests = false +# contentsecuritypolicy.styleNonceTag = '{csp-style-nonce}' +# contentsecuritypolicy.scriptNonceTag = '{csp-script-nonce}' +# contentsecuritypolicy.autoNonce = true #-------------------------------------------------------------------- # COOKIE diff --git a/system/Common.php b/system/Common.php index e2fa9bce5a33..690275c4fedf 100644 --- a/system/Common.php +++ b/system/Common.php @@ -288,6 +288,38 @@ function csrf_meta(?string $id = null): string } } +if (! function_exists('csp_style_nonce')) { + /** + * Generates a nonce attribute for style tag. + */ + function csp_style_nonce(): string + { + $csp = Services::csp(); + + if (! $csp->enabled()) { + return ''; + } + + return 'nonce="' . $csp->getStyleNonce() . '"'; + } +} + +if (! function_exists('csp_script_nonce')) { + /** + * Generates a nonce attribute for script tag. + */ + function csp_script_nonce(): string + { + $csp = Services::csp(); + + if (! $csp->enabled()) { + return ''; + } + + return 'nonce="' . $csp->getScriptNonce() . '"'; + } +} + if (! function_exists('db_connect')) { /** * Grabs a database connection and returns it to the user. diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index b50617d7ed83..38bac37fdaa9 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -28,6 +28,7 @@ use CodeIgniter\Format\Format; use CodeIgniter\Honeypot\Honeypot; use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\ContentSecurityPolicy; use CodeIgniter\HTTP\CURLRequest; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Negotiate; @@ -56,6 +57,7 @@ use Config\App; use Config\Autoload; use Config\Cache; +use Config\ContentSecurityPolicy as CSPConfig; use Config\Encryption; use Config\Exceptions as ConfigExceptions; use Config\Filters as ConfigFilters; @@ -94,6 +96,7 @@ * @method static CLIRequest clirequest(App $config = null, $getShared = true) * @method static CodeIgniter codeigniter(App $config = null, $getShared = true) * @method static Commands commands($getShared = true) + * @method static ContentSecurityPolicy csp(CSPConfig $config = null, $getShared = true) * @method static CURLRequest curlrequest($options = [], ResponseInterface $response = null, App $config = null, $getShared = true) * @method static Email email($config = null, $getShared = true) * @method static EncrypterInterface encrypter(Encryption $config = null, $getShared = false) diff --git a/system/Config/Services.php b/system/Config/Services.php index b72fad36eec7..2d44e3ccfd78 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -28,6 +28,7 @@ use CodeIgniter\Format\Format; use CodeIgniter\Honeypot\Honeypot; use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\ContentSecurityPolicy; use CodeIgniter\HTTP\CURLRequest; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Negotiate; @@ -56,6 +57,7 @@ use CodeIgniter\View\View; use Config\App; use Config\Cache; +use Config\ContentSecurityPolicy as CSPConfig; use Config\Email as EmailConfig; use Config\Encryption as EncryptionConfig; use Config\Exceptions as ExceptionsConfig; @@ -153,6 +155,22 @@ public static function commands(bool $getShared = true) return new Commands(); } + /** + * Content Security Policy + * + * @return ContentSecurityPolicy + */ + public static function csp(?CSPConfig $config = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('csp', $config); + } + + $config ??= config('ContentSecurityPolicy'); + + return new ContentSecurityPolicy($config); + } + /** * The CURL Request class acts as a simple HTTP client for interacting * with other servers, typically through APIs. diff --git a/system/Config/View.php b/system/Config/View.php index 5a8baeac2d6a..da4f81b1c716 100644 --- a/system/Config/View.php +++ b/system/Config/View.php @@ -76,6 +76,8 @@ class View extends BaseConfig * @var array */ protected $corePlugins = [ + 'csp_script_nonce' => '\CodeIgniter\View\Plugins::cspScriptNonce', + 'csp_style_nonce' => '\CodeIgniter\View\Plugins::cspStyleNonce', 'current_url' => '\CodeIgniter\View\Plugins::currentURL', 'previous_url' => '\CodeIgniter\View\Plugins::previousURL', 'mailto' => '\CodeIgniter\View\Plugins::mailto', diff --git a/system/Debug/Kint/RichRenderer.php b/system/Debug/Kint/RichRenderer.php index 756cac75e144..8210fb21e1c2 100644 --- a/system/Debug/Kint/RichRenderer.php +++ b/system/Debug/Kint/RichRenderer.php @@ -36,11 +36,11 @@ public function preRender() switch ($type) { case 'script': - $output .= ''; + $output .= ''; break; case 'style': - $output .= ''; + $output .= ''; break; default: diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 8e100fe25a6b..bf502f1f1563 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -402,11 +402,11 @@ public function prepare(?RequestInterface $request = null, ?ResponseInterface $r $kintScript = substr($kintScript, 0, strpos($kintScript, '') + 8); $script = PHP_EOL - . '' - . '' - . '' + . '' + . '' . $kintScript . PHP_EOL; diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index 3b18fc8bf1df..b7f9b066d7d8 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -176,6 +176,41 @@ class ContentSecurityPolicy */ protected $nonces = []; + /** + * Nonce for style + * + * @var string + */ + protected $styleNonce; + + /** + * Nonce for script + * + * @var string + */ + protected $scriptNonce; + + /** + * Nonce tag for style + * + * @var string + */ + protected $styleNonceTag = '{csp-style-nonce}'; + + /** + * Nonce tag for script + * + * @var string + */ + protected $scriptNonceTag = '{csp-script-nonce}'; + + /** + * Replace nonce tag automatically + * + * @var bool + */ + protected $autoNonce = true; + /** * An array of header info since we have * to build ourself before passing to Response. @@ -192,6 +227,13 @@ class ContentSecurityPolicy */ protected $reportOnlyHeaders = []; + /** + * Whether Content Security Policy is being enforced. + * + * @var bool + */ + protected $CSPEnabled = false; + /** * Constructor. * @@ -199,11 +241,56 @@ class ContentSecurityPolicy */ public function __construct(ContentSecurityPolicyConfig $config) { + $appConfig = config('App'); + $this->CSPEnabled = $appConfig->CSPEnabled; + foreach (get_object_vars($config) as $setting => $value) { if (property_exists($this, $setting)) { $this->{$setting} = $value; } } + + if (! is_array($this->styleSrc)) { + $this->styleSrc = [$this->styleSrc]; + } + + if (! is_array($this->scriptSrc)) { + $this->scriptSrc = [$this->scriptSrc]; + } + } + + /** + * 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 = bin2hex(random_bytes(12)); + $this->styleSrc[] = 'nonce-' . $this->styleNonce; + } + + return $this->styleNonce; + } + + /** + * Get the nonce for the script tag. + */ + public function getScriptNonce(): string + { + if ($this->scriptNonce === null) { + $this->scriptNonce = bin2hex(random_bytes(12)); + $this->scriptSrc[] = 'nonce-' . $this->scriptNonce; + } + + return $this->scriptNonce; } /** @@ -213,6 +300,10 @@ public function __construct(ContentSecurityPolicyConfig $config) */ public function finalize(ResponseInterface &$response) { + if ($this->autoNonce === false) { + return; + } + $this->generateNonces($response); $this->buildHeaders($response); } @@ -580,28 +671,18 @@ protected function generateNonces(ResponseInterface &$response) return; } - if (! is_array($this->styleSrc)) { - $this->styleSrc = [$this->styleSrc]; - } - - if (! is_array($this->scriptSrc)) { - $this->scriptSrc = [$this->scriptSrc]; - } - // Replace style placeholders with nonces - $body = preg_replace_callback('/{csp-style-nonce}/', function () { - $nonce = bin2hex(random_bytes(12)); - - $this->styleSrc[] = 'nonce-' . $nonce; + $pattern = '/' . preg_quote($this->styleNonceTag, '/') . '/'; + $body = preg_replace_callback($pattern, function () { + $nonce = $this->getStyleNonce(); return "nonce=\"{$nonce}\""; }, $body); // Replace script placeholders with nonces - $body = preg_replace_callback('/{csp-script-nonce}/', function () { - $nonce = bin2hex(random_bytes(12)); - - $this->scriptSrc[] = 'nonce-' . $nonce; + $pattern = '/' . preg_quote($this->scriptNonceTag, '/') . '/'; + $body = preg_replace_callback($pattern, function () { + $nonce = $this->getScriptNonce(); return "nonce=\"{$nonce}\""; }, $body); diff --git a/system/HTTP/Response.php b/system/HTTP/Response.php index 8e2cc472cc00..493c990e1eae 100644 --- a/system/HTTP/Response.php +++ b/system/HTTP/Response.php @@ -16,7 +16,7 @@ use CodeIgniter\Cookie\Exceptions\CookieException; use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\App; -use Config\ContentSecurityPolicy as CSPConfig; +use Config\Services; /** * Representation of an outgoing, getServer-side response. @@ -152,7 +152,7 @@ public function __construct($config) $this->noCache(); // We need CSP object even if not enabled to avoid calls to non existing methods - $this->CSP = new ContentSecurityPolicy(new CSPConfig()); + $this->CSP = Services::csp(); $this->CSPEnabled = $config->CSPEnabled; diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index 278143fd3c3a..412e7b970e7c 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -38,6 +38,8 @@ trait ResponseTrait * Whether Content Security Policy is being enforced. * * @var bool + * + * @deprecated Use $this->CSP->enabled() instead. */ protected $CSPEnabled = false; @@ -433,7 +435,7 @@ public function send() { // If we're enforcing a Content Security Policy, // we need to give it a chance to build out it's headers. - if ($this->CSPEnabled === true) { + if ($this->CSP->enabled()) { $this->CSP->finalize($this); } else { $this->body = str_replace(['{csp-style-nonce}', '{csp-script-nonce}'], '', $this->body ?? ''); diff --git a/system/View/Plugins.php b/system/View/Plugins.php index d0e170071a6a..b74ad96bd385 100644 --- a/system/View/Plugins.php +++ b/system/View/Plugins.php @@ -103,4 +103,20 @@ public static function siteURL(array $params = []): string { return site_url(...$params); } + + /** + * Wrap csp_script_nonce() function to use as view plugin. + */ + public static function cspScriptNonce(): string + { + return csp_script_nonce(); + } + + /** + * Wrap csp_style_nonce() function to use as view plugin. + */ + public static function cspStyleNonce(): string + { + return csp_style_nonce(); + } } diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index 3916a1ab785f..15a511926d3c 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -519,19 +519,19 @@ public function testIsCli() public function testDWithCSP() { + $this->resetServices(); + /** @var App $config */ - $config = config(App::class); - $CSPEnabled = $config->CSPEnabled; + $config = config('App'); $cliDetection = Kint::$cli_detection; $config->CSPEnabled = true; Kint::$cli_detection = false; - $this->expectOutputRegex('/