diff --git a/COPYRIGHT b/COPYRIGHT index 93f4a43fee1..405482ff71c 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -29,7 +29,7 @@ GeoIP2 0.2.0 Apache License 2.0 Yes Mobiledetect 2.8.41 MIT License Yes Detect mobile devices browsers NuSoap 0.9.5 LGPL 2.1+ Yes Library to develop SOAP Web services (not into rpm and deb package) PEAR Mail_MIME 1.8.9 BSD Yes NuSoap dependency -ParseDown 1.6 MIT License Yes Markdown parser +ParseDown 1.7.4 MIT License Yes Markdown parser PCLZip 2.8.4 LGPL-3+ Yes Library to zip/unzip files PHPDebugBar 1.18.2 MIT License Yes Used only by the module "debugbar" for developers PHP-Imap 2.7.2 MIT License Yes Library to use IMAP with OAuth diff --git a/htdocs/core/lib/parsemd.lib.php b/htdocs/core/lib/parsemd.lib.php index 72903dd9e76..89e0936611f 100644 --- a/htdocs/core/lib/parsemd.lib.php +++ b/htdocs/core/lib/parsemd.lib.php @@ -1,5 +1,5 @@ +/* Copyright (C) 2008-2023 Laurent Destailleur * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,7 +18,7 @@ /** * \file htdocs/core/lib/parsemd.lib.php - * \brief This file contains functions dedicated to MD parsind. + * \brief This file contains functions dedicated to MD parsing. */ /** @@ -40,8 +40,9 @@ function dolMd2Html($content, $parser = 'parsedown', $replaceimagepath = null) } if ($parser == 'parsedown') { include_once DOL_DOCUMENT_ROOT.'/includes/parsedown/Parsedown.php'; - $Parsedown = new Parsedown(); - $content = $Parsedown->text($content); + $parsedown = new Parsedown(); + $parsedown->setSafeMode(true); // This will escape HTML link into html entities but markdown links are ok + $content = $parsedown->text($content); } else { $content = nl2br($content); } diff --git a/htdocs/core/modules/DolibarrModules.class.php b/htdocs/core/modules/DolibarrModules.class.php index 62dbd494082..e98b00b658b 100644 --- a/htdocs/core/modules/DolibarrModules.class.php +++ b/htdocs/core/modules/DolibarrModules.class.php @@ -683,6 +683,11 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it if ((float) DOL_VERSION >= 6.0) { @include_once DOL_DOCUMENT_ROOT.'/core/lib/parsemd.lib.php'; + // Replace a HTML string with a Markdown syntax + $content = preg_replace('/([^<]+)<\/a>/', '[\2](\1)', $content); + //$content = preg_replace('/([^<]+)<\/a>/', '[\3](\1){:target="\2"}', $content); + $content = preg_replace('/([^<]+)<\/a>/', '[\3](\1)', $content); + $content = dolMd2Html( $content, 'parsedown', @@ -692,6 +697,8 @@ class DolibarrModules // Can not be abstract, because we need to instantiate it 'images/' => dol_buildpath(strtolower($this->name).'/images/', 1), ) ); + + $content = preg_replace('/safeMode = (bool) $safeMode; + + return $this; + } + + protected $safeMode; + + protected $safeLinksWhitelist = array( + 'http://', + 'https://', + 'ftp://', + 'ftps://', + 'mailto:', + 'data:image/png;base64,', + 'data:image/gif;base64,', + 'data:image/jpeg;base64,', + 'irc:', + 'ircs:', + 'git:', + 'ssh:', + 'news:', + 'steam:', + ); + # # Lines # @@ -141,11 +167,7 @@ class Parsedown foreach ($parts as $part) { - // @CHANGE LDR Fix when mb_strlen is not available - //$shortage = 4 - mb_strlen($line, 'utf-8') % 4; - if (function_exists('mb_strlen')) $len = mb_strlen($line, 'utf-8'); - else $len = strlen($line); - $shortage = 4 - $len % 4; + $shortage = 4 - mb_strlen($line, 'utf-8') % 4; $line .= str_repeat(' ', $shortage); $line .= $part; @@ -346,8 +368,6 @@ class Parsedown { $text = $Block['element']['text']['text']; - $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); - $Block['element']['text']['text'] = $text; return $Block; @@ -358,7 +378,7 @@ class Parsedown protected function blockComment($Line) { - if ($this->markupEscaped) + if ($this->markupEscaped or $this->safeMode) { return; } @@ -400,7 +420,7 @@ class Parsedown protected function blockFencedCode($Line) { - if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([\w-]+)?[ ]*$/', $Line['text'], $matches)) + if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([^`]+)?[ ]*$/', $Line['text'], $matches)) { $Element = array( 'name' => 'code', @@ -409,7 +429,21 @@ class Parsedown if (isset($matches[1])) { - $class = 'language-'.$matches[1]; + /** + * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes + * Every HTML element may have a class attribute specified. + * The attribute, if specified, must have a value that is a set + * of space-separated tokens representing the various classes + * that the element belongs to. + * [...] + * The space characters, for the purposes of this specification, + * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), + * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and + * U+000D CARRIAGE RETURN (CR). + */ + $language = substr($matches[1], 0, strcspn($matches[1], " \t\n\f\r")); + + $class = 'language-'.$language; $Element['attributes'] = array( 'class' => $class, @@ -461,8 +495,6 @@ class Parsedown { $text = $Block['element']['text']['text']; - $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8'); - $Block['element']['text']['text'] = $text; return $Block; @@ -551,6 +583,8 @@ class Parsedown { $Block['li']['text'] []= ''; + $Block['loose'] = true; + unset($Block['interrupted']); } @@ -599,6 +633,22 @@ class Parsedown } } + protected function blockListComplete(array $Block) + { + if (isset($Block['loose'])) + { + foreach ($Block['element']['text'] as &$li) + { + if (end($li['text']) !== '') + { + $li['text'] []= ''; + } + } + } + + return $Block; + } + # # Quote @@ -682,12 +732,12 @@ class Parsedown protected function blockMarkup($Line) { - if ($this->markupEscaped) + if ($this->markupEscaped or $this->safeMode) { return; } - if (preg_match('/^<(\w*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) + if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) { $element = strtolower($matches[1]); @@ -1001,7 +1051,7 @@ class Parsedown # ~ # - public function line($text) + public function line($text, $nonNestables=array()) { $markup = ''; @@ -1017,6 +1067,13 @@ class Parsedown foreach ($this->InlineTypes[$marker] as $inlineType) { + # check to see if the current inline type is nestable in the current context + + if ( ! empty($nonNestables) and in_array($inlineType, $nonNestables)) + { + continue; + } + $Inline = $this->{'inline'.$inlineType}($Excerpt); if ( ! isset($Inline)) @@ -1038,6 +1095,13 @@ class Parsedown $Inline['position'] = $markerPosition; } + # cause the new element to 'inherit' our non nestables + + foreach ($nonNestables as $non_nestable) + { + $Inline['element']['nonNestables'][] = $non_nestable; + } + # the text that comes before the inline $unmarkedText = substr($text, 0, $Inline['position']); @@ -1078,7 +1142,6 @@ class Parsedown if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(? 'img', 'attributes' => array( 'src' => $Link['element']['attributes']['href'], - 'alt' => $Link['element']['text'], - // @CHANGE LDR - 'class' => (!empty($Link['element']['attributes']['class']) ? $Link['element']['attributes']['class'] : '') + 'alt' => $Link['element']['text'], ), ), ); @@ -1199,6 +1260,7 @@ class Parsedown $Element = array( 'name' => 'a', 'handler' => 'line', + 'nonNestables' => array('Url', 'Link'), 'text' => null, 'attributes' => array( 'href' => null, @@ -1233,13 +1295,6 @@ class Parsedown } $extent += strlen($matches[0]); - - // @CHANGE LDR - if (preg_match('/{([^}]+)}/', $remainder, $matches2)) - { - $Element['attributes']['class'] = $matches2[1]; - $remainder = preg_replace('/{'.preg_quote($matches2[1],'/').'}/', '', $remainder); - } } else { @@ -1266,8 +1321,6 @@ class Parsedown $Element['attributes']['title'] = $Definition['title']; } - $Element['attributes']['href'] = str_replace(array('&', '<'), array('&', '<'), $Element['attributes']['href']); - return array( 'extent' => $extent, 'element' => $Element, @@ -1276,12 +1329,12 @@ class Parsedown protected function inlineMarkup($Excerpt) { - if ($this->markupEscaped or strpos($Excerpt['text'], '>') === false) + if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) { return; } - if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w*[ ]*>/s', $Excerpt['text'], $matches)) + if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*[ ]*>/s', $Excerpt['text'], $matches)) { return array( 'markup' => $matches[0], @@ -1297,7 +1350,7 @@ class Parsedown ); } - if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches)) + if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches)) { return array( 'markup' => $matches[0], @@ -1356,14 +1409,16 @@ class Parsedown if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) { + $url = $matches[0][0]; + $Inline = array( 'extent' => strlen($matches[0][0]), 'position' => $matches[0][1], 'element' => array( 'name' => 'a', - 'text' => $matches[0][0], + 'text' => $url, 'attributes' => array( - 'href' => $matches[0][0], + 'href' => $url, ), ), ); @@ -1376,7 +1431,7 @@ class Parsedown { if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches)) { - $url = str_replace(array('&', '<'), array('&', '<'), $matches[1]); + $url = $matches[1]; return array( 'extent' => strlen($matches[0]), @@ -1414,6 +1469,11 @@ class Parsedown protected function element(array $Element) { + if ($this->safeMode) + { + $Element = $this->sanitiseElement($Element); + } + $markup = '<'.$Element['name']; if (isset($Element['attributes'])) @@ -1425,23 +1485,45 @@ class Parsedown continue; } - $markup .= ' '.$name.'="'.$value.'"'; + $markup .= ' '.$name.'="'.self::escape($value).'"'; } } + $permitRawHtml = false; + if (isset($Element['text'])) + { + $text = $Element['text']; + } + // very strongly consider an alternative if you're writing an + // extension + elseif (isset($Element['rawHtml'])) + { + $text = $Element['rawHtml']; + $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; + $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; + } + + if (isset($text)) { $markup .= '>'; + if (!isset($Element['nonNestables'])) + { + $Element['nonNestables'] = array(); + } + if (isset($Element['handler'])) { - // @CHANGE LDR - //$markup .= $this->{$Element['handler']}($Element['text']); - $markup .= preg_replace('/>{[^}]+}/', '>', $this->{$Element['handler']}($Element['text'])); + $markup .= $this->{$Element['handler']}($text, $Element['nonNestables']); + } + elseif (!$permitRawHtml) + { + $markup .= self::escape($text, true); } else { - $markup .= $Element['text']; + $markup .= $text; } $markup .= ''; @@ -1500,10 +1582,77 @@ class Parsedown return $markup; } + protected function sanitiseElement(array $Element) + { + static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; + static $safeUrlNameToAtt = array( + 'a' => 'href', + 'img' => 'src', + ); + + if (isset($safeUrlNameToAtt[$Element['name']])) + { + $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); + } + + if ( ! empty($Element['attributes'])) + { + foreach ($Element['attributes'] as $att => $val) + { + # filter out badly parsed attribute + if ( ! preg_match($goodAttribute, $att)) + { + unset($Element['attributes'][$att]); + } + # dump onevent attribute + elseif (self::striAtStart($att, 'on')) + { + unset($Element['attributes'][$att]); + } + } + } + + return $Element; + } + + protected function filterUnsafeUrlInAttribute(array $Element, $attribute) + { + foreach ($this->safeLinksWhitelist as $scheme) + { + if (self::striAtStart($Element['attributes'][$attribute], $scheme)) + { + return $Element; + } + } + + $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); + + return $Element; + } + # # Static Methods # + protected static function escape($text, $allowQuotes = false) + { + return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); + } + + protected static function striAtStart($string, $needle) + { + $len = strlen($needle); + + if ($len > strlen($string)) + { + return false; + } + else + { + return strtolower(substr($string, 0, $len)) === strtolower($needle); + } + } + static function instance($name = 'default') { if (isset(self::$instances[$name])) @@ -1554,10 +1703,10 @@ class Parsedown 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', 'i', 'rp', 'del', 'code', 'strike', 'marquee', 'q', 'rt', 'ins', 'font', 'strong', - 's', 'tt', 'sub', 'mark', - 'u', 'xm', 'sup', 'nobr', - 'var', 'ruby', - 'wbr', 'span', - 'time', + 's', 'tt', 'kbd', 'mark', + 'u', 'xm', 'sub', 'nobr', + 'sup', 'ruby', + 'var', 'span', + 'wbr', 'time', ); } diff --git a/htdocs/includes/parsedown/README.md b/htdocs/includes/parsedown/README.md index 4e5659a4804..b5d9ed2ee33 100644 --- a/htdocs/includes/parsedown/README.md +++ b/htdocs/includes/parsedown/README.md @@ -1,4 +1,4 @@ -> You might also like [Caret](http://caret.io?ref=parsedown) - our Markdown editor for Mac / Windows / Linux. +> I also make [Caret](https://caret.io?ref=parsedown) - a Markdown editor for Mac and PC. ## Parsedown @@ -15,6 +15,7 @@ Better Markdown Parser in PHP ### Features * One File +* No Dependencies * Super Fast * Extensible * [GitHub flavored](https://help.github.com/articles/github-flavored-markdown) @@ -35,6 +36,35 @@ echo $Parsedown->text('Hello _Parsedown_!'); # prints:

Hello ParsedownsetSafeMode(true); +``` + +If instead, you wish to allow HTML within untrusted user-input, but still want output to be free from XSS it is recommended that you make use of a HTML sanitiser that allows HTML tags to be whitelisted, like [HTML Purifier](http://htmlpurifier.org/). + +In both cases you should strongly consider employing defence-in-depth measures, like [deploying a Content-Security-Policy](https://scotthelme.co.uk/content-security-policy-an-introduction/) (a browser security feature) so that your page is likely to be safe even if an attacker finds a vulnerability in one of the first lines of defence above. + +#### Security of Parsedown Extensions + +Safe mode does not necessarily yield safe results when using extensions to Parsedown. Extensions should be evaluated on their own to determine their specific safety against XSS. + +### Escaping HTML +> ⚠️  **WARNING:** This method isn't safe from XSS! + +If you wish to escape HTML **in trusted input**, you can use the following: +```php +$parsedown = new Parsedown; +$parsedown->setMarkupEscaped(true); +``` + +Beware that this still allows users to insert unsafe scripting vectors, such as links like `[xss](javascript:alert%281%29)`. + ### Questions **How does Parsedown work?** @@ -49,7 +79,7 @@ It passes most of the CommonMark tests. Most of the tests that don't pass deal w **Who uses it?** -[phpDocumentor](http://www.phpdoc.org/), [October CMS](http://octobercms.com/), [Bolt CMS](http://bolt.cm/), [Kirby CMS](http://getkirby.com/), [Grav CMS](http://getgrav.org/), [Statamic CMS](http://www.statamic.com/), [Herbie CMS](http://www.getherbie.org/), [RaspberryPi.org](http://www.raspberrypi.org/), [Symfony demo](https://github.com/symfony/symfony-demo) and [more](https://packagist.org/packages/erusev/parsedown/dependents). +[Laravel Framework](https://laravel.com/), [Bolt CMS](http://bolt.cm/), [Grav CMS](http://getgrav.org/), [Herbie CMS](http://www.getherbie.org/), [Kirby CMS](http://getkirby.com/), [October CMS](http://octobercms.com/), [Pico CMS](http://picocms.org), [Statamic CMS](http://www.statamic.com/), [phpDocumentor](http://www.phpdoc.org/), [RaspberryPi.org](http://www.raspberrypi.org/), [Symfony demo](https://github.com/symfony/symfony-demo) and [more](https://packagist.org/packages/erusev/parsedown/dependents). **How can I help?** diff --git a/htdocs/includes/parsedown/composer.json b/htdocs/includes/parsedown/composer.json index 28145af68ca..f8b40f8ce84 100644 --- a/htdocs/includes/parsedown/composer.json +++ b/htdocs/includes/parsedown/composer.json @@ -13,9 +13,21 @@ } ], "require": { - "php": ">=5.3.0" + "php": ">=5.3.0", + "ext-mbstring": "*" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" }, "autoload": { "psr-0": {"Parsedown": ""} + }, + "autoload-dev": { + "psr-0": { + "TestParsedown": "test/", + "ParsedownTest": "test/", + "CommonMarkTest": "test/", + "CommonMarkTestWeak": "test/" + } } } diff --git a/htdocs/includes/parsedown/phpunit.xml.dist b/htdocs/includes/parsedown/phpunit.xml.dist deleted file mode 100644 index b2d5e9d4fe1..00000000000 --- a/htdocs/includes/parsedown/phpunit.xml.dist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - test/ParsedownTest.php - - - \ No newline at end of file