diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 82499cdf2..ff502d164 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -4,6 +4,7 @@ use BookStack\Entities\Models\Page; use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension; use BookStack\Facades\Theme; use BookStack\Theming\ThemeEvents; +use BookStack\Util\HtmlContentFilter; use DOMDocument; use DOMNodeList; use DOMXPath; @@ -169,7 +170,7 @@ class PageContent $content = $this->page->html; if (!config('app.allow_content_scripts')) { - $content = $this->escapeScripts($content); + $content = HtmlContentFilter::removeScripts($content); } if ($blankIncludes) { @@ -308,65 +309,4 @@ class PageContent return $innerContent; } - - /** - * Escape script tags within HTML content. - */ - protected function escapeScripts(string $html) : string - { - if (empty($html)) { - return $html; - } - - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); - $xPath = new DOMXPath($doc); - - // Remove standard script tags - $scriptElems = $xPath->query('//script'); - foreach ($scriptElems as $scriptElem) { - $scriptElem->parentNode->removeChild($scriptElem); - } - - // Remove clickable links to JavaScript URI - $badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]'); - foreach ($badLinks as $badLink) { - $badLink->parentNode->removeChild($badLink); - } - - // Remove forms with calls to JavaScript URI - $badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]'); - foreach ($badForms as $badForm) { - $badForm->parentNode->removeChild($badForm); - } - - // Remove meta tag to prevent external redirects - $metaTags = $xPath->query('//meta[contains(@content, \'url\')]'); - foreach ($metaTags as $metaTag) { - $metaTag->parentNode->removeChild($metaTag); - } - - // Remove data or JavaScript iFrames - $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]'); - foreach ($badIframes as $badIframe) { - $badIframe->parentNode->removeChild($badIframe); - } - - // Remove 'on*' attributes - $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]'); - foreach ($onAttributes as $attr) { - /** @var \DOMAttr $attr*/ - $attrName = $attr->nodeName; - $attr->parentNode->removeAttribute($attrName); - } - - $html = ''; - $topElems = $doc->documentElement->childNodes->item(0)->childNodes; - foreach ($topElems as $child) { - $html .= $doc->saveHTML($child); - } - - return $html; - } } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 31736e1b0..1ffb99f8d 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -105,7 +105,7 @@ class HomeController extends Controller */ public function customHeadContent() { - return view('partials.custom-head-content'); + return view('partials.custom-head'); } /** diff --git a/app/Util/HtmlContentFilter.php b/app/Util/HtmlContentFilter.php new file mode 100644 index 000000000..cec927a3c --- /dev/null +++ b/app/Util/HtmlContentFilter.php @@ -0,0 +1,71 @@ +<?php namespace BookStack\Util; + +use DOMDocument; +use DOMNode; +use DOMNodeList; +use DOMXPath; + +class HtmlContentFilter +{ + /** + * Remove all of the script elements from the given HTML. + */ + public static function removeScripts(string $html): string + { + if (empty($html)) { + return $html; + } + + libxml_use_internal_errors(true); + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + $xPath = new DOMXPath($doc); + + // Remove standard script tags + $scriptElems = $xPath->query('//script'); + static::removeNodes($scriptElems); + + // Remove clickable links to JavaScript URI + $badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]'); + static::removeNodes($badLinks); + + // Remove forms with calls to JavaScript URI + $badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]'); + static::removeNodes($badForms); + + // Remove meta tag to prevent external redirects + $metaTags = $xPath->query('//meta[contains(@content, \'url\')]'); + static::removeNodes($metaTags); + + // Remove data or JavaScript iFrames + $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]'); + static::removeNodes($badIframes); + + // Remove 'on*' attributes + $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]'); + foreach ($onAttributes as $attr) { + /** @var \DOMAttr $attr*/ + $attrName = $attr->nodeName; + $attr->parentNode->removeAttribute($attrName); + } + + $html = ''; + $topElems = $doc->documentElement->childNodes->item(0)->childNodes; + foreach ($topElems as $child) { + $html .= $doc->saveHTML($child); + } + + return $html; + } + + /** + * Removed all of the given DOMNodes. + */ + static protected function removeNodes(DOMNodeList $nodes): void + { + foreach ($nodes as $node) { + $node->parentNode->removeChild($node); + } + } + +} \ No newline at end of file diff --git a/resources/views/books/export.blade.php b/resources/views/books/export.blade.php index f62b89582..1faa3880e 100644 --- a/resources/views/books/export.blade.php +++ b/resources/views/books/export.blade.php @@ -27,7 +27,7 @@ } </style> @yield('head') - @include('partials.custom-head') + @include('partials.export-custom-head') </head> <body> diff --git a/resources/views/chapters/export.blade.php b/resources/views/chapters/export.blade.php index 506e8db3d..96d9d7700 100644 --- a/resources/views/chapters/export.blade.php +++ b/resources/views/chapters/export.blade.php @@ -19,7 +19,7 @@ } } </style> - @include('partials.custom-head') + @include('partials.export-custom-head') </head> <body> diff --git a/resources/views/pages/export.blade.php b/resources/views/pages/export.blade.php index 47a4d870a..1f2e60576 100644 --- a/resources/views/pages/export.blade.php +++ b/resources/views/pages/export.blade.php @@ -29,7 +29,7 @@ </style> @endif - @include('partials.custom-head') + @include('partials.export-custom-head') </head> <body> diff --git a/resources/views/partials/custom-head-content.blade.php b/resources/views/partials/custom-head-content.blade.php deleted file mode 100644 index b245b7ad6..000000000 --- a/resources/views/partials/custom-head-content.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -@if(setting('app-custom-head', false)) - <!-- Custom user content --> - {!! setting('app-custom-head') !!} - <!-- End custom user content --> -@endif \ No newline at end of file diff --git a/resources/views/partials/custom-head.blade.php b/resources/views/partials/custom-head.blade.php index dd7cc41e4..fa5ba0cc4 100644 --- a/resources/views/partials/custom-head.blade.php +++ b/resources/views/partials/custom-head.blade.php @@ -1,5 +1,5 @@ @if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings') - <!-- Custom user content --> - {!! setting('app-custom-head') !!} - <!-- End custom user content --> +<!-- Custom user content --> +{!! setting('app-custom-head') !!} +<!-- End custom user content --> @endif \ No newline at end of file diff --git a/resources/views/partials/export-custom-head.blade.php b/resources/views/partials/export-custom-head.blade.php new file mode 100644 index 000000000..f428e9fe9 --- /dev/null +++ b/resources/views/partials/export-custom-head.blade.php @@ -0,0 +1,5 @@ +@if(setting('app-custom-head')) +<!-- Custom user content --> +{!! \BookStack\Util\HtmlContentFilter::removeScripts(setting('app-custom-head')) !!} +<!-- End custom user content --> +@endif \ No newline at end of file diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 05672c6ca..d04ccc69a 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -1,5 +1,6 @@ <?php namespace Tests\Entity; +use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use Illuminate\Support\Facades\Storage; @@ -214,4 +215,19 @@ class ExportTest extends TestCase $resp->assertSee('src="/uploads/svg_test.svg"'); } + public function test_exports_removes_scripts_from_custom_head() + { + $entities = [ + Page::query()->first(), Chapter::query()->first(), Book::query()->first(), + ]; + setting()->put('app-custom-head', '<script>window.donkey = "cat";</script><style>.my-test-class { color: red; }</style>'); + + foreach ($entities as $entity) { + $resp = $this->asEditor()->get($entity->getUrl('/export/html')); + $resp->assertDontSee('window.donkey'); + $resp->assertDontSee('script'); + $resp->assertSee('.my-test-class { color: red; }'); + } + } + }