CVE-2020-26211: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: In BookStack before version 0.30.4, a user with permissions to edit a page could insert JavaScript code through the use of `javascript:` URIs within a link or form which would run, within the context of the current page, when clicked or submitted. Additionally, a user with permissions to edit a page could insert a particular meta tag which could be used to silently redirect users to a alternative location upon visit of a page. Dangerous content may remain in the database but will be removed before being displayed on a page. If you think this could have been exploited the linked advisory provides a SQL query to test. As a workaround without upgrading, page edit permissions could be limited to only those that are trusted until you can upgrade although this will not address existing exploitation of this vulnerability. The issue is fixed in BookStack version 0.30.4.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 8.7
Published: 2020-11-03T21:15Z
Repository: https://github.com/BookStackApp/BookStack
Language: PHP
Files changed: app/Entities/Managers/PageContent.php, tests/Entity/PageContentTest.php
Commit date: 2020-10-27 01:34:51 +0000
Commit message: XSS and redirect fixes with test cases

Diff: diff --git a/app/Entities/Managers/PageContent.php b/app/Entities/Managers/PageContent.php
index a787e5d9..7338a36b 100644
--- a/app/Entities/Managers/PageContent.php
+++ b/app/Entities/Managers/PageContent.php
@@ -294,10 +294,28 @@ class PageContent
         $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);
         }
diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php
index 99547fd1..e97df2c7 100644
--- a/tests/Entity/PageContentTest.php
+++ b/tests/Entity/PageContentTest.php
@@ -157,10 +157,76 @@ class PageContentTest extends TestCase
             $pageView->assertElementNotContains('.page-content', 'base64');
         }
 
     }
 
+    public function test_javascript_uri_links_are_removed()
+    {
+        $checks = [
+            '<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
+            '<a id="xss" href="javascript: alert(document.cookie)>Click me</a>'
+        ];
+
+        $this->asEditor();
+        $page = Page::first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', '<a id="xss">');
+            $pageView->assertElementNotContains('.page-content', 'href=javascript:');
+        }
+    }
+    public function test_form_actions_with_javascript_are_removed()
+    {
+        $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 id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>'
+        ];
+
+        $this->asEditor();
+        $page = Page::first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', '<button id="xss"');
+            $pageView->assertElementNotContains('.page-content', '<input id="xss"');
+            $pageView->assertElementNotContains('.page-content', '<form id="xss"');
+            $pageView->assertElementNotContains('.page-content', 'action=javascript:');
+            $pageView->assertElementNotContains('.page-content', 'formaction=javascript:');
+        }
+    }
+    
+    public function test_metadata_redirects_are_removed()
+    {
+        $checks = [
+            '<meta http-equiv="refresh" content="0; url=//external_url">',
+        ];
+
+        $this->asEditor();
+        $page = Page::first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', '<meta>');
+            $pageView->assertElementNotContains('.page-content', '</meta>');
+            $pageView->assertElementNotContains('.page-content', 'content=');
+            $pageView->assertElementNotContains('.page-content', 'external_url');
+        }
+    }
     public function test_page_inline_on_attributes_removed_by_default()
     {
         $this->asEditor();
         $page = Page::first();
         $script = '<p onmouseenter="console.log(\'test\')">Hello</p>';

Fixed code:         // 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);
        }

    public function test_javascript_uri_links_are_removed()
    {
        $checks = [
            '<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
            '<a id="xss" href="javascript: alert(document.cookie)>Click me</a>'
        ];

        $this->asEditor();
        $page = Page::first();

        foreach ($checks as $check) {
            $page->html = $check;
            $page->save();

            $pageView = $this->get($page->getUrl());
            $pageView->assertStatus(200);
            $pageView->assertElementNotContains('.page-content', '<a id="xss">');
            $pageView->assertElementNotContains('.page-content', 'href=javascript:');
        }
    }
    public function test_form_actions_with_javascript_are_removed()
    {
        $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 id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>'
        ];

        $this->asEditor();
        $page = Page::first();

        foreach ($checks as $check) {
            $page->html = $check;
            $page->save();

            $pageView = $this->get($page->getUrl());
            $pageView->assertStatus(200);
            $pageView->assertElementNotContains('.page-content', '<button id="xss"');
            $pageView->assertElementNotContains('.page-content', '<input id="xss"');
            $pageView->assertElementNotContains('.page-content', '<form id="xss"');
            $pageView->assertElementNotContains('.page-content', 'action=javascript:');
            $pageView->assertElementNotContains('.page-content', 'formaction=javascript:');
        }
    }
    
    public function test_metadata_redirects_are_removed()
    {
        $checks = [
            '<meta http-equiv="refresh" content="0; url=//external_url">',
        ];

        $this->asEditor();
        $page = Page::first();

        foreach ($checks as $check) {
            $page->html = $check;
            $page->save();

            $pageView = $this->get($page->getUrl());
            $pageView->assertStatus(200);
            $pageView->assertElementNotContains('.page-content', '<meta>');
            $pageView->assertElementNotContains('.page-content', '</meta>');
            $pageView->assertElementNotContains('.page-content', 'content=');
            $pageView->assertElementNotContains('.page-content', 'external_url');
        }
    }