Files
iTop/core/htmlsanitizer.class.inc.php
bruno DA SILVA d3525190d5 N°2556 - Html sanitization preserve content of removed tags (except for a forbidden list)
forbidden list: see $aTagsContentRemovableList

(cherry picked from commit 746b47bb0e)
(cherry picked from commit 79909fadc0)
2020-06-22 11:40:38 +02:00

494 lines
14 KiB
PHP

<?php
// Copyright (C) 2016-2017 Combodo SARL
//
// This file is part of iTop.
//
// iTop is free software; you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// iTop is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with iTop. If not, see <http://www.gnu.org/licenses/>
/**
* Base class for all possible implementations of HTML Sanitization
*/
abstract class HTMLSanitizer
{
public function __construct()
{
// Do nothing..
}
/**
* Sanitizes the given HTML document
* @param string $sHTML
* @return string
*/
abstract public function DoSanitize($sHTML);
/**
* Sanitize an HTML string with the configured sanitizer, falling back to HTMLDOMSanitizer in case of Exception or invalid configuration
* @param string $sHTML
* @return string
*/
public static function Sanitize($sHTML)
{
$sSanitizerClass = MetaModel::GetConfig()->Get('html_sanitizer');
if(!class_exists($sSanitizerClass))
{
IssueLog::Warning('The configured "html_sanitizer" class "'.$sSanitizerClass.'" is not a valid class. Will use HTMLDOMSanitizer as the default sanitizer.');
$sSanitizerClass = 'HTMLDOMSanitizer';
}
else if(!is_subclass_of($sSanitizerClass, 'HTMLSanitizer'))
{
IssueLog::Warning('The configured "html_sanitizer" class "'.$sSanitizerClass.'" is not a subclass of HTMLSanitizer. Will use HTMLDOMSanitizer as the default sanitizer.');
$sSanitizerClass = 'HTMLDOMSanitizer';
}
try
{
$oSanitizer = new $sSanitizerClass();
$sCleanHTML = $oSanitizer->DoSanitize($sHTML);
}
catch(Exception $e)
{
if($sSanitizerClass != 'HTMLDOMSanitizer')
{
IssueLog::Warning('Failed to sanitize an HTML string with "'.$sSanitizerClass.'". The following exception occured: '.$e->getMessage());
IssueLog::Warning('Will try to sanitize with HTMLDOMSanitizer.');
// try again with the HTMLDOMSanitizer
$oSanitizer = new HTMLDOMSanitizer();
$sCleanHTML = $oSanitizer->DoSanitize($sHTML);
}
else
{
IssueLog::Error('Failed to sanitize an HTML string with "HTMLDOMSanitizer". The following exception occured: '.$e->getMessage());
IssueLog::Error('The HTML will NOT be sanitized.');
$sCleanHTML = $sHTML;
}
}
return $sCleanHTML;
}
}
/**
* Dummy HTMLSanitizer which does nothing at all!
*
* Can be used if HTML Sanitization is not important
* (for example when importing "safe" data during an on-boarding)
* and performance is at stake
*
* **Warning** : this won't filter HTML inserted in iTop at all, so this is a great security issue !
* Also, the InlineImage objects processing won't be called.
*/
class HTMLNullSanitizer extends HTMLSanitizer
{
/**
* (non-PHPdoc)
* @see HTMLSanitizer::Sanitize()
*/
public function DoSanitize($sHTML)
{
return $sHTML;
}
}
/**
* A standard-compliant HTMLSanitizer based on the HTMLPurifier library by Edward Z. Yang
* Complete but quite slow
* http://htmlpurifier.org
*/
/*
class HTMLPurifierSanitizer extends HTMLSanitizer
{
protected static $oPurifier = null;
public function __construct()
{
if (self::$oPurifier == null)
{
$sLibPath = APPROOT.'lib/htmlpurifier/HTMLPurifier.auto.php';
if (!file_exists($sLibPath))
{
throw new Exception("Missing library '$sLibPath', cannot use HTMLPurifierSanitizer.");
}
require_once($sLibPath);
$oPurifierConfig = HTMLPurifier_Config::createDefault();
$oPurifierConfig->set('Core.Encoding', 'UTF-8'); // defaults to 'UTF-8'
$oPurifierConfig->set('HTML.Doctype', 'XHTML 1.0 Strict'); // defaults to 'XHTML 1.0 Transitional'
$oPurifierConfig->set('URI.AllowedSchemes', array (
'http' => true,
'https' => true,
'data' => true, // This one is not present by default
));
$sPurifierCache = APPROOT.'data/HTMLPurifier';
if (!is_dir($sPurifierCache))
{
mkdir($sPurifierCache);
}
if (!is_dir($sPurifierCache))
{
throw new Exception("Could not create the cache directory '$sPurifierCache'");
}
$oPurifierConfig->set('Cache.SerializerPath', $sPurifierCache); // no trailing slash
self::$oPurifier = new HTMLPurifier($oPurifierConfig);
}
}
public function DoSanitize($sHTML)
{
$sCleanHtml = self::$oPurifier->purify($sHTML);
return $sCleanHtml;
}
}
*/
class HTMLDOMSanitizer extends HTMLSanitizer
{
protected $oDoc;
/**
* @var array
* @see https://www.itophub.io/wiki/page?id=2_6_0%3Aadmin%3Arich_text_limitations
*/
protected static $aTagsWhiteList = array(
'a' => array('href', 'name', 'style', 'target', 'title'),
'b' => array(),
'big' => array(),
'blockquote' => array('style'),
'body' => array(),
'br' => array(),
'center' => array(),
'cite' => array(),
'code' => array('style', 'class'),
'del' => array(),
'div' => array('style'),
'em' => array(),
'fieldset' => array('style'),
'font' => array('face', 'color', 'style', 'size'),
'h1' => array('style'),
'h2' => array('style'),
'h3' => array('style'),
'h4' => array('style'),
'hr' => array('style'),
'html' => array(),
'i' => array(),
'img' => array('src', 'style', 'alt', 'title'),
'ins' => array(),
'kbd' => array(),
'legend' => array('style'),
'li' => array('style'),
'nav' => array('style'),
'ol' => array('style'),
'p' => array('style'),
'pre' => array(),
'q' => array(),
'samp' => array(),
's' => array(), // strikethrough
'section' => array('style'),
'small' => array(),
'span' => array('style'),
'strong' => array(),
'table' => array('style', 'width', 'summary', 'align', 'border', 'cellpadding', 'cellspacing'),
'tbody' => array('style'),
'td' => array('style', 'colspan', 'rowspan'),
'th' => array('style', 'colspan', 'rowspan'),
'thead' => array('style'),
'tr' => array('style', 'colspan', 'rowspan'),
'tt' => array(),
'u' => array(),
'ul' => array('style'),
'var' => array(),
);
protected static $aTagsContentRemovableList = array(
'applet',
'basefont',
'canvas',
'code',
'dialog',
'embed',
'object',
'script',
'style',
);
protected static $aAttrsWhiteList = array(
'src' => '/^(http:|https:|data:)/i',
);
/**
* @var array
* @see https://www.itophub.io/wiki/page?id=2_6_0%3Aadmin%3Arich_text_limitations
*/
protected static $aStylesWhiteList = array(
'background-color',
'border',
'border-collapse',
'bordercolor',
'cellpadding',
'cellspacing',
'color',
'float',
'font',
'font-family',
'font-size',
'font-style',
'height',
'margin',
'padding',
'text-align',
'vertical-align',
'width',
'white-space',
);
public function __construct()
{
parent::__construct();
// Building href validation pattern from url and email validation patterns as the patterns are not used the same way in HTML content than in standard attributes value.
// eg. "foo@bar.com" vs "mailto:foo@bar.com?subject=Title&body=Hello%20world"
if (!array_key_exists('href', self::$aAttrsWhiteList))
{
// Regular urls
$sUrlPattern = utils::GetConfig()->Get('url_validation_pattern');
// Mailto urls
$sMailtoPattern = '(mailto:(' . utils::GetConfig()->Get('email_validation_pattern') . ')(?:\?(?:subject|body)=([a-zA-Z0-9+\$_.-]*)(?:&(?:subject|body)=([a-zA-Z0-9+\$_.-]*))?)?)';
// Notification placeholders
// eg. $this->caller_id$, $this->hyperlink()$, $this->hyperlink(portal)$, $APP_URL$, $MODULES_URL$, ...
// Note: Authorize both $xxx$ and %24xxx%24 as the latter one is encoded when used in HTML attributes (eg. a[href])
$sPlaceholderPattern = '(\$|%24)[\w-]*(->[\w]*(\([\w-]*?\))?)?(\$|%24)';
$sPattern = $sUrlPattern . '|' . $sMailtoPattern . '|' . $sPlaceholderPattern;
$sPattern = '/'.str_replace('/', '\/', $sPattern).'/i';
self::$aAttrsWhiteList['href'] = $sPattern;
}
}
public function DoSanitize($sHTML)
{
$this->oDoc = new DOMDocument();
$this->oDoc->preserveWhitespace = true;
// MS outlook implements empty lines by the mean of <p><o:p></o:p></p>
// We have to transform that into <p><br></p> (which is how Thunderbird implements empty lines)
// Unfortunately, DOMDocument::loadHTML does not take the tag namespaces into account (once loaded there is no way to know if the tag did have a namespace)
// therefore we have to do the transformation upfront
$sHTML = preg_replace('@<o:p>(\s|&nbsp;)*</o:p>@', '<br>', $sHTML);
// Replace badly encoded non breaking space
$sHTML = preg_replace('~\xc2\xa0~', ' ', $sHTML);
@$this->oDoc->loadHTML('<?xml encoding="UTF-8"?>'.$sHTML); // For loading HTML chunks where the character set is not specified
$this->CleanNode($this->oDoc);
$oXPath = new DOMXPath($this->oDoc);
$sXPath = "//body";
$oNodesList = $oXPath->query($sXPath);
if ($oNodesList->length == 0)
{
// No body, save the whole document
$sCleanHtml = $this->oDoc->saveHTML();
}
else
{
// Export only the content of the body tag
$sCleanHtml = $this->oDoc->saveHTML($oNodesList->item(0));
// remove the body tag itself
$sCleanHtml = str_replace( array('<body>', '</body>'), '', $sCleanHtml);
}
return $sCleanHtml;
}
protected function CleanNode(DOMNode $oElement)
{
$this->CleanNodeRemoveForbiddenTags($oElement);
$this->CleanNodeHandleImages($oElement);
$this->CleanNodeRemoveForbiddenAttributes($oElement);
}
protected function CleanNodeRemoveForbiddenTags(DOMNode $oElement)
{
if ($oElement->hasChildNodes())
{
$aValidatedNodes = array();
do
{
$bChildRemoved = false;
$aNodes = array();
foreach($oElement->childNodes as $oNode)
{
$aNodes[] = $oNode;
}
foreach($aNodes as $oNode)
{
if (($oNode instanceof DOMElement) && (!array_key_exists(strtolower($oNode->tagName), self::$aTagsWhiteList)))
{
$bChildRemoved = true;
$this->SmartRemoveChild($oElement, $oNode);
}
else if ($oNode instanceof DOMComment)
{
$oElement->removeChild($oNode);
}
else
{
//if the node is kept, we can recurse into it, bu we want to perform this only once (see the do/while above?)
$bAlreadyValidated = false;
/** @var \DOMNode $oValidatedNode */
foreach ($aValidatedNodes as $oValidatedNode)
{
if ($oValidatedNode->isSameNode($oNode))
{
$bAlreadyValidated = true;
break;
}
}
if (! $bAlreadyValidated)
{
$this->CleanNodeRemoveForbiddenTags($oNode);
$aValidatedNodes[] = $oNode;
}
}
}
} while ($bChildRemoved);
}
}
/**
* Remove a node, but move its inner nodes in the parent.
* Note: invalid/forbidden tags may be moved up, so they have to be checked again.
*
* @param \DOMNode $oParent
* @param \DOMElement $oRemovable
*/
private function SmartRemoveChild(DOMNode $oParent, DOMElement $oRemovable)
{
if (!$oRemovable->hasChildNodes())
{
$oParent->removeChild($oRemovable);
}
else if (in_array(strtolower($oRemovable->tagName), self::$aTagsContentRemovableList))
{
$oParent->removeChild($oRemovable);
}
else
{
/** @var \DOMNode $oNode */
foreach ($oRemovable->childNodes as $oNode)
{
$oNode = $oNode->cloneNode(true);
$oParent->insertBefore($oNode, $oRemovable);
}
$oParent->removeChild($oRemovable);
}
}
protected function CleanNodeHandleImages(DOMNode $oElement)
{
if ($oElement->hasChildNodes())
{
foreach($oElement->childNodes as $oNode)
{
$this->CleanNodeHandleImages($oNode);
if (($oNode instanceof DOMElement) && (strtolower($oNode->tagName) == 'img'))
{
InlineImage::ProcessImageTag($oNode);
}
}
}
}
protected function CleanNodeRemoveForbiddenAttributes(DOMNode $oElement)
{
$aAttrToRemove = array();
// Gather the attributes to remove
if ($oElement->hasAttributes())
{
foreach($oElement->attributes as $oAttr)
{
$sAttr = strtolower($oAttr->name);
if (!in_array($sAttr, self::$aTagsWhiteList[strtolower($oElement->tagName)]))
{
// Forbidden (or unknown) attribute
$aAttrToRemove[] = $oAttr->name;
}
else if (!$this->IsValidAttributeContent($sAttr, $oAttr->value))
{
// Invalid content
$aAttrToRemove[] = $oAttr->name;
}
else if ($sAttr == 'style')
{
// Special processing for style tags
$sCleanStyle = $this->CleanStyle($oAttr->value);
if ($sCleanStyle == '')
{
// Invalid content
$aAttrToRemove[] = $oAttr->name;
}
else
{
$oElement->setAttribute($oAttr->name, $sCleanStyle);
}
}
}
// Now remove them
foreach($aAttrToRemove as $sName)
{
$oElement->removeAttribute($sName);
}
}
if ($oElement->hasChildNodes())
{
foreach($oElement->childNodes as $oNode)
{
$this->CleanNodeRemoveForbiddenAttributes($oNode);
}
}
}
protected function CleanStyle($sStyle)
{
$aAllowedStyles = array();
$aItems = explode(';', $sStyle);
{
foreach($aItems as $sItem)
{
$aElements = explode(':', trim($sItem));
if (in_array(trim(strtolower($aElements[0])), static::$aStylesWhiteList))
{
$aAllowedStyles[] = trim($sItem);
}
}
}
return implode(';', $aAllowedStyles);
}
protected function IsValidAttributeContent($sAttributeName, $sValue)
{
if (array_key_exists($sAttributeName, self::$aAttrsWhiteList))
{
return preg_match(self::$aAttrsWhiteList[$sAttributeName], $sValue);
}
return true;
}
}