]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'master' into release
authorDan Brown <redacted>
Sat, 4 Sep 2021 14:06:33 +0000 (15:06 +0100)
committerDan Brown <redacted>
Sat, 4 Sep 2021 14:06:33 +0000 (15:06 +0100)
17 files changed:
app/Http/Kernel.php
app/Http/Middleware/ApplyCspRules.php [new file with mode: 0644]
app/Http/Middleware/ControlIframeSecurity.php [deleted file]
app/Providers/AppServiceProvider.php
app/Theming/CustomHtmlHeadContentProvider.php [new file with mode: 0644]
app/Util/CspService.php [new file with mode: 0644]
app/Util/HtmlContentFilter.php
app/Util/HtmlNonceApplicator.php [new file with mode: 0644]
resources/views/common/custom-head.blade.php
resources/views/common/export-custom-head.blade.php
resources/views/layouts/base.blade.php
resources/views/pages/edit.blade.php
routes/api.php
routes/web.php
tests/Api/ApiDocsTest.php
tests/Entity/PageContentTest.php
tests/SecurityHeaderTest.php

index 4b8cdfba466365cf2c1e5753785149c88fb80f6c..a98528d0f9ebc747a4224974a3500c573e67f0ac 100644 (file)
@@ -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 (file)
index 0000000..a65d12a
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use BookStack\Util\CspService;
+use Closure;
+use Illuminate\Http\Request;
+
+class ApplyCspRules
+{
+
+    /**
+     * @var CspService
+     */
+    protected $cspService;
+
+    public function __construct(CspService $cspService)
+    {
+        $this->cspService = $cspService;
+    }
+
+    /**
+     * Handle an incoming request.
+     *
+     * @param Request $request
+     * @param Closure $next
+     *
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        view()->share('cspNonce', $this->cspService->getNonce());
+        if ($this->cspService->allowedIFrameHostsConfigured()) {
+            config()->set('session.same_site', 'none');
+        }
+
+        $response = $next($request);
+
+        $this->cspService->setFrameAncestors($response);
+        $this->cspService->setScriptSrc($response);
+        $this->cspService->setObjectSrc($response);
+        $this->cspService->setBaseUri($response);
+
+        return $response;
+    }
+
+}
diff --git a/app/Http/Middleware/ControlIframeSecurity.php b/app/Http/Middleware/ControlIframeSecurity.php
deleted file mode 100644 (file)
index 11d9e6d..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-namespace BookStack\Http\Middleware;
-
-use Closure;
-
-/**
- * Sets CSP 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.
- */
-class ControlIframeSecurity
-{
-    /**
-     * Handle an incoming request.
-     *
-     * @param \Illuminate\Http\Request $request
-     * @param \Closure                 $next
-     *
-     * @return mixed
-     */
-    public function handle($request, Closure $next)
-    {
-        $iframeHosts = collect(explode(' ', config('app.iframe_hosts', '')))->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;
-    }
-}
index 145a7645b72254904dcaa890f1cf2b082918841c..1119d87df023ce4a82cf24bb0815ed8210c34bd4 100644 (file)
@@ -12,6 +12,7 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use BookStack\Settings\Setting;
 use BookStack\Settings\SettingService;
+use BookStack\Util\CspService;
 use Illuminate\Contracts\Cache\Repository;
 use Illuminate\Database\Eloquent\Relations\Relation;
 use Illuminate\Support\Facades\View;
@@ -71,5 +72,9 @@ class AppServiceProvider extends ServiceProvider
         $this->app->singleton(SocialAuthService::class, function ($app) {
             return new SocialAuthService($app->make(SocialiteFactory::class), $app->make(LoginService::class));
         });
+
+        $this->app->singleton(CspService::class, function($app) {
+            return new CspService();
+        });
     }
 }
diff --git a/app/Theming/CustomHtmlHeadContentProvider.php b/app/Theming/CustomHtmlHeadContentProvider.php
new file mode 100644 (file)
index 0000000..6110d5a
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace BookStack\Theming;
+
+use BookStack\Util\CspService;
+use BookStack\Util\HtmlContentFilter;
+use BookStack\Util\HtmlNonceApplicator;
+use Illuminate\Contracts\Cache\Repository as Cache;
+
+class CustomHtmlHeadContentProvider
+{
+    /**
+     * @var CspService
+     */
+    protected $cspService;
+
+    /**
+     * @var Cache
+     */
+    protected $cache;
+
+    public function __construct(CspService $cspService, Cache $cache)
+    {
+        $this->cspService = $cspService;
+        $this->cache = $cache;
+    }
+
+    /**
+     * Fetch our custom HTML head content prepared for use on web pages.
+     * Content has a nonce applied for CSP.
+     */
+    public function forWeb(): string
+    {
+        $content = $this->getSourceContent();
+        $hash = md5($content);
+        $html = $this->cache->remember('custom-head-web:' . $hash, 86400, function() use ($content) {
+            return HtmlNonceApplicator::prepare($content);
+        });
+        return HtmlNonceApplicator::apply($html, $this->cspService->getNonce());
+    }
+
+    /**
+     * Fetch our custom HTML head content prepared for use in export formats.
+     * Scripts are stripped to avoid potential issues.
+     */
+    public function forExport(): string
+    {
+        $content = $this->getSourceContent();
+        $hash = md5($content);
+        return $this->cache->remember('custom-head-export:' . $hash, 86400, function() use ($content) {
+             return HtmlContentFilter::removeScripts($content);
+        });
+    }
+
+    /**
+     * Get the original custom head content to use.
+     */
+    protected function getSourceContent(): string
+    {
+        return setting('app-custom-head', '');
+    }
+
+}
\ No newline at end of file
diff --git a/app/Util/CspService.php b/app/Util/CspService.php
new file mode 100644 (file)
index 0000000..2979ebc
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+namespace BookStack\Util;
+
+use Illuminate\Support\Str;
+use Symfony\Component\HttpFoundation\Response;
+
+class CspService
+{
+    /** @var string */
+    protected $nonce;
+
+    public function __construct(string $nonce = '')
+    {
+        $this->nonce = $nonce ?: Str::random(16);
+    }
+
+    /**
+     * Get the nonce value for CSP.
+     */
+    public function getNonce(): string
+    {
+        return $this->nonce;
+    }
+
+    /**
+     * Sets CSP 'script-src' headers to restrict the forms of script that can
+     * run on the page.
+     */
+    public function setScriptSrc(Response $response)
+    {
+        if (config('app.allow_content_scripts')) {
+            return;
+        }
+
+        $parts = [
+            'http:',
+            'https:',
+            '\'nonce-' . $this->nonce . '\'',
+            '\'strict-dynamic\'',
+        ];
+
+        $value = 'script-src ' . implode(' ', $parts);
+        $response->headers->set('Content-Security-Policy', $value, false);
+    }
+
+    /**
+     * 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.
+     */
+    public function setFrameAncestors(Response $response)
+    {
+        $iframeHosts = $this->getAllowedIframeHosts();
+        array_unshift($iframeHosts, "'self'");
+        $cspValue = 'frame-ancestors ' . implode(' ', $iframeHosts);
+        $response->headers->set('Content-Security-Policy', $cspValue, false);
+    }
+
+    /**
+     * Check if the user has configured some allowed iframe hosts.
+     */
+    public function allowedIFrameHostsConfigured(): bool
+    {
+        return count($this->getAllowedIframeHosts()) > 0;
+    }
+
+    /**
+     * Sets CSP 'object-src' headers to restrict the types of dynamic content
+     * that can be embedded on the page.
+     */
+    public function setObjectSrc(Response $response)
+    {
+        if (config('app.allow_content_scripts')) {
+            return;
+        }
+
+        $response->headers->set('Content-Security-Policy', 'object-src \'self\'', false);
+    }
+
+    /**
+     * Sets CSP 'base-uri' headers to restrict what base tags can be set on
+     * the page to prevent manipulation of relative links.
+     */
+    public function setBaseUri(Response $response)
+    {
+        $response->headers->set('Content-Security-Policy', 'base-uri \'self\'', false);
+    }
+
+    protected function getAllowedIframeHosts(): array
+    {
+        $hosts = config('app.iframe_hosts', '');
+        return array_filter(explode(' ', $hosts));
+    }
+
+}
\ No newline at end of file
index f251a22fdc94730ea1fc049299641b3cd42a279a..aa395cc45c8d82d25a4c58fdb98b4c581c61d5f1 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Util;
 
+use DOMAttr;
 use DOMDocument;
 use DOMNodeList;
 use DOMXPath;
@@ -9,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
     {
@@ -28,28 +29,29 @@ class HtmlContentFilter
         static::removeNodes($scriptElems);
 
         // Remove clickable links to JavaScript URI
-        $badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]');
+        $badLinks = $xPath->query('//*[' . static::xpathContains('@href', 'javascript:') . ']');
         static::removeNodes($badLinks);
 
         // Remove forms with calls to JavaScript URI
-        $badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]');
+        $badForms = $xPath->query('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
         static::removeNodes($badForms);
 
         // Remove meta tag to prevent external redirects
-        $metaTags = $xPath->query('//meta[contains(@content, \'url\')]');
+        $metaTags = $xPath->query('//meta[' . static::xpathContains('@content', 'url') . ']');
         static::removeNodes($metaTags);
 
         // Remove data or JavaScript iFrames
-        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
+        $badIframes = $xPath->query('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
         static::removeNodes($badIframes);
 
+        // Remove elements with a xlink:href attribute
+        // Used in SVG but deprecated anyway, so we'll be a bit more heavy-handed here.
+        $xlinkHrefAttributes = $xPath->query('//@*[contains(name(), \'xlink:href\')]');
+        static::removeAttributes($xlinkHrefAttributes);
+
         // Remove 'on*' attributes
         $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
-        foreach ($onAttributes as $attr) {
-            /** @var \DOMAttr $attr */
-            $attrName = $attr->nodeName;
-            $attr->parentNode->removeAttribute($attrName);
-        }
+        static::removeAttributes($onAttributes);
 
         $html = '';
         $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
@@ -61,7 +63,18 @@ class HtmlContentFilter
     }
 
     /**
-     * Removed all of the given DOMNodes.
+     * Create a xpath contains statement with a translation automatically built within
+     * to affectively search in a cases-insensitive manner.
+     */
+    protected static function xpathContains(string $property, string $value): string
+    {
+        $value = strtolower($value);
+        $upperVal = strtoupper($value);
+        return 'contains(translate(' . $property . ', \'' . $upperVal . '\', \'' . $value . '\'), \'' . $value . '\')';
+    }
+
+    /**
+     * Remove all the given DOMNodes.
      */
     protected static function removeNodes(DOMNodeList $nodes): void
     {
@@ -69,4 +82,16 @@ class HtmlContentFilter
             $node->parentNode->removeChild($node);
         }
     }
+
+    /**
+     * Remove all the given attribute nodes.
+     */
+    protected static function removeAttributes(DOMNodeList $attrs): void
+    {
+        /** @var DOMAttr $attr */
+        foreach ($attrs as $attr) {
+            $attrName = $attr->nodeName;
+            $attr->parentNode->removeAttribute($attrName);
+        }
+    }
 }
diff --git a/app/Util/HtmlNonceApplicator.php b/app/Util/HtmlNonceApplicator.php
new file mode 100644 (file)
index 0000000..e66625b
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace BookStack\Util;
+
+use DOMDocument;
+use DOMElement;
+use DOMNodeList;
+use DOMXPath;
+
+class HtmlNonceApplicator
+{
+    protected static $placeholder = '[CSP_NONCE_VALUE]';
+
+    /**
+     * Prepare the given HTML content with nonce attributes including a placeholder
+     * value which we can target later.
+     */
+    public static function prepare(string $html): string
+    {
+        if (empty($html)) {
+            return $html;
+        }
+
+        $html = '<body>' . $html . '</body>';
+        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, static::$placeholder);
+
+        // Apply to styles
+        $styleElems = $xPath->query('//style');
+        static::addNonceAttributes($styleElems, static::$placeholder);
+
+        $returnHtml = '';
+        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
+        foreach ($topElems as $child) {
+            $returnHtml .= $doc->saveHTML($child);
+        }
+
+        return $returnHtml;
+    }
+
+    /**
+     * Apply the give nonce value to the given prepared HTML.
+     */
+    public static function apply(string $html, string $nonce): string
+    {
+        return str_replace(static::$placeholder, $nonce, $html);
+    }
+
+    protected static function addNonceAttributes(DOMNodeList $nodes, string $attrValue): void
+    {
+        /** @var DOMElement $node */
+        foreach ($nodes as $node) {
+            $node->setAttribute('nonce', $attrValue);
+        }
+    }
+
+}
index fa5ba0cc456333776de144372098e68fc7f3fe65..6f88bd43f7a9cd77d8d1aec9a85c665fadb27e7c 100644 (file)
@@ -1,5 +1,7 @@
+@inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider')
+
 @if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings')
 <!-- Custom user content -->
-{!! setting('app-custom-head') !!}
+{!! $headContent->forWeb() !!}
 <!-- End custom user content -->
 @endif
\ No newline at end of file
index f428e9fe9b0ecf6d291aba85b9f34d07569b5e19..2452d6b8e646ceff733678a93b34be14ed38f9a8 100644 (file)
@@ -1,5 +1,7 @@
+@inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider')
+
 @if(setting('app-custom-head'))
 <!-- Custom user content -->
-{!! \BookStack\Util\HtmlContentFilter::removeScripts(setting('app-custom-head')) !!}
+{!! $headContent->forExport() !!}
 <!-- End custom user content -->
 @endif
\ No newline at end of file
index 6a45b4209ac2b2b3425620f51d4eb6007d20dfe4..1f28e354ce8e119f03a0281fde89563eaee371d5 100644 (file)
@@ -15,7 +15,6 @@
     <meta property="og:title" content="{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}">
     <meta property="og:url" content="{{ url()->current() }}">
     @stack('social-meta')
-    
 
     <!-- Styles and Fonts -->
     <link rel="stylesheet" href="{{ versioned_asset('dist/styles.css') }}">
@@ -51,7 +50,7 @@
     </div>
 
     @yield('bottom')
-    <script src="{{ versioned_asset('dist/app.js') }}"></script>
+    <script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
     @yield('scripts')
 
 </body>
index db518b0d45af321621ee91bec4f86d46237630cc..6d2c3d484d43574ede66250a58347cef8c3f2692 100644 (file)
@@ -1,7 +1,7 @@
 @extends('layouts.base')
 
 @section('head')
-    <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=4.9.4') }}"></script>
+    <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=4.9.4') }}" nonce="{{ $cspNonce }}"></script>
 @stop
 
 @section('body-class', 'flexbox')
index a6ed0c8f110702c007db4feceb8075a5f1ab6971..83a411219833ae3daa9048cb21e88ba1d539f29e 100644 (file)
@@ -5,7 +5,6 @@
  * Routes have a uri prefix of /api/.
  * Controllers are all within app/Http/Controllers/Api.
  */
-Route::get('docs', 'ApiDocsController@display');
 Route::get('docs.json', 'ApiDocsController@json');
 
 Route::get('books', 'BookApiController@list');
index a823b73c88d13ee3c3ac3427024d67ce191098a2..08adeceb947318a9f017f55424e4b5e91e09ccb7 100644 (file)
@@ -10,6 +10,9 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/uploads/images/{path}', 'Images\ImageController@showImage')
         ->where('path', '.*$');
 
+    // API docs routes
+    Route::get('/api/docs', 'Api\ApiDocsController@display');
+
     Route::get('/pages/recently-updated', 'PageController@showRecentlyUpdated');
 
     // Shelves
index 90d107eb34aa7b170d73fe2999014c7789248176..062adce5376821a8b1914b734039c107edf6fa2b 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace Tests\Api;
 
-use BookStack\Auth\User;
 use Tests\TestCase;
 
 class ApiDocsTest extends TestCase
@@ -11,16 +10,6 @@ class ApiDocsTest extends TestCase
 
     protected $endpoint = '/api/docs';
 
-    public function test_docs_page_not_visible_to_normal_viewers()
-    {
-        $viewer = $this->getViewer();
-        $resp = $this->actingAs($viewer)->get($this->endpoint);
-        $resp->assertStatus(403);
-
-        $resp = $this->actingAsApiEditor()->get($this->endpoint);
-        $resp->assertStatus(200);
-    }
-
     public function test_docs_page_returns_view_with_docs_content()
     {
         $resp = $this->actingAsApiEditor()->get($this->endpoint);
@@ -42,19 +31,4 @@ class ApiDocsTest extends TestCase
             ]],
         ]);
     }
-
-    public function test_docs_page_visible_by_public_user_if_given_permission()
-    {
-        $this->setSettings(['app-public' => true]);
-        $guest = User::getDefault();
-
-        $this->startSession();
-        $resp = $this->get('/api/docs');
-        $resp->assertStatus(403);
-
-        $this->giveUserPermissions($guest, ['access-api']);
-
-        $resp = $this->get('/api/docs');
-        $resp->assertStatus(200);
-    }
 }
index 5aee9788786e17e453b98730fdd1ad908717028f..1b2ce2db2f218769e1ca2682e203909af1925679 100644 (file)
@@ -135,14 +135,26 @@ class PageContentTest extends TestCase
         }
     }
 
-    public function test_iframe_js_and_base64_urls_are_removed()
+    public function test_js_and_base64_src_urls_are_removed()
     {
         $checks = [
             '<iframe src="javascript:alert(document.cookie)"></iframe>',
+            '<iframe src="JavAScRipT:alert(document.cookie)"></iframe>',
+            '<iframe src="JavAScRipT:alert(document.cookie)"></iframe>',
             '<iframe SRC=" javascript: alert(document.cookie)"></iframe>',
             '<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
+            '<iframe src="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
             '<iframe src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
+            '<img src="javascript:alert(document.cookie)"/>',
+            '<img src="JavAScRipT:alert(document.cookie)"/>',
+            '<img src="JavAScRipT:alert(document.cookie)"/>',
+            '<img SRC=" javascript: alert(document.cookie)"/>',
+            '<img src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
+            '<img src="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
+            '<img src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
             '<iframe srcdoc="<script>window.alert(document.cookie)</script>"></iframe>',
+            '<iframe SRCdoc="<script>window.alert(document.cookie)</script>"></iframe>',
+            '<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>',
         ];
 
         $this->asEditor();
@@ -155,6 +167,7 @@ class PageContentTest extends TestCase
             $pageView = $this->get($page->getUrl());
             $pageView->assertStatus(200);
             $pageView->assertElementNotContains('.page-content', '<iframe>');
+            $pageView->assertElementNotContains('.page-content', '<img');
             $pageView->assertElementNotContains('.page-content', '</iframe>');
             $pageView->assertElementNotContains('.page-content', 'src=');
             $pageView->assertElementNotContains('.page-content', 'javascript:');
@@ -168,6 +181,8 @@ class PageContentTest extends TestCase
         $checks = [
             '<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
             '<a id="xss" href="javascript: alert(document.cookie)>Click me</a>',
+            '<a id="xss" href="JaVaScRiPt: alert(document.cookie)>Click me</a>',
+            '<a id="xss" href=" JaVaScRiPt: alert(document.cookie)>Click me</a>',
         ];
 
         $this->asEditor();
@@ -179,7 +194,7 @@ class PageContentTest extends TestCase
 
             $pageView = $this->get($page->getUrl());
             $pageView->assertStatus(200);
-            $pageView->assertElementNotContains('.page-content', '<a id="xss">');
+            $pageView->assertElementNotContains('.page-content', '<a id="xss"');
             $pageView->assertElementNotContains('.page-content', 'href=javascript:');
         }
     }
@@ -188,8 +203,10 @@ class PageContentTest extends TestCase
     {
         $checks = [
             '<form><input id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><input></form>',
+            '<form ><button id="xss" formaction="JaVaScRiPt:alert(document.domain)">Click me</button></form>',
             '<form ><button id="xss" formaction=javascript:alert(document.domain)>Click me</button></form>',
             '<form id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>',
+            '<form id="xss" action="JaVaScRiPt:alert(document.domain)"><input type=submit value=Submit></form>',
         ];
 
         $this->asEditor();
@@ -213,6 +230,8 @@ class PageContentTest extends TestCase
     {
         $checks = [
             '<meta http-equiv="refresh" content="0; url=//external_url">',
+            '<meta http-equiv="refresh" ConTeNt="0; url=//external_url">',
+            '<meta http-equiv="refresh" content="0; UrL=//external_url">',
         ];
 
         $this->asEditor();
@@ -249,11 +268,13 @@ class PageContentTest extends TestCase
     {
         $checks = [
             '<p onclick="console.log(\'test\')">Hello</p>',
+            '<p OnCliCk="console.log(\'test\')">Hello</p>',
             '<div>Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p>',
             '<div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div>',
             '<div><div><div><div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div></div></div></div>',
             '<div onclick="console.log(\'test\')">Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p><div></div>',
             '<a a="<img src=1 onerror=\'alert(1)\'> ',
+            '\<a onclick="alert(document.cookie)"\>xss link\</a\>',
         ];
 
         $this->asEditor();
@@ -284,6 +305,28 @@ class PageContentTest extends TestCase
         $pageView->assertDontSee('abc123abc123');
     }
 
+    public function test_svg_xlink_hrefs_are_removed()
+    {
+        $checks = [
+            '<svg id="test" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100"><a xlink:href="javascript:alert(document.domain)"><rect x="0" y="0" width="100" height="100" /></a></svg>',
+            '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="data:application/xml;base64 ,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4KPGRlZnM+CjxjaXJjbGUgaWQ9InRlc3QiIHI9IjAiIGN4PSIwIiBjeT0iMCIgc3R5bGU9ImZpbGw6ICNGMDAiPgo8c2V0IGF0dHJpYnV0ZU5hbWU9ImZpbGwiIGF0dHJpYnV0ZVR5cGU9IkNTUyIgb25iZWdpbj0nYWxlcnQoZG9jdW1lbnQuZG9tYWluKScKb25lbmQ9J2FsZXJ0KCJvbmVuZCIpJyB0bz0iIzAwRiIgYmVnaW49IjBzIiBkdXI9Ijk5OXMiIC8+CjwvY2lyY2xlPgo8L2RlZnM+Cjx1c2UgeGxpbms6aHJlZj0iI3Rlc3QiLz4KPC9zdmc+#test"/></svg>'
+        ];
+
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', 'alert');
+            $pageView->assertElementNotContains('.page-content', 'xlink:href');
+            $pageView->assertElementNotContains('.page-content', 'application/xml');
+        }
+    }
+
     public function test_page_inline_on_attributes_show_if_configured()
     {
         $this->asEditor();
index 888dac8106af4b8088ecf2f28fb8d82bd96d12f6..fe25ef3f00b6a95de8021c7f99b53a82495a910b 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace Tests;
 
-use Illuminate\Support\Str;
+use BookStack\Util\CspService;
 
 class SecurityHeaderTest extends TestCase
 {
@@ -44,26 +44,89 @@ class SecurityHeaderTest extends TestCase
     public function test_iframe_csp_self_only_by_default()
     {
         $resp = $this->get('/');
-        $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
-        $frameHeaders = $cspHeaders->filter(function ($val) {
-            return Str::startsWith($val, 'frame-ancestors');
-        });
+        $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
 
-        $this->assertTrue($frameHeaders->count() === 1);
-        $this->assertEquals('frame-ancestors \'self\'', $frameHeaders->first());
+        $this->assertEquals('frame-ancestors \'self\'', $frameHeader);
     }
 
     public function test_iframe_csp_includes_extra_hosts_if_configured()
     {
         $this->runWithEnv('ALLOWED_IFRAME_HOSTS', 'https://a.example.com https://b.example.com', function () {
             $resp = $this->get('/');
-            $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
-            $frameHeaders = $cspHeaders->filter(function ($val) {
-                return Str::startsWith($val, 'frame-ancestors');
-            });
+            $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
 
-            $this->assertTrue($frameHeaders->count() === 1);
-            $this->assertEquals('frame-ancestors \'self\' https://a.example.com https://b.example.com', $frameHeaders->first());
+            $this->assertNotEmpty($frameHeader);
+            $this->assertEquals('frame-ancestors \'self\' https://a.example.com https://b.example.com', $frameHeader);
         });
     }
+
+    public function test_script_csp_set_on_responses()
+    {
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+        $this->assertStringContainsString('\'strict-dynamic\'', $scriptHeader);
+        $this->assertStringContainsString('\'nonce-', $scriptHeader);
+    }
+
+    public function test_script_csp_nonce_matches_nonce_used_in_custom_head()
+    {
+        $this->setSettings(['app-custom-head' => '<script>console.log("cat");</script>']);
+        $resp = $this->get('/login');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+
+        $nonce = app()->make(CspService::class)->getNonce();
+        $this->assertStringContainsString('nonce-' . $nonce, $scriptHeader);
+        $resp->assertSee('<script nonce="' . $nonce . '">console.log("cat");</script>');
+    }
+
+    public function test_script_csp_nonce_changes_per_request()
+    {
+        $resp = $this->get('/');
+        $firstHeader = $this->getCspHeader($resp, 'script-src');
+
+        $this->refreshApplication();
+
+        $resp = $this->get('/');
+        $secondHeader = $this->getCspHeader($resp, 'script-src');
+
+        $this->assertNotEquals($firstHeader, $secondHeader);
+    }
+
+    public function test_allow_content_scripts_settings_controls_csp_script_headers()
+    {
+        config()->set('app.allow_content_scripts', true);
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+        $this->assertEmpty($scriptHeader);
+
+        config()->set('app.allow_content_scripts', false);
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+        $this->assertNotEmpty($scriptHeader);
+    }
+
+    public function test_object_src_csp_header_set()
+    {
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'object-src');
+        $this->assertEquals('object-src \'self\'', $scriptHeader);
+    }
+
+    public function test_base_uri_csp_header_set()
+    {
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'base-uri');
+        $this->assertEquals('base-uri \'self\'', $scriptHeader);
+    }
+
+    /**
+     * Get the value of the first CSP header of the given type.
+     */
+    protected function getCspHeader(TestResponse $resp, string $type): string
+    {
+        $cspHeaders = collect($resp->headers->all('Content-Security-Policy'));
+        return $cspHeaders->filter(function ($val) use ($type) {
+            return strpos($val, $type) === 0;
+        })->first() ?? '';
+    }
 }