From fd44e4ba74b7615e196dcafa4f1eddc634c0b44d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 3 Sep 2021 23:32:42 +0100 Subject: [PATCH] Started application of CSP headers --- app/Http/Kernel.php | 2 +- app/Http/Middleware/ApplyCspRules.php | 69 +++++++++++++++++++ app/Http/Middleware/ControlIframeSecurity.php | 37 ---------- app/Util/HtmlContentFilter.php | 2 +- app/Util/HtmlNonceApplicator.php | 52 ++++++++++++++ resources/views/common/custom-head.blade.php | 2 +- 6 files changed, 124 insertions(+), 40 deletions(-) create mode 100644 app/Http/Middleware/ApplyCspRules.php delete mode 100644 app/Http/Middleware/ControlIframeSecurity.php create mode 100644 app/Util/HtmlNonceApplicator.php diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 4b8cdfba4..a98528d0f 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -24,7 +24,7 @@ class Kernel extends HttpKernel */ protected $middlewareGroups = [ 'web' => [ - \BookStack\Http\Middleware\ControlIframeSecurity::class, + \BookStack\Http\Middleware\ApplyCspRules::class, \BookStack\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, diff --git a/app/Http/Middleware/ApplyCspRules.php b/app/Http/Middleware/ApplyCspRules.php new file mode 100644 index 000000000..2889d80b0 --- /dev/null +++ b/app/Http/Middleware/ApplyCspRules.php @@ -0,0 +1,69 @@ +share('cspNonce', $nonce); + + // TODO - Assess whether image/style/iframe CSP rules should be set + // TODO - Extract nonce application to custom head content in a way that's cacheable. + // TODO - Fix remaining CSP issues and test lots + + $response = $next($request); + + $this->setFrameAncestors($response); + $this->setScriptSrc($response, $nonce); + + return $response; + } + + /** + * Sets CSP 'script-src' headers to restrict the forms of script that can + * run on the page. + */ + public function setScriptSrc(Response $response, string $nonce) + { + $parts = [ + '\'self\'', + '\'nonce-' . $nonce . '\'', + '\'strict-dynamic\'', + ]; + $response->headers->set('Content-Security-Policy', 'script-src ' . implode(' ', $parts)); + } + + /** + * Sets CSP "frame-ancestors" headers to restrict the hosts that BookStack can be + * iframed within. Also adjusts the cookie samesite options so that cookies will + * operate in the third-party context. + */ + protected function setFrameAncestors(Response $response) + { + $iframeHosts = collect(explode(' ', config('app.iframe_hosts', '')))->filter(); + + if ($iframeHosts->count() > 0) { + config()->set('session.same_site', 'none'); + } + + $iframeHosts->prepend("'self'"); + $cspValue = 'frame-ancestors ' . $iframeHosts->join(' '); + $response->headers->set('Content-Security-Policy', $cspValue); + } +} diff --git a/app/Http/Middleware/ControlIframeSecurity.php b/app/Http/Middleware/ControlIframeSecurity.php deleted file mode 100644 index 11d9e6d4c..000000000 --- a/app/Http/Middleware/ControlIframeSecurity.php +++ /dev/null @@ -1,37 +0,0 @@ -filter(); - if ($iframeHosts->count() > 0) { - config()->set('session.same_site', 'none'); - } - - $iframeHosts->prepend("'self'"); - - $response = $next($request); - $cspValue = 'frame-ancestors ' . $iframeHosts->join(' '); - $response->headers->set('Content-Security-Policy', $cspValue); - - return $response; - } -} diff --git a/app/Util/HtmlContentFilter.php b/app/Util/HtmlContentFilter.php index f3f29ae04..aa395cc45 100644 --- a/app/Util/HtmlContentFilter.php +++ b/app/Util/HtmlContentFilter.php @@ -10,7 +10,7 @@ use DOMXPath; class HtmlContentFilter { /** - * Remove all of the script elements from the given HTML. + * Remove all the script elements from the given HTML. */ public static function removeScripts(string $html): string { diff --git a/app/Util/HtmlNonceApplicator.php b/app/Util/HtmlNonceApplicator.php new file mode 100644 index 000000000..eb2cf2687 --- /dev/null +++ b/app/Util/HtmlNonceApplicator.php @@ -0,0 +1,52 @@ +' . $html . ''; + libxml_use_internal_errors(true); + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + $xPath = new DOMXPath($doc); + + // Apply to scripts + $scriptElems = $xPath->query('//script'); + static::addNonceAttributes($scriptElems, $nonce); + + // Apply to styles + $styleElems = $xPath->query('//style'); + static::addNonceAttributes($styleElems, $nonce); + + $returnHtml = ''; + $topElems = $doc->documentElement->childNodes->item(0)->childNodes; + foreach ($topElems as $child) { + $returnHtml .= $doc->saveHTML($child); + } + + return $returnHtml; + } + + protected static function addNonceAttributes(DOMNodeList $nodes, string $nonce): void + { + /** @var DOMElement $node */ + foreach ($nodes as $node) { + $node->setAttribute('nonce', $nonce); + } + } + +} diff --git a/resources/views/common/custom-head.blade.php b/resources/views/common/custom-head.blade.php index fa5ba0cc4..3199fc179 100644 --- a/resources/views/common/custom-head.blade.php +++ b/resources/views/common/custom-head.blade.php @@ -1,5 +1,5 @@ @if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings') -{!! setting('app-custom-head') !!} +{!! \BookStack\Util\HtmlNonceApplicator::apply(setting('app-custom-head'), $cspNonce) !!} @endif \ No newline at end of file