CVE-2022-4647 (PHP, published 2022-12-22T02:15Z)
Description: value: Cross-site Scripting (XSS) - Stored in GitHub repository microweber/microweber prior to 1.3.2.
Severity: nan (CVSS v3 base score: 6.1)
Weakness: CWE-79 \u2014 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
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.
Repository: https://github.com/microweber/microweber
Files changed:
src/MicroweberPackages/App/Utils/Parser.php
src/MicroweberPackages/App/Utils/lib/phpQuery.php
src/MicroweberPackages/Content/ContentManagerCrud.php
src/MicroweberPackages/Content/ContentManagerHelpers.php
src/MicroweberPackages/Content/tests/ContentApiControllerLiveEditSaveTest.php
src/MicroweberPackages/Database/DatabaseManager.php
src/MicroweberPackages/Database/Utils.php
src/MicroweberPackages/Helper/Format.php
src/MicroweberPackages/Helper/XSSClean.php
Commit message: update


Vulnerable code:

        if ($layout == '') {
        $this->document->formatOutput = true;
            $return = @$this->document->loadHTML($markup);
    public static $defaultDoctype = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
//  AntiXSS makes bug on save convertind comments to htmlentities
//                        $antixss = new AntiXSS();
//                        $html_to_save = $content = $antixss->xss_clean($html_to_save);

    public function testSaveContentOnPageLiveEdit()
        $user = User::where('is_admin', '=', '1')->first();
        Auth::login($user);


        MultilanguageHelpers::setMultilanguageEnabled(0);
            'title' => 'pagecontent222'.$unique,
            'url' => 'pagecontent222'.$unique,
            'active_site_template'=> 'new-world',
        $pageLink = '/' . str_replace(site_url(),'', $pageLink);
        $_SERVER['REQUEST_URI'] = $pageLink;
</a><a class="mb-2" href=""><i class="mdi mdi-arrow-right"></i></a>
            'field_data_0'=>[
                'attributes'=>[
                    'class'=>'container edit',
                    'rel'=>'content',
                    'rel_id'=>$findPage->id,
                    'field'=>'content',
                'html'=>$contentFieldHtml


        $findPage = Page::whereId($fieldSaved[0]['id'])->first();

                '@<![\s\S]*?--[ \t\n\r]*>@', // Strip multi-line comments
                '/<title(.*?)<\/title>/is',
                '/<audio(.*?)<\/audio>/is',
                '/<video(.*?)<\/video>/is',
                '/<head(.*?)>/is',
        $allow_attibutes = [
        $antiXss->removeEvilAttributes($allow_attibutes);
        $allow_tags = [
        $antiXss->removeEvilHtmlTags($allow_tags);
        return $html;

Fixed code:
         if ($layout == '') {

        $layout = str_ireplace('{SITE_URL}','___mw-site-url-temp-replace-on-make-tags___', $layout);



        $layout = str_ireplace('___mw-site-url-temp-replace-on-make-tags___','{SITE_URL}', $layout);



            $this->document->formatOutput = false;

            $this->document->validateOnParse=false;
            $this->document->strictErrorChecking=false;
            $this->document->recover=false;



        libxml_use_internal_errors(true);

       // $this->document->formatOutput = true;
        $this->document->formatOutput = false;
       // $this->document->standalone = true;


        $this->document->validateOnParse=false;
        $this->document->strictErrorChecking=false;
        $this->document->recover=false;
         // $return = @$this->document->loadHTML($markup);
            $return = @$this->document->loadHTML($markup,
                LIBXML_SCHEMA_CREATE |
                LIBXML_HTML_NOIMPLIED |
                LIBXML_HTML_NODEFDTD |
                LIBXML_NOERROR |
                LIBXML_NONET |
                LIBXML_NOWARNING
            );

/*                $fragment->loadMarkupXML('<?xml version="1.0" encoding="'.$charset.'"?>'*/
//                       . phpQuery::$defaultDoctype.''
//                        .'<fake xmlns="http://www.w3.org/1999/xhtml">'.$markup.'</fake>');
    public static $defaultDoctype_old = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"

    public static $defaultDoctype = '<!doctype html>';
use MicroweberPackages\Content\Models\Content;
                        //\Log::info($html_to_save);

                      //  \Log::info($html_to_save);






use MicroweberPackages\Helper\XSSClean;
    public function testSaveContentOnPageLiveEditSingle()
        $this->cleanupAndPrepare();
            'title' => 'pagecontent222' . $unique,
            'url' => 'pagecontent222' . $unique,
            'active_site_template' => 'new-world',
        $pageLink = '/' . str_replace(site_url(), '', $pageLink);
        $_SERVER['REQ`UEST_URI'] = $pageLink;
<div class="feature-icon bg-primary bg-gradient">
</a>
<a class="mb-2" href=""><i class="mdi mdi-arrow-right"></i></a>
<a class="mb-2" href="https://example.com"><i class="mdi mdi-arrow-up"></i>example link</a>
<a class="mb-3" target="_blank" href="https://example.com/2"><i class="mdi mdi-arrow-left-bold-box"></i>link 2</a>
</div>
            'field_data_0' => [
                'attributes' => [
                    'class' => 'container edit',
                    'rel' => 'content',
                    'rel_id' => $findPage->id,
                    'field' => 'content',
                'html' => $contentFieldHtml
        $findPage = Page::whereId($findPage->id)->first();
        $this->assertTrue(str_contains($findPage->content, '<a class="mb-2" href="https://example.com"><i class="mdi mdi-arrow-up"></i>example link</a>'));
        $this->assertTrue(str_contains($findPage->content, '<a class="mb-3" target="_blank" href="https://example.com/2"><i class="mdi mdi-arrow-left-bold-box"></i>link 2</a>'));


    }


    public function testSaveContentOnPageLiveEditFromBootstrapExamples()
    {
        $this->cleanupAndPrepare();
        $unique = uniqid('testSaveContentOnPage');
        $newCleanMlPage = save_content([
            'subtype' => 'static',
            'content_type' => 'page',
            'layout_file' => 'clean.php',
            'title' => 'pagecontent-bs-examples-save' . $unique,
            'preview_layout_file' => 'clean.php',
            'is_active' => 1,
        ]);



        $findPage = Page::whereId($newCleanMlPage)->first();

        $pageId = $findPage->id;
        $this->assertEquals($findPage->id, $newCleanMlPage);

        $pageLink = content_link($pageId);
        $pageLink = '/' . str_replace(site_url(), '', $pageLink);

        $_SERVER['PHP_SELF'] = '/index.php';
        $_SERVER['REQ`UEST_URI'] = $pageLink;
        $_SERVER['REDIRECT_URL'] = $pageLink;
        $_SERVER['HTTP_REFERER'] = content_link($pageId);

        $zipname = __DIR__ . '/../../Helper/tests/misc/bootstrap-5.0.2-examples.zip';


        //

        $zip = new \ZipArchive();

        $htmls = [];
        if ($zip->open($zipname)) {
            for ($i = 0; $i < $zip->numFiles; $i++) {
                $fn = $zip->getNameIndex($i);
                $ext = get_file_extension($fn);
                if ($ext == 'html') {
                    $string = $zip->getFromName($fn);
                    $htmls[$fn] = $string;
                }
            }
        }
        $zip->close();


        $this->assertTrue(!empty($htmls));


        foreach ($htmls as $k => $html) {


            //remove empty tags
           $html =  preg_replace('/<[^\/>]*>([\s]?)*<\/[^>]*>/', '', $html);

            // remove script tags
            $html = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', "", $html);

             // remove code tags
            $html = preg_replace('/<code\b[^>]*>(.*?)<\/code>/is', "", $html);


            $l = $html;
            $pq = \phpQuery::newDocument($l);

            // $isolated_html = pq('main')->eq(0)->htmlOuter();
            $contentFieldHtml = pq('main')->eq(0)->htmlOuter();


            $fieldsData = [
                'field_data_0' => [
                    'attributes' => [
                        'class' => 'container edit',
                        'rel' => 'content',
                        'rel_id' => $findPage->id,
                        'field' => 'content',
                    ],
                    'html' => $contentFieldHtml
                ]
            ];




            $encoded = base64_encode(json_encode($fieldsData));

            $response = $this->call(
                'POST',
                route('api.content.save_edit'),
                [
                    'data_base64' => $encoded,
                ],
                [],//params
                $_COOKIE,//cookie
                [],//files
                $_SERVER //server
            );


            $fieldSaved = $response->decodeResponseJson();

            $this->assertEquals(trim($fieldSaved[0]['content']), trim($contentFieldHtml));
            $this->assertEquals($fieldSaved[0]['rel_type'], 'content');
            $this->assertEquals($fieldSaved[0]['field'], 'content');



            $pq2 = \phpQuery::newDocument($contentFieldHtml);
            $this->assertEquals($contentFieldHtml, $pq2->htmlOuter());


            $findPage = Page::whereId($fieldSaved[0]['id'])->first();
            $contentFieldHtml1 = trim($contentFieldHtml);
            $contentFieldHtml2 = trim($findPage->content);

            $this->assertEquals($contentFieldHtml1, $contentFieldHtml2);
            $this->assertEquals($contentFieldHtml, $findPage->content);


        }


    }


    public function testSaveContentOnPageLiveEditXssList()
    {

        $this->cleanupAndPrepare();

        $unique = uniqid('testSaveContentOnPageLiveEditXssList');
        $newCleanMlPage = save_content([
            'subtype' => 'static',
            'content_type' => 'page',
            'layout_file' => 'clean.php',
            'title' => 'xss-test-' . $unique,
            'preview_layout_file' => 'clean.php',
            'is_active' => 1,
        ]);

        $findPage = Page::whereId($newCleanMlPage)->first();

        $pageId = $findPage->id;
        $this->assertEquals($findPage->id, $newCleanMlPage);

        $pageLink = content_link($pageId);
        $pageLink = '/' . str_replace(site_url(), '', $pageLink);

        $_SERVER['PHP_SELF'] = '/index.php';
        $_SERVER['REQ`UEST_URI'] = $pageLink;
        $_SERVER['REDIRECT_URL'] = $pageLink;
        $_SERVER['HTTP_REFERER'] = content_link($pageId);



        $zip = new \ZipArchive();
        $zip->open(__DIR__ . '/../../Helper/tests/misc/xss-test-files.zip');
        $xssList = $zip->getFromName('xss-payload-list.txt');
        $zip->close();

        $xssList = preg_replace('~\R~u', "\r\n", $xssList);
        $xssList = explode(PHP_EOL, $xssList);


        $xssListChunks = array_chunk($xssList, 100);


        foreach ($xssListChunks as $stringChunk) {

            $string = implode(PHP_EOL, $stringChunk);
             if (empty(trim($string))) {
                continue;
            }

            $contentFieldHtml = $string;

            $fieldsData = [
                'field_data_0' => [
                    'attributes' => [
                        'class' => 'container edit',
                        'rel' => 'content',
                        'rel_id' => $findPage->id,
                        'field' => 'content',
                    ],
                    'html' => $contentFieldHtml
                ]
            ];

            $encoded = base64_encode(json_encode($fieldsData));

            $response = $this->call(
                'POST',
                route('api.content.save_edit'),
                [
                    'data_base64' => $encoded,
                ],
                [],//params
                $_COOKIE,//cookie
                [],//files
                $_SERVER //server
            );

            $fieldSaved = $response->decodeResponseJson();

            $this->assertNotEquals(trim($fieldSaved[0]['content']), trim($contentFieldHtml));
            $this->assertEquals($fieldSaved[0]['rel_type'], 'content');
            $this->assertEquals($fieldSaved[0]['field'], 'content');

            $findPage = Page::whereId($fieldSaved[0]['id'])->first();
            $contentFieldHtml1 = trim($contentFieldHtml);
            $contentFieldHtml2 = trim($findPage->content);

           $this->assertNotEquals($contentFieldHtml1, $contentFieldHtml2);


            foreach ($stringChunk as $stringItem) {
                if (trim($stringItem) == '') {
                    continue;
                }
                $this->assertStringNotContainsString($stringItem, $findPage->content);
            }



        }
    }



    public function testSaveContentOnPageLiveEditFromOtherExamples()
    {
        $this->cleanupAndPrepare();


        $unique = uniqid('testSaveContentOnPageLiveEditFromOtherExamples');
        $newCleanMlPage = save_content([
            'subtype' => 'static',
            'content_type' => 'page',
            'layout_file' => 'clean.php',
            'title' => 'pagecontent-other-examples-save' . $unique,
            'preview_layout_file' => 'clean.php',
            'is_active' => 1,
        ]);

        $findPage = Page::whereId($newCleanMlPage)->first();

        $pageId = $findPage->id;
        $this->assertEquals($findPage->id, $newCleanMlPage);

        $pageLink = content_link($pageId);
        $pageLink = '/' . str_replace(site_url(), '', $pageLink);

        $_SERVER['PHP_SELF'] = '/index.php';
        $_SERVER['REQ`UEST_URI'] = $pageLink;
        $_SERVER['REDIRECT_URL'] = $pageLink;
        $_SERVER['HTTP_REFERER'] = content_link($pageId);

        $zipname = __DIR__ . '/../../Helper/tests/misc/edit-fields-other-html.zip';


        $zip = new \ZipArchive();

        $htmls = [];
        if ($zip->open($zipname)) {
            for ($i = 0; $i < $zip->numFiles; $i++) {
                $fn = $zip->getNameIndex($i);
                $ext = get_file_extension($fn);
                if ($ext == 'html') {
                    $string = $zip->getFromName($fn);
                    if(trim($string) == ''){
                        continue;
                    }
                    $htmls[$fn] = $string;
                }
            }
        }
        $zip->close();


        $this->assertTrue(!empty($htmls));

        foreach ($htmls as $k => $html) {

            $contentFieldHtml = $html;
            $contentFieldHtml = self::fixLinksPrecentAttributes($contentFieldHtml);

            $contentFieldHtmlTest1 = app()->parser->make_tags($contentFieldHtml);
            $this->assertEquals($contentFieldHtmlTest1, $contentFieldHtml);


            $xssClean = new XSSClean();
            $contentFieldHtmlTest1 = $xssClean->clean($contentFieldHtml);
            $this->assertEquals($contentFieldHtmlTest1, $contentFieldHtml);


        }

        $htmlsChunks = array_chunk($htmls, 100);



      foreach ($htmlsChunks as $k => $htmlChunk) {

          $html = implode('--------chunk-------', $htmlChunk);
         $contentFieldHtml = $html;


           // $contentFieldHtml = str_replace('{SITE_URL}', site_url(), $contentFieldHtml);
            $fieldsData = [
                'field_data_0' => [
                    'attributes' => [
                        'class' => 'container edit',
                        'rel' => 'content',
                        'rel_id' => $findPage->id,
                        'field' => 'content',
                    ],
                    'html' => $contentFieldHtml
                ]
            ];

            $contentFieldHtml = self::fixLinksPrecentAttributes($contentFieldHtml);

            $encoded = base64_encode(json_encode($fieldsData));
            $response = $this->call(
                'POST',
                route('api.content.save_edit'),
                [
                    'data_base64' => $encoded,
                ],
                [],//params
                $_COOKIE,//cookie
                [],//files
                $_SERVER //server
            );


            $fieldSaved = $response->decodeResponseJson();



            $findPage = Page::whereId($fieldSaved[0]['id'])->first();
            $contentFieldHtml1 = trim($contentFieldHtml);
            $contentFieldHtml2 = trim($findPage->content);

            $this->assertEquals($contentFieldHtml1, $contentFieldHtml2);
            $this->assertEquals($contentFieldHtml, $findPage->content);


       }


    }

    private function cleanupAndPrepare(){
        $user = User::where('is_admin', '=', '1')->first();
        Auth::login($user);
        \Config::set('microweber.disable_model_cache', 1);


        MultilanguageHelpers::setMultilanguageEnabled(0);
        Page::truncate();
    public static function fixLinksPrecentAttributes($text)
    {
        $text = str_ireplace('{SITE_URL}','___mw-site-url-temp-replace-on-clean___', $text);
        $pq = \phpQuery::newDocument($text);

        foreach($pq->find('a') as $stuffs)
        {
            $href = pq($stuffs)->attr('href');
            if($href){
            pq($stuffs)->attr('href', str_replace(' ', '%20', $href));
            }
        }
        $text = $pq->htmlOuter();
        $text = str_ireplace('___mw-site-url-temp-replace-on-clean___','{SITE_URL}',     $text);
        return $text;

    }


          //      '@<![\s\S]*?--[ \t\n\r]*>@', // Strip multi-line comments

               // '/<title(.*?)<\/title>/is',
              //  '/<audio(.*?)<\/audio>/is',
             //   '/<video(.*?)<\/video>/is',
               // '/<head(.*?)>/is',


        $_preserve_replaced_tags = [];
        $html = str_ireplace('{SITE_URL}','___mw-site-url-temp-replace-on-clean___', $html);
//        $tags = [ 'textarea', 'pre','code', 'svg', 'kbd'];
//
//        foreach ($tags as $tag) {
//
//            //  $script_pattern = "/<".$tag."[^>]*>(.*)<\/.$tag.>/Uis";
//            $script_pattern = "/\<" . $tag . "(.*?)?\>(.|\s)*?\<\/" . $tag . "\>/i";
//
//            preg_match_all($script_pattern, $html, $mw_script_matches);
//
//            if (!empty($mw_script_matches)) {
//                foreach ($mw_script_matches [0] as $key => $value) {
//                    if ($value != '') {
//                        $v1 = crc32($value);
//                        $v1 = 'mw_xss_clean_repeserve_tags_tag_' . $tag . $v1 . '';
//                        $html = str_replace($value, $v1, $html);
//                        $_preserve_replaced_tags[$v1] = $value;
//
//                    }
//                }
//            }
//
//        }










        $allowAttibutes = [

            'href',
            'alt',
            'target',
            'srcset',
            'sizes',
            'title',
        $antiXss->removeEvilAttributes($allowAttibutes);
        $allowTags = [
            'head',
            'header',
            'main',
            'aside',
            'form',
            'title',
            'input',
            'button',
            'select',
            'option',
            'textarea',
            'picture',
            'source',
         ];

        $antiXss->removeEvilHtmlTags($allowTags);
        $allowRegex = [
//            '<!--(.*)-->' => '<!--(.*)-->',
//            '&lt;!--',
//            '&lt;!--$1--&gt;'
            '<!--(.*)-->' => '&lt;!--$1--&gt;',
            '&lt;!--', '&lt;!--$1--&gt;'


        $antiXss->removeNeverAllowedRegex($allowRegex);

        $allowNotClosed= [
            'li',
            'ul',
            'textarea',
        ];
        $antiXss->removeDoNotCloseHtmlTags($allowNotClosed);

        $html_to_return = $html;
        if ($_preserve_replaced_tags) {
            foreach ($_preserve_replaced_tags as $key => $value) {

                $html_to_return = str_replace($key, $value, $html_to_return);
            }
        }


        $html_to_return = str_ireplace('___mw-site-url-temp-replace-on-clean___','{SITE_URL}', $html_to_return);


        return $html_to_return;