From f43a6b41a8ed6b2a8c814b2360a52a0fa384b3fb Mon Sep 17 00:00:00 2001 From: kenjis Date: Fri, 31 Dec 2021 11:45:01 +0900 Subject: [PATCH 01/24] refactor: move $CSPEnabled from Response to ContentSecurityPolicy ContentSecurityPolicy will be needed out of Response. --- system/HTTP/ContentSecurityPolicy.php | 23 +++++++++++++++++++++++ system/HTTP/Response.php | 3 +++ system/HTTP/ResponseTrait.php | 4 +++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index 3b18fc8bf1df..cee4c92b9d37 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -192,6 +192,13 @@ class ContentSecurityPolicy */ protected $reportOnlyHeaders = []; + /** + * Whether Content Security Policy is being enforced. + * + * @var bool + */ + protected $CSPEnabled = false; + /** * Constructor. * @@ -206,6 +213,22 @@ public function __construct(ContentSecurityPolicyConfig $config) } } + /** + * Enable CSP + */ + public function enable(): void + { + $this->CSPEnabled = true; + } + + /** + * Whether Content Security Policy is being enforced. + */ + public function enabled(): bool + { + return $this->CSPEnabled; + } + /** * Compiles and sets the appropriate headers in the request. * diff --git a/system/HTTP/Response.php b/system/HTTP/Response.php index 8e2cc472cc00..f25962d6af37 100644 --- a/system/HTTP/Response.php +++ b/system/HTTP/Response.php @@ -155,6 +155,9 @@ public function __construct($config) $this->CSP = new ContentSecurityPolicy(new CSPConfig()); $this->CSPEnabled = $config->CSPEnabled; + if ($config->CSPEnabled) { + $this->CSP->enable(); + } // DEPRECATED COOKIE MANAGEMENT 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 ?? ''); From 434f90ac4f9b33e1d9bf772254221d79019389a7 Mon Sep 17 00:00:00 2001 From: kenjis Date: Fri, 31 Dec 2021 11:53:01 +0900 Subject: [PATCH 02/24] feat: add methods to get nonces --- system/HTTP/ContentSecurityPolicy.php | 38 +++++++++++++++++++ .../system/HTTP/ContentSecurityPolicyTest.php | 18 +++++++++ 2 files changed, 56 insertions(+) diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index cee4c92b9d37..baf42ff6193d 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -176,6 +176,20 @@ class ContentSecurityPolicy */ protected $nonces = []; + /** + * Nonce for style + * + * @var string + */ + protected $styleNonce; + + /** + * Nonce for script + * + * @var string + */ + protected $scriptNonce; + /** * An array of header info since we have * to build ourself before passing to Response. @@ -229,6 +243,30 @@ 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)); + } + + return $this->styleNonce; + } + + /** + * Get the nonce for the script tag. + */ + public function getScriptNonce(): string + { + if ($this->scriptNonce === null) { + $this->scriptNonce = bin2hex(random_bytes(12)); + } + + return $this->scriptNonce; + } + /** * Compiles and sets the appropriate headers in the request. * diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index 0bbb6f60f988..8d70c4929cd0 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -519,4 +519,22 @@ public function testCSPDisabled() $this->assertHeaderNotEmitted('content-security-policy', true); } + + public function testGetScriptNonce() + { + $this->prepare(); + + $nonce = $this->csp->getScriptNonce(); + + $this->assertMatchesRegularExpression('/[0-9a-z]{24}/', $nonce); + } + + public function testGetStyleNonce() + { + $this->prepare(); + + $nonce = $this->csp->getStyleNonce(); + + $this->assertMatchesRegularExpression('/[0-9a-z]{24}/', $nonce); + } } From c45cfcb622365bf9b288f52fa2e56874e3354a41 Mon Sep 17 00:00:00 2001 From: kenjis Date: Fri, 31 Dec 2021 12:53:08 +0900 Subject: [PATCH 03/24] refactor: output only one nonce Outputting multiple nonces do not improve security. One is enough. --- system/HTTP/ContentSecurityPolicy.php | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index baf42ff6193d..540b5ab86789 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -651,21 +651,25 @@ protected function generateNonces(ResponseInterface &$response) // Replace style placeholders with nonces $body = preg_replace_callback('/{csp-style-nonce}/', function () { - $nonce = bin2hex(random_bytes(12)); - - $this->styleSrc[] = 'nonce-' . $nonce; + $nonce = $this->getStyleNonce(); return "nonce=\"{$nonce}\""; - }, $body); + }, $body, -1, $count); + + if ($count > 0) { + $this->styleSrc[] = 'nonce-' . $this->styleNonce; + } // Replace script placeholders with nonces $body = preg_replace_callback('/{csp-script-nonce}/', function () { - $nonce = bin2hex(random_bytes(12)); - - $this->scriptSrc[] = 'nonce-' . $nonce; + $nonce = $this->getScriptNonce(); return "nonce=\"{$nonce}\""; - }, $body); + }, $body, -1, $count); + + if ($count > 0) { + $this->scriptSrc[] = 'nonce-' . $this->scriptNonce; + } $response->setBody($body); } From 6be16a8c5c776e02d9a72064e06425d8e7a4034a Mon Sep 17 00:00:00 2001 From: kenjis Date: Fri, 31 Dec 2021 13:26:08 +0900 Subject: [PATCH 04/24] feat: custom nonce tags --- app/Config/ContentSecurityPolicy.php | 14 ++++++++ env | 2 ++ system/HTTP/ContentSecurityPolicy.php | 20 +++++++++-- .../system/HTTP/ContentSecurityPolicyTest.php | 33 +++++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php index 6fa5bd7b4cc7..b4450d5ed422 100644 --- a/app/Config/ContentSecurityPolicy.php +++ b/app/Config/ContentSecurityPolicy.php @@ -164,4 +164,18 @@ 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}'; } diff --git a/env b/env index c60b367265e7..7b52d6690441 100644 --- a/env +++ b/env @@ -73,6 +73,8 @@ # contentsecuritypolicy.reportURI = null # contentsecuritypolicy.sandbox = false # contentsecuritypolicy.upgradeInsecureRequests = false +# contentsecuritypolicy.styleNonceTag = '{csp-style-nonce}' +# contentsecuritypolicy.scriptNonceTag = '{csp-script-nonce}' #-------------------------------------------------------------------- # COOKIE diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index 540b5ab86789..fb70c613549a 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -190,6 +190,20 @@ class ContentSecurityPolicy */ protected $scriptNonce; + /** + * Nonce tag for style + * + * @var string + */ + protected $styleNonceTag = '{csp-style-nonce}'; + + /** + * Nonce tag for script + * + * @var string + */ + protected $scriptNonceTag = '{csp-script-nonce}'; + /** * An array of header info since we have * to build ourself before passing to Response. @@ -650,7 +664,8 @@ protected function generateNonces(ResponseInterface &$response) } // Replace style placeholders with nonces - $body = preg_replace_callback('/{csp-style-nonce}/', function () { + $pattern = '/' . preg_quote($this->styleNonceTag, '/') . '/'; + $body = preg_replace_callback($pattern, function () { $nonce = $this->getStyleNonce(); return "nonce=\"{$nonce}\""; @@ -661,7 +676,8 @@ protected function generateNonces(ResponseInterface &$response) } // Replace script placeholders with nonces - $body = preg_replace_callback('/{csp-script-nonce}/', function () { + $pattern = '/' . preg_quote($this->scriptNonceTag, '/') . '/'; + $body = preg_replace_callback($pattern, function () { $nonce = $this->getScriptNonce(); return "nonce=\"{$nonce}\""; diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index 8d70c4929cd0..5525309d7a2f 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -13,6 +13,7 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\App; +use Config\ContentSecurityPolicy as CSPConfig; /** * Test the CSP policy directive creation. @@ -463,6 +464,22 @@ public function testBodyScriptNonce() $this->assertStringContainsString('nonce-', $result); } + public function testBodyScriptNonceCustomScriptTag() + { + $config = new CSPConfig(); + $config->scriptNonceTag = '{custom-script-nonce-tag}'; + $csp = new ContentSecurityPolicy($config); + + $response = new Response(new App()); + $response->pretend(true); + $body = 'Blah blah {custom-script-nonce-tag} blah blah'; + $response->setBody($body); + + $csp->finalize($response); + + $this->assertStringContainsString('nonce=', $response->getBody()); + } + /** * @runInSeparateProcess * @preserveGlobalState disabled @@ -481,6 +498,22 @@ public function testBodyStyleNonce() $this->assertStringContainsString('nonce-', $result); } + public function testBodyStyleNonceCustomStyleTag() + { + $config = new CSPConfig(); + $config->styleNonceTag = '{custom-style-nonce-tag}'; + $csp = new ContentSecurityPolicy($config); + + $response = new Response(new App()); + $response->pretend(true); + $body = 'Blah blah {custom-style-nonce-tag} blah blah'; + $response->setBody($body); + + $csp->finalize($response); + + $this->assertStringContainsString('nonce=', $response->getBody()); + } + /** * @runInSeparateProcess * @preserveGlobalState disabled From 7a75b89b8b7aed4a9ad4f5f76805428b4c3216a6 Mon Sep 17 00:00:00 2001 From: kenjis Date: Fri, 31 Dec 2021 13:49:53 +0900 Subject: [PATCH 05/24] feat: add csp_style_nonce() and csp_script_nonce() --- system/Common.php | 35 ++++++++++++++++++++++++++++ tests/system/CommonFunctionsTest.php | 10 ++++++++ 2 files changed, 45 insertions(+) diff --git a/system/Common.php b/system/Common.php index e2fa9bce5a33..80a7dc013944 100644 --- a/system/Common.php +++ b/system/Common.php @@ -21,6 +21,7 @@ use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\URI; use CodeIgniter\Model; @@ -288,6 +289,40 @@ 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 + { + /** @var Response $response */ + $response = Services::response(); + + if (! $response->CSP->enabled()) { + return ''; + } + + return 'nonce="' . $response->CSP->getStyleNonce() . '"'; + } +} + +if (! function_exists('csp_script_nonce')) { + /** + * Generates a nonce attribute for script tag. + */ + function csp_script_nonce(): string + { + /** @var Response $response */ + $response = Services::response(); + + if (! $response->CSP->enabled()) { + return ''; + } + + return 'nonce="' . $response->CSP->getScriptNonce() . '"'; + } +} + if (! function_exists('db_connect')) { /** * Grabs a database connection and returns it to the user. diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index 3916a1ab785f..f55f55fb45b4 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -549,4 +549,14 @@ public function testTraceWithCSP() $this->expectOutputRegex('/') + 8); $script = PHP_EOL - . '' - . '' - . '' + . '' + . '' . $kintScript . PHP_EOL; From e7b0727e8d42833bbee5a38476a76ade10bd4f5b Mon Sep 17 00:00:00 2001 From: kenjis Date: Fri, 31 Dec 2021 13:55:42 +0900 Subject: [PATCH 07/24] config: fix incorrect property name --- env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/env b/env index 7b52d6690441..e11bb08e0493 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 From d548118c264dedc1dde45b6ca0afcd550462b49f Mon Sep 17 00:00:00 2001 From: kenjis Date: Fri, 31 Dec 2021 14:01:36 +0900 Subject: [PATCH 08/24] feat: add config for disabling to replace nonce tag automatically --- app/Config/ContentSecurityPolicy.php | 7 ++++ env | 1 + system/HTTP/ContentSecurityPolicy.php | 11 +++++++ .../system/HTTP/ContentSecurityPolicyTest.php | 32 +++++++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php index b4450d5ed422..aa18ba9f1060 100644 --- a/app/Config/ContentSecurityPolicy.php +++ b/app/Config/ContentSecurityPolicy.php @@ -178,4 +178,11 @@ class ContentSecurityPolicy extends BaseConfig * @var string */ public $scriptNonceTag = '{csp-script-nonce}'; + + /** + * Replace nonce tag automatically + * + * @var bool + */ + public $autoNonce = true; } diff --git a/env b/env index e11bb08e0493..83def018081f 100644 --- a/env +++ b/env @@ -75,6 +75,7 @@ # contentsecuritypolicy.upgradeInsecureRequests = false # contentsecuritypolicy.styleNonceTag = '{csp-style-nonce}' # contentsecuritypolicy.scriptNonceTag = '{csp-script-nonce}' +# contentsecuritypolicy.autoNonce = true #-------------------------------------------------------------------- # COOKIE diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index fb70c613549a..341411013d0e 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -204,6 +204,13 @@ class ContentSecurityPolicy */ 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. @@ -288,6 +295,10 @@ public function getScriptNonce(): string */ public function finalize(ResponseInterface &$response) { + if ($this->autoNonce === false) { + return; + } + $this->generateNonces($response); $this->buildHeaders($response); } diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index 5525309d7a2f..fa95d9ba9fb6 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -480,6 +480,38 @@ public function testBodyScriptNonceCustomScriptTag() $this->assertStringContainsString('nonce=', $response->getBody()); } + public function testBodyScriptNonceDisableAutoNonce() + { + $config = new CSPConfig(); + $config->autoNonce = false; + $csp = new ContentSecurityPolicy($config); + + $response = new Response(new App()); + $response->pretend(true); + $body = 'Blah blah {csp-script-nonce} blah blah'; + $response->setBody($body); + + $csp->finalize($response); + + $this->assertStringContainsString('{csp-script-nonce}', $response->getBody()); + } + + public function testBodyStyleNonceDisableAutoNonce() + { + $config = new CSPConfig(); + $config->autoNonce = false; + $csp = new ContentSecurityPolicy($config); + + $response = new Response(new App()); + $response->pretend(true); + $body = 'Blah blah {csp-style-nonce} blah blah'; + $response->setBody($body); + + $csp->finalize($response); + + $this->assertStringContainsString('{csp-style-nonce}', $response->getBody()); + } + /** * @runInSeparateProcess * @preserveGlobalState disabled From bc52fad6678b3600e2d62139a032dbd5a2482192 Mon Sep 17 00:00:00 2001 From: kenjis Date: Fri, 31 Dec 2021 14:13:57 +0900 Subject: [PATCH 09/24] refactor: use csp_script_nonce() and csp_script_nonce() in Kint --- system/Debug/Kint/RichRenderer.php | 4 ++-- tests/system/CommonFunctionsTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index f55f55fb45b4..51eb7f94938f 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -527,7 +527,7 @@ public function testDWithCSP() $config->CSPEnabled = true; Kint::$cli_detection = false; - $this->expectOutputRegex('/