Aleksander Machniak
2016-01-18 22a018d082ba7ce4f88a44bb473180b5dd58277a
Merge branch 'dev-svg'
3 files modified
325 ■■■■ changed files
program/lib/Roundcube/rcube_washtml.php 266 ●●●● patch | view | raw | blame | history
program/steps/mail/get.inc 20 ●●●● patch | view | raw | blame | history
tests/Framework/Washtml.php 39 ●●●●● patch | view | raw | blame | history
program/lib/Roundcube/rcube_washtml.php
@@ -97,7 +97,20 @@
        'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', 'img',
        'video', 'source',
        // form elements
        'button', 'input', 'textarea', 'select', 'option', 'optgroup'
        'button', 'input', 'textarea', 'select', 'option', 'optgroup',
        // SVG
        'svg', 'altglyph', 'altglyphdef', 'altglyphitem', 'animate',
        'animatecolor', 'animatetransform', 'circle', 'clippath', 'defs', 'desc',
        'ellipse', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line',
        'lineargradient', 'marker', 'mask', 'mpath', 'path', 'pattern',
        'polygon', 'polyline', 'radialgradient', 'rect', 'set', 'stop', 'switch', 'symbol',
        'text', 'textpath', 'tref', 'tspan', 'use', 'view', 'vkern', 'filter',
         // SVG Filters
        'feblend', 'fecolormatrix', 'fecomponenttransfer', 'fecomposite',
        'feconvolvematrix', 'fediffuselighting', 'fedisplacementmap',
        'feflood', 'fefunca', 'fefuncb', 'fefuncg', 'fefuncr', 'fegaussianblur',
        'feimage', 'femerge', 'femergenode', 'femorphology', 'feoffset',
        'fespecularlighting', 'fetile', 'feturbulence',
    );
    /* Ignore these HTML tags and their content */
@@ -110,13 +123,41 @@
        'bordercolordark', 'face', 'marginwidth', 'marginheight', 'axis', 'border',
        'abbr', 'char', 'charoff', 'clear', 'compact', 'coords', 'vspace', 'hspace',
        'cellborder', 'size', 'lang', 'dir', 'usemap', 'shape', 'media',
        'background', 'src', 'poster', 'href',
        // attributes of form elements
        'type', 'rows', 'cols', 'disabled', 'readonly', 'checked', 'multiple', 'value'
        'type', 'rows', 'cols', 'disabled', 'readonly', 'checked', 'multiple', 'value',
        // SVG
        'accent-height', 'accumulate', 'additive', 'alignment-baseline', 'alphabetic',
        'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseprofile',
        'baseline-shift', 'begin', 'bias', 'by', 'clip', 'clip-path', 'clip-rule',
        'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile',
        'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction',
        'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'fill', 'fill-opacity',
        'fill-rule', 'filter', 'flood-color', 'flood-opacity', 'font-family', 'font-size',
        'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'from',
        'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform',
        'image-rendering', 'in', 'in2', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints',
        'keysplines', 'keytimes', 'lengthadjust', 'letter-spacing', 'kernelmatrix',
        'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid',
        'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits',
        'maskunits', 'max', 'mask', 'mode', 'min', 'numoctaves', 'offset', 'operator',
        'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order',
        'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits',
        'points', 'preservealpha', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount',
        'repeatdur', 'restart', 'rotate', 'scale', 'seed', 'shape-rendering', 'show', 'specularconstant',
        'specularexponent', 'spreadmethod', 'stddeviation', 'stitchtiles', 'stop-color',
        'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap',
        'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width',
        'surfacescale', 'targetx', 'targety', 'transform', 'text-anchor', 'text-decoration',
        'text-rendering', 'textlength', 'to', 'u1', 'u2', 'unicode', 'values', 'viewbox',
        'visibility', 'vert-adv-y', 'version', 'vert-origin-x', 'vert-origin-y', 'word-spacing',
        'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2',
        'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan',
    );
    /* Elements which could be empty and be returned in short form (<tag />) */
    static $void_elements = array('area', 'base', 'br', 'col', 'command', 'embed', 'hr',
        'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
        'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr',
    );
    /* State for linked objects in HTML */
@@ -143,13 +184,15 @@
    /* Max nesting level */
    private $max_nesting_level;
    private $is_xml = false;
    /**
     * Class constructor
     */
    public function __construct($p = array())
    {
        $this->_html_elements   = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements) ;
        $this->_html_elements   = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements);
        $this->_html_attribs    = array_flip((array)$p['html_attribs']) + array_flip(self::$html_attribs);
        $this->_ignore_elements = array_flip((array)$p['ignore_elements']) + array_flip(self::$ignore_elements);
        $this->_void_elements   = array_flip((array)$p['void_elements']) + array_flip(self::$void_elements);
@@ -186,22 +229,8 @@
                foreach ($this->explode_style($str) as $val) {
                    if (preg_match('/^url\(/i', $val)) {
                        if (preg_match('/^url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)/iu', $val, $match)) {
                            $url = $match[1];
                            if (($src = $this->config['cid_map'][$url])
                                || ($src = $this->config['cid_map'][$this->config['base_url'].$url])
                            ) {
                                $value .= ' url('.htmlspecialchars($src, ENT_QUOTES) . ')';
                            }
                            else if (preg_match('!^(https?:)?//[a-z0-9/._+-]+$!i', $url, $m)) {
                                if ($this->config['allow_remote']) {
                                    $value .= ' url('.htmlspecialchars($m[0], ENT_QUOTES).')';
                                }
                                else {
                                    $this->extlinks = true;
                                }
                            }
                            else if (preg_match('/^data:.+/i', $url)) { // RFC2397
                                $value .= ' url('.htmlspecialchars($url, ENT_QUOTES).')';
                            if ($url = $this->wash_uri($match[1])) {
                                $value .= ' url(' . htmlspecialchars($url, ENT_QUOTES) . ')';
                            }
                        }
                    }
@@ -232,54 +261,137 @@
     */
    private function wash_attribs($node)
    {
        $t = '';
        $washed = '';
        $result = '';
        $washed = array();
        foreach ($node->attributes as $key => $plop) {
            $key   = strtolower($key);
            $value = $node->getAttribute($key);
        foreach ($node->attributes as $name => $attr) {
            $key   = strtolower($name);
            $value = $attr->nodeValue;
            if (isset($this->_html_attribs[$key]) ||
                ($key == 'href' && ($value = trim($value))
                    && !preg_match('!^(javascript|vbscript|data:text)!i', $value)
                    && preg_match('!^([a-z][a-z0-9.+-]+:|//|#).+!i', $value))
            ) {
                $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
            }
            else if ($key == 'style' && ($style = $this->wash_style($value))) {
            if ($key == 'style' && ($style = $this->wash_style($value))) {
                // replace double quotes to prevent syntax error and XSS issues (#1490227)
                $t .= ' style="' . str_replace('"', '&quot;', $style) . '"';
                $result .= ' style="' . str_replace('"', '&quot;', $style) . '"';
            }
            else if ($key == 'background'
                || ($key == 'src' && preg_match('/^(img|source)$/i', $node->tagName))
                || ($key == 'poster' && strtolower($node->tagName) == 'video')
            ) {
                if (($src = $this->config['cid_map'][$value])
                    || ($src = $this->config['cid_map'][$this->config['base_url'].$value])
                ) {
                    $t .= ' ' . $key . '="' . htmlspecialchars($src, ENT_QUOTES) . '"';
                }
                else if (preg_match('/^(http|https|ftp):.+/i', $value)) {
                    if ($this->config['allow_remote']) {
                        $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
            else if (isset($this->_html_attribs[$key])) {
                $value = trim($value);
                $out   = null;
                // in SVG to/from attribs may contain anything, including URIs
                if ($key == 'to' || $key == 'from') {
                    $key = strtolower($node->getAttribute('attributeName'));
                    if ($key && !isset($this->_html_attribs[$key])) {
                        $key = null;
                    }
                    else {
                        $this->extlinks = true;
                        if ($this->config['blocked_src']) {
                            $t .= ' ' . $key . '="' . htmlspecialchars($this->config['blocked_src'], ENT_QUOTES) . '"';
                }
                if ($this->is_image_attribute($node->tagName, $key)) {
                    $out = $this->wash_uri($value, true);
                }
                else if ($this->is_link_attribute($node->tagName, $key)) {
                    if (!preg_match('!^(javascript|vbscript|data:text)!i', $value)
                        && preg_match('!^([a-z][a-z0-9.+-]+:|//|#).+!i', $value)
                    ) {
                        $out = $value;
                    }
                }
                else if ($this->is_funciri_attribute($node->tagName, $key)) {
                    if (preg_match('/^[a-z:]*url\(/i', $val)) {
                        if (preg_match('/^([a-z:]*url)\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)/iu', $value, $match)) {
                            if ($url = $this->wash_uri($match[2])) {
                                $result .= ' ' . $attr->nodeName . '="' . $match[1] . '(' . htmlspecialchars($url, ENT_QUOTES) . ')'
                                     . substr($val, strlen($match[0])) . '"';
                                continue;
                            }
                        }
                        else {
                            $out = $value;
                        }
                    }
                    else {
                        $out = $value;
                    }
                }
                else if (preg_match('/^data:.+/i', $value)) { // RFC2397
                    $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
                else if ($key) {
                   $out = $value;
                }
                if ($out !== null && $out !== '') {
                    $result .= ' ' . $attr->nodeName . '="' . htmlspecialchars($out, ENT_QUOTES) . '"';
                }
                else if ($value) {
                    $washed[] = htmlspecialchars($attr->nodeName, ENT_QUOTES);
                }
            }
            else {
                $washed .= ($washed ? ' ' : '') . $key;
                $washed[] = htmlspecialchars($attr->nodeName, ENT_QUOTES);
            }
        }
        return $t . ($washed && $this->config['show_washed'] ? ' x-washed="'.$washed.'"' : '');
        if (!empty($washed) && $this->config['show_washed']) {
            $result .= ' x-washed="' . implode(' ', $washed) . '"';
        }
        return $result;
    }
    /**
     * Wash URI value
     */
    private function wash_uri($uri, $blocked_source = false)
    {
        if (($src = $this->config['cid_map'][$uri])
            || ($src = $this->config['cid_map'][$this->config['base_url'].$uri])
        ) {
            return $src;
        }
        // allow url(#id) used in SVG
        if ($uri[0] == '#') {
            return $uri;
        }
        if (preg_match('/^(http|https|ftp):.+/i', $uri)) {
            if ($this->config['allow_remote']) {
                return $uri;
            }
            $this->extlinks = true;
            if ($blocked_source && $this->config['blocked_src']) {
                return $this->config['blocked_src'];
            }
        }
        else if (preg_match('/^data:image.+/i', $uri)) { // RFC2397
            return $uri;
        }
    }
    /**
     * Check it the tag/attribute may contain an URI
     */
    private function is_link_attribute($tag, $attr)
    {
        return $tag == 'a' && $attr == 'href';
    }
    /**
     * Check it the tag/attribute may contain an image URI
     */
    private function is_image_attribute($tag, $attr)
    {
        return $attr == 'background'
            || $attr == 'color-profile' // SVG
            || ($attr == 'poster' && $tag == 'video')
            || ($attr == 'src' && preg_match('/^(img|source)$/i', $tag))
            || ($tag == 'image' && $attr == 'href'); // SVG
    }
    /**
     * Check it the tag/attribute may contain a FUNCIRI value
     */
    private function is_funciri_attribute($tag, $attr)
    {
        return in_array($attr, array('fill', 'filter', 'stroke', 'marker-start',
            'marker-end', 'marker-mid', 'clip-path', 'mask', 'cursor'));
    }
    /**
@@ -322,14 +434,31 @@
                }
                else if (isset($this->_html_elements[$tagName])) {
                    $content = $this->dumpHtml($node, $level);
                    $dump .= '<' . $tagName . $this->wash_attribs($node) .
                        ($content === '' && isset($this->_void_elements[$tagName]) ? ' />' : ">$content</$tagName>");
                    $dump .= '<' . $node->tagName;
                    if ($tagName == 'svg') {
                        $xpath = new DOMXPath($node->ownerDocument);
                        foreach ($xpath->query('namespace::*') as $ns) {
                            if ($ns->nodeName != 'xmlns:xml') {
                                $dump .= ' ' . $ns->nodeName . '="' . $ns->nodeValue . '"';
                            }
                        }
                    }
                    $dump .= $this->wash_attribs($node);
                    if ($content === '' && ($this->is_xml || isset($this->_void_elements[$tagName]))) {
                        $dump .= ' />';
                    }
                    else {
                        $dump .= '>' . $content . '</' . $node->tagName . '>';
                    }
                }
                else if (isset($this->_ignore_elements[$tagName])) {
                    $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
                    $dump .= '<!-- ' . htmlspecialchars($node->tagName, ENT_QUOTES) . ' not allowed -->';
                }
                else {
                    $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
                    $dump .= '<!-- ' . htmlspecialchars($node->tagName, ENT_QUOTES) . ' ignored -->';
                    $dump .= $this->dumpHtml($node, $level); // ignore tags not its content
                }
                break;
@@ -375,12 +504,18 @@
        // Detect max nesting level (for dumpHTML) (#1489110)
        $this->max_nesting_level = (int) @ini_get('xdebug.max_nesting_level');
        // SVG need to be parsed as XML
        $this->is_xml = stripos($html, '<svg') !== false || stripos($html, '<?xml') !== false;
        $method       = $this->is_xml ? 'loadXML' : 'loadHTML';
        $options      = 0;
        // Use optimizations if supported
        if (PHP_VERSION_ID >= 50400) {
            @$node->loadHTML($html, LIBXML_PARSEHUGE | LIBXML_COMPACT);
            $options = LIBXML_PARSEHUGE | LIBXML_COMPACT | LIBXML_NONET;
            @$node->{$method}($html, $options);
        }
        else {
            @$node->loadHTML($html);
            @$node->{$method}($html);
        }
        return $this->dumpHtml($node);
@@ -399,6 +534,8 @@
     */
    private function cleanup($html)
    {
        $html = trim($html);
        // special replacements (not properly handled by washtml class)
        $html_search = array(
            // space(s) between <NOBR>
@@ -420,17 +557,19 @@
            '',
            '<html>',
        );
        $html = preg_replace($html_search, $html_replace, trim($html));
        //-> Replace all of those weird MS Word quotes and other high characters
        // Replace all of those weird MS Word quotes and other high characters
        $badwordchars = array(
            "\xe2\x80\x98", // left single quote
            "\xe2\x80\x99", // right single quote
            "\xe2\x80\x9c", // left double quote
            "\xe2\x80\x9d", // right double quote
            "\xe2\x80\x94", // em dash
            "\xe2\x80\xa6" // elipses
            "\xe2\x80\xa6"  // elipses
        );
        $fixedwordchars = array(
            "'",
            "'",
@@ -439,6 +578,7 @@
            '&mdash;',
            '...'
        );
        $html = str_replace($badwordchars, $fixedwordchars, $html);
        // PCRE errors handling (#1486856), should we use something like for every preg_* use?
@@ -484,7 +624,7 @@
        $tagname = $matches[2];
        $tagname = preg_replace(array(
            '/:.*$/',               // Microsoft's Smart Tags <st1:xxxx>
            '/[^a-z0-9_\[\]\!-]/i', // forbidden characters
            '/[^a-z0-9_\[\]\!?-]/i', // forbidden characters
        ), '', $tagname);
        // fix invalid closing tags - remove any attributes (#1489446)
program/steps/mail/get.inc
@@ -515,12 +515,20 @@
 */
function rcmail_svg_filter($body)
{
    $dom = new DOMDocument;
    $dom->loadXML($body);
    // clean SVG with washhtml
    $wash_opts = array(
        'show_washed'   => false,
        'allow_remote'  => false,
        'charset'       => RCUBE_CHARSET,
        'html_elements' => array('title'),
//        'blocked_src'   => 'program/resources/blocked.gif',
    );
    foreach ($dom->getElementsByTagName('script') as $node) {
        $node->parentNode->removeChild($node);
    }
    // initialize HTML washer
    $washer = new rcube_washtml($wash_opts);
    return $dom->saveXML() ?: '';
    // allow CSS styles, will be sanitized by rcmail_washtml_callback()
    $washer->add_callback('style', 'rcmail_washtml_callback');
    return $washer->wash($body);
}
tests/Framework/Washtml.php
@@ -213,4 +213,43 @@
        $this->assertTrue(strpos($washed, $exp) !== false, "Style quotes XSS issue (#1490227)");
    }
    /**
     * Test SVG cleanup
     */
    function test_style_wash_svg()
    {
        $svg = '<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" viewBox="0 0 100 100">
  <polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400" onmouseover="alert(1)" />
  <text x="50" y="68" font-size="48" fill="#FFF" text-anchor="middle"><![CDATA[410]]></text>
  <script type="text/javascript">
    alert(document.cookie);
  </script>
  <text x="10" y="25" >An example text</text>
  <a xlink:href="http://www.w.pl"><rect width="100%" height="100%" /></a>
  <foreignObject xlink:href="data:text/xml,%3Cscript xmlns=\'http://www.w3.org/1999/xhtml\'%3Ealert(1)%3C/script%3E"/>
  <set attributeName="onmouseover" to="alert(1)"/>
  <animate attributeName="onunload" to="alert(1)"/>
  <animate attributeName="xlink:href" begin="0" from="javascript:alert(1)" />
</svg>';
        $exp = '<svg xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" version="1.1" baseProfile="full" viewBox="0 0 100 100">
  <polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400" x-washed="onmouseover" />
  <text x="50" y="68" font-size="48" fill="#FFF" text-anchor="middle">410</text>
  <!-- script not allowed -->
  <text x="10" y="25">An example text</text>
  <a xlink:href="http://www.w.pl"><rect width="100%" height="100%" /></a>
  <!-- foreignObject ignored -->
  <set attributeName="onmouseover" x-washed="to" />
  <animate attributeName="onunload" x-washed="to" />
  <animate attributeName="xlink:href" begin="0" x-washed="from" />
</svg>';
        $washer = new rcube_washtml;
        $washed = $washer->wash($svg);
        $this->assertSame($washed, $exp, "SVG content");
    }
}