Filtered scripts in custom HTML head for exports

Since it appeared to cause problems in some scenarios.
Related to #2490
This commit is contained in:
Dan Brown 2021-05-03 23:59:52 +01:00
parent c50ac022a8
commit 43b6633183
10 changed files with 101 additions and 74 deletions

View File

@ -4,6 +4,7 @@ use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension; use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Util\HtmlContentFilter;
use DOMDocument; use DOMDocument;
use DOMNodeList; use DOMNodeList;
use DOMXPath; use DOMXPath;
@ -169,7 +170,7 @@ class PageContent
$content = $this->page->html; $content = $this->page->html;
if (!config('app.allow_content_scripts')) { if (!config('app.allow_content_scripts')) {
$content = $this->escapeScripts($content); $content = HtmlContentFilter::removeScripts($content);
} }
if ($blankIncludes) { if ($blankIncludes) {
@ -308,65 +309,4 @@ class PageContent
return $innerContent; 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;
}
} }

View File

@ -105,7 +105,7 @@ class HomeController extends Controller
*/ */
public function customHeadContent() public function customHeadContent()
{ {
return view('partials.custom-head-content'); return view('partials.custom-head');
} }
/** /**

View File

@ -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);
}
}
}

View File

@ -27,7 +27,7 @@
} }
</style> </style>
@yield('head') @yield('head')
@include('partials.custom-head') @include('partials.export-custom-head')
</head> </head>
<body> <body>

View File

@ -19,7 +19,7 @@
} }
} }
</style> </style>
@include('partials.custom-head') @include('partials.export-custom-head')
</head> </head>
<body> <body>

View File

@ -29,7 +29,7 @@
</style> </style>
@endif @endif
@include('partials.custom-head') @include('partials.export-custom-head')
</head> </head>
<body> <body>

View File

@ -1,5 +0,0 @@
@if(setting('app-custom-head', false))
<!-- Custom user content -->
{!! setting('app-custom-head') !!}
<!-- End custom user content -->
@endif

View File

@ -1,5 +1,5 @@
@if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings') @if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings')
<!-- Custom user content --> <!-- Custom user content -->
{!! setting('app-custom-head') !!} {!! setting('app-custom-head') !!}
<!-- End custom user content --> <!-- End custom user content -->
@endif @endif

View File

@ -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

View File

@ -1,5 +1,6 @@
<?php namespace Tests\Entity; <?php namespace Tests\Entity;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -214,4 +215,19 @@ class ExportTest extends TestCase
$resp->assertSee('src="/uploads/svg_test.svg"'); $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; }');
}
}
} }