<?php
class HTMLUtils {
public static function cleanHtmlUserInput($html) {
$allowedTags = '<b><strong><i><em><u><strike><a><br><br/><p><div><img><span><ol><li><ul>';
$allowedAttributes = array( '*' => array('id', 'class', 'style'), 'img' => array('src', 'alt'), );
$html = HTMLUtils::removeAttributes($html, $allowedAttributes);
}
const STATE_WAIT_FOR_TAG = 0;
const STATE_STARTING_TAG = 1;
const STATE_COLLECT_TAG_NAME = 2;
const STATE_SPACE_INSIDE_TAG = 3;
const STATE_COLLECT_ATTR_NAME = 4;
const STATE_ATTR_EQUAL_SIGN = 5;
const STATE_COLLECT_ATTR_VALUE = 6;
const STATE_WAIT_FOR_TAG_END = 7;
const STATE_ENDING_TAG = 8;
const STATE_WAIT_FOR_CDATA_END = 9;
public static
function removeAttributes
($html, array $allowedAttributes = array()) {
if (empty($allowedAttributes)) {
// тег звездочка - для разрешения набора аттрибутов всем тегам
// теги и атрибуты обязаны быть в нижнем регистре
$allowedAttributes = array( // '*' => array('id', 'class', 'style'),
'img' => array('src', 'alt'), );
}
$specialSymbols = array('<', '>', '='); $spaceSymbols = array(' ', "\n", "\r", "\t", "\v", "\f", '/'); // браузерные движки трактуют слэш так же, как пробел
$currentTag = '';
$currentAttribute = '';
$currentAttrValue = '';
$attrStartPosition = 0;
$attrValueBorderSymbol = '';
// первая вложенность - состояние, оставшееся или установленное в предыдущей итерации
// вторая вложенность - текущий спецсимвол, s - space, 0 - любой другой символ
// если в таблице состояний нет такого условия - условие сохраняется с предыдущей итерации
$states[self::STATE_WAIT_FOR_TAG] ['<'] = self::STATE_STARTING_TAG;
$states[self::STATE_WAIT_FOR_TAG_END] ['>'] = self::STATE_ENDING_TAG;
$states[self::STATE_COLLECT_TAG_NAME] ['s'] = self::STATE_SPACE_INSIDE_TAG;
$states[self::STATE_COLLECT_TAG_NAME] ['>'] = self::STATE_ENDING_TAG;
$states[self::STATE_SPACE_INSIDE_TAG] ['>'] = self::STATE_ENDING_TAG;
$states[self::STATE_SPACE_INSIDE_TAG] [0] = self::STATE_COLLECT_ATTR_NAME;
$states[self::STATE_SPACE_INSIDE_TAG] ['<'] = self::STATE_COLLECT_ATTR_NAME;
$states[self::STATE_SPACE_INSIDE_TAG] ['='] = self::STATE_COLLECT_ATTR_NAME;
$states[self::STATE_COLLECT_ATTR_NAME]['>'] = self::STATE_ENDING_TAG;
$states[self::STATE_COLLECT_ATTR_NAME]['s'] = self::STATE_SPACE_INSIDE_TAG;
$states[self::STATE_COLLECT_ATTR_NAME]['='] = self::STATE_ATTR_EQUAL_SIGN;
// главный цикл
for ($i = 0, $state = self::STATE_WAIT_FOR_TAG; $i < strlen($html); $i++) {
// приведение текущих переменных к корректным индексам массивов
// 0 - "не имеет значения", "без разницы"
$specialSymbol = in_array($html[$i], $specialSymbols) ?
$html[$i] : (in_array($html[$i], $spaceSymbols) ?
's' : 0);
$state = (isset($states[$state][$specialSymbol])) ?
$states[$state][$specialSymbol] : $state;
switch ($state) {
case self::STATE_WAIT_FOR_TAG:
case self::STATE_WAIT_FOR_TAG_END:
break;
case self::STATE_STARTING_TAG:
// не проверяем CDATA
if (substr($html, $i + 1, 8) === '![CDATA[') {
$state = self::STATE_WAIT_FOR_CDATA_END;
$i = $i + 8;
}
// не проверяем управляющие конструкции (!doctype, !element, ?xml) и закрывающие теги
// через <?xml-* команды можно вставить инъекцию, но браузеры их не понимают
elseif (isset($html[$i + 1]) && ($html[$i + 1] === '!' || $html[$i + 1] === '?' || $html[$i + 1] === '/')) {
$state = self::STATE_WAIT_FOR_TAG_END;
$i++;
}
// если после < идет пробел - это не считается тегом
elseif (isset($html[$i + 1]) && in_array($html[$i + 1], $spaceSymbols)) {
$state = self::STATE_WAIT_FOR_TAG;
}
// тег обыкновенный
else {
$state = self::STATE_COLLECT_TAG_NAME;
}
break;
case self::STATE_COLLECT_TAG_NAME:
$currentTag .= $html[$i];
break;
case self::STATE_SPACE_INSIDE_TAG:
if (!empty($currentAttribute)) {
// Вариант завершения атрибута № 1: атрибут кончился, а тег еще нет
self::_deleteAttribute($html, $i, $currentTag, $currentAttribute, $currentAttrValue, $attrStartPosition, $allowedAttributes);
}
break;
case self::STATE_COLLECT_ATTR_NAME:
if (empty($currentAttribute)) {
// Если мы здесь "впервые", то запоминаем позицию начала атрибута
$attrStartPosition = $i;
}
$currentAttribute .= $html[$i];
break;
case self::STATE_ATTR_EQUAL_SIGN:
if (isset($html[$i + 1]) && in_array($html[$i + 1], $spaceSymbols)) {
$state = self::STATE_SPACE_INSIDE_TAG;
} else {
$attrValueBorderSymbol = $html[$i + 1];
$i++;
} else {
$attrValueBorderSymbol = '';
}
$state = self::STATE_COLLECT_ATTR_VALUE;
}
break;
case self::STATE_COLLECT_ATTR_VALUE:
if ($html[$i] === $attrValueBorderSymbol) {
// если нет пробела между атрибутами (attr="value"attr="value"), надо вставить
if (isset($html[$i + 1]) && !in_array($html[$i + 1], $spaceSymbols) && $html[$i + 1] !== '>') {
$html = substr($html, 0, $i + 1) . ' ' . substr($html, $i + 1);
}
$state = self::STATE_SPACE_INSIDE_TAG;
} elseif (empty($attrValueBorderSymbol) && isset($html[$i + 1]) && in_array($html[$i + 1], $spaceSymbols) && $html[$i + 1] !== '/') {
$state = self::STATE_SPACE_INSIDE_TAG;
} elseif (empty($attrValueBorderSymbol) && isset($html[$i + 1]) && $html[$i + 1] === '>') {
// Если у нас конец тега - уходим туда на след. итерации
// Атрибут будет удален там
$state = self::STATE_ENDING_TAG;
}
// если не кавычка, добавить символ к значению атрибута
if ($html[$i] !== $attrValueBorderSymbol) {
$currentAttrValue .= $html[$i];
}
break;
case self::STATE_ENDING_TAG:
if (!empty($currentAttribute)) {
// Вариант завершения атрибута № 2: самый прозаический. У нас кончился тег.
self::_deleteAttribute($html, $i, $currentTag, $currentAttribute, $currentAttrValue, $attrStartPosition, $allowedAttributes);
}
$currentTag = '';
$state = self::STATE_WAIT_FOR_TAG;
break;
case self::STATE_WAIT_FOR_CDATA_END:
if (substr($html, $i, 3) === ']]>') {
$state = self::STATE_WAIT_FOR_TAG;
$i = $i + 2;
}
break;
default:
throw new Exception('Unexpected state on step ' . $i);
}
}
// Если цикл закончился, а мы не снаружи всех html-тегов, то просто сносим последний тег
if ($state !== self::STATE_WAIT_FOR_TAG) {
// для CDATA особый случай
if ($state === self::STATE_WAIT_FOR_CDATA_END) {
} else {
}
}
return $html;
}
// this is removeAttribute helper
private static function _deleteAttribute(&$html, &$i, &$currentTag, &$currentAttribute, &$currentAttrValue, &$attrStartPosition, &$allowedAttributes) {
// $i здесь всегда на символе после атрибута
// $attrStartPosition - 1 для того, чтобы так же удалить пробел перед атрибутом
// перед атрибутом всегда гарантированно есть минимум один пробел
$currentAttribute = strtolower($currentAttribute); $currentAttrValue = strtolower($currentAttrValue);
// если атрибута нет в allowedAttributes или значение атрибута является подозрительным
if (
(!((isset($allowedAttributes['*']) && in_array($currentAttribute, $allowedAttributes['*'])) || (isset($allowedAttributes[$currentTag]) && in_array($currentAttribute, $allowedAttributes[$currentTag])))) || (substr($currentAttrValue, 0, 11) === 'javascript:') )
{
$html = substr($html, 0, $attrStartPosition - 1) . substr($html, $i);
$i = $attrStartPosition - 1;
}
$currentAttribute = '';
$currentAttrValue = '';
}
}
$test = <<<EOL
<img src="alert(String.fromCharCode(88,83,83))//';alert(String.fromCharCode(88,83,83))//";
alert(String.fromCharCode(88,83,83))//";alert(String.fromCharCode(88,83,83))//--
></SCRIPT>">'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>">
';alert(String.fromCharCode(88,83,83))//';alert(String.fromCharCode(88,83,83))//";
alert(String.fromCharCode(88,83,83))//";alert(String.fromCharCode(88,83,83))//--
></SCRIPT>">'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>
'';!--"<XSS>=&{()}
<img src="'';!--"<XSS>=&{()}">
<SCRIPT SRC=http://x...content-available-to-author-only...s.rocks/xss.js></SCRIPT>
<IMG SRC="javascript:alert('XSS');">
<IMG SRC=javascript:alert('XSS')>
<IMG SRC=JaVaScRiPt:alert('XSS')>
<IMG SRC=javascript:alert("XSS")>
<IMG SRC=`javascript:alert("RSnake says, 'XSS'")`>
<a onmouseover="alert(document.cookie)">xxs link</a>
<IMG """><SCRIPT>alert("XSS")</SCRIPT>">
<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>
<IMG SRC=# onmouseover="alert('xxs')">
<IMG SRC= onmouseover="alert('xxs')">
<IMG onmouseover="alert('xxs')">
<IMG SRC=/ onerror="alert(String.fromCharCode(88,83,83))"></img>
<img src=x onerror="javascript:alert('XSS')">
<IMG SRC=javascript:alert(
'XSS')>
<IMG SRC=javascript:a&
#0000108ert('XSS')>
<IMG SRC=javascript:alert('XSS')>
<IMG SRC="jav ascript:alert('XSS');">
<IMG SRC="jav	ascript:alert('XSS');">
<IMG SRC="jav
ascript:alert('XSS');">
<IMG SRC="jav
ascript:alert('XSS');">
<IMG SRC="jav�ascript:alert('XSS');">
<IMG SRC="  javascript:alert('XSS');">
<SCRIPT/XSS SRC="http://x...content-available-to-author-only...s.rocks/xss.js"></SCRIPT>
<BODY onload!#$%&()*~+-_.,:;?@[/|\]^`=alert("XSS")>
<SCRIPT/SRC="http://x...content-available-to-author-only...s.rocks/xss.js"></SCRIPT>
<<SCRIPT>alert("XSS");//<</SCRIPT>
<SCRIPT SRC=http://x...content-available-to-author-only...s.rocks/xss.js?< B >
<SCRIPT SRC=//xss.rocks/.j>
<IMG SRC="javascript:alert('XSS')"
EOL;
echo HTMLUtils::cleanHtmlUserInput($test);