fork download
  1. <?php
  2.  
  3. class HTMLUtils {
  4.  
  5. public static function cleanHtmlUserInput($html) {
  6.  
  7. $allowedTags = '<b><strong><i><em><u><strike><a><br><br/><p><div><img><span><ol><li><ul>';
  8. $allowedAttributes = array(
  9. '*' => array('id', 'class', 'style'),
  10. 'img' => array('src', 'alt'),
  11. 'a' => array('href')
  12. );
  13.  
  14. $html = strip_tags($html, $allowedTags);
  15.  
  16. $html = HTMLUtils::removeAttributes($html, $allowedAttributes);
  17.  
  18. return trim($html);
  19.  
  20. }
  21.  
  22. const STATE_WAIT_FOR_TAG = 0;
  23. const STATE_STARTING_TAG = 1;
  24. const STATE_COLLECT_TAG_NAME = 2;
  25. const STATE_SPACE_INSIDE_TAG = 3;
  26. const STATE_COLLECT_ATTR_NAME = 4;
  27. const STATE_ATTR_EQUAL_SIGN = 5;
  28. const STATE_COLLECT_ATTR_VALUE = 6;
  29. const STATE_WAIT_FOR_TAG_END = 7;
  30. const STATE_ENDING_TAG = 8;
  31. const STATE_WAIT_FOR_CDATA_END = 9;
  32.  
  33.  
  34. public static function removeAttributes($html, array $allowedAttributes = array()) {
  35.  
  36. if (empty($allowedAttributes)) {
  37.  
  38. // тег звездочка - для разрешения набора аттрибутов всем тегам
  39. // теги и атрибуты обязаны быть в нижнем регистре
  40. $allowedAttributes = array(
  41. // '*' => array('id', 'class', 'style'),
  42. 'img' => array('src', 'alt'),
  43. 'a' => array('href')
  44. );
  45.  
  46. }
  47.  
  48. $specialSymbols = array('<', '>', '=');
  49. $spaceSymbols = array(' ', "\n", "\r", "\t", "\v", "\f", '/'); // браузерные движки трактуют слэш так же, как пробел
  50.  
  51. $currentTag = '';
  52. $currentAttribute = '';
  53. $currentAttrValue = '';
  54. $attrStartPosition = 0;
  55. $attrValueBorderSymbol = '';
  56.  
  57. $states = array();
  58.  
  59. // первая вложенность - состояние, оставшееся или установленное в предыдущей итерации
  60. // вторая вложенность - текущий спецсимвол, s - space, 0 - любой другой символ
  61. // если в таблице состояний нет такого условия - условие сохраняется с предыдущей итерации
  62.  
  63. $states[self::STATE_WAIT_FOR_TAG] ['<'] = self::STATE_STARTING_TAG;
  64.  
  65. $states[self::STATE_WAIT_FOR_TAG_END] ['>'] = self::STATE_ENDING_TAG;
  66.  
  67. $states[self::STATE_COLLECT_TAG_NAME] ['s'] = self::STATE_SPACE_INSIDE_TAG;
  68. $states[self::STATE_COLLECT_TAG_NAME] ['>'] = self::STATE_ENDING_TAG;
  69.  
  70. $states[self::STATE_SPACE_INSIDE_TAG] ['>'] = self::STATE_ENDING_TAG;
  71. $states[self::STATE_SPACE_INSIDE_TAG] [0] = self::STATE_COLLECT_ATTR_NAME;
  72. $states[self::STATE_SPACE_INSIDE_TAG] ['<'] = self::STATE_COLLECT_ATTR_NAME;
  73. $states[self::STATE_SPACE_INSIDE_TAG] ['='] = self::STATE_COLLECT_ATTR_NAME;
  74.  
  75. $states[self::STATE_COLLECT_ATTR_NAME]['>'] = self::STATE_ENDING_TAG;
  76. $states[self::STATE_COLLECT_ATTR_NAME]['s'] = self::STATE_SPACE_INSIDE_TAG;
  77. $states[self::STATE_COLLECT_ATTR_NAME]['='] = self::STATE_ATTR_EQUAL_SIGN;
  78.  
  79.  
  80. // главный цикл
  81.  
  82. for ($i = 0, $state = self::STATE_WAIT_FOR_TAG; $i < strlen($html); $i++) {
  83.  
  84. // приведение текущих переменных к корректным индексам массивов
  85. // 0 - "не имеет значения", "без разницы"
  86.  
  87. $specialSymbol = in_array($html[$i], $specialSymbols) ? $html[$i] : (in_array($html[$i], $spaceSymbols) ? 's' : 0);
  88.  
  89. $state = (isset($states[$state][$specialSymbol])) ? $states[$state][$specialSymbol] : $state;
  90.  
  91.  
  92. switch ($state) {
  93.  
  94. case self::STATE_WAIT_FOR_TAG:
  95. case self::STATE_WAIT_FOR_TAG_END:
  96. break;
  97.  
  98.  
  99. case self::STATE_STARTING_TAG:
  100.  
  101. // не проверяем CDATA
  102. if (substr($html, $i + 1, 8) === '![CDATA[') {
  103.  
  104. $state = self::STATE_WAIT_FOR_CDATA_END;
  105. $i = $i + 8;
  106.  
  107. }
  108.  
  109. // не проверяем управляющие конструкции (!doctype, !element, ?xml) и закрывающие теги
  110. // через <?xml-* команды можно вставить инъекцию, но браузеры их не понимают
  111. elseif (isset($html[$i + 1]) && ($html[$i + 1] === '!' || $html[$i + 1] === '?' || $html[$i + 1] === '/')) {
  112.  
  113. $state = self::STATE_WAIT_FOR_TAG_END;
  114. $i++;
  115.  
  116. }
  117.  
  118. // если после < идет пробел - это не считается тегом
  119. elseif (isset($html[$i + 1]) && in_array($html[$i + 1], $spaceSymbols)) {
  120.  
  121. $state = self::STATE_WAIT_FOR_TAG;
  122.  
  123. }
  124.  
  125. // тег обыкновенный
  126. else {
  127.  
  128. $state = self::STATE_COLLECT_TAG_NAME;
  129.  
  130. }
  131.  
  132. break;
  133.  
  134.  
  135. case self::STATE_COLLECT_TAG_NAME:
  136.  
  137. $currentTag .= $html[$i];
  138.  
  139. break;
  140.  
  141.  
  142. case self::STATE_SPACE_INSIDE_TAG:
  143.  
  144. if (!empty($currentAttribute)) {
  145.  
  146. // Вариант завершения атрибута № 1: атрибут кончился, а тег еще нет
  147.  
  148. self::_deleteAttribute($html, $i, $currentTag, $currentAttribute, $currentAttrValue, $attrStartPosition, $allowedAttributes);
  149.  
  150. }
  151.  
  152. break;
  153.  
  154.  
  155. case self::STATE_COLLECT_ATTR_NAME:
  156.  
  157. if (empty($currentAttribute)) {
  158.  
  159. // Если мы здесь "впервые", то запоминаем позицию начала атрибута
  160.  
  161. $attrStartPosition = $i;
  162.  
  163. }
  164.  
  165. $currentAttribute .= $html[$i];
  166.  
  167. break;
  168.  
  169.  
  170. case self::STATE_ATTR_EQUAL_SIGN:
  171.  
  172. if (isset($html[$i + 1]) && in_array($html[$i + 1], $spaceSymbols)) {
  173.  
  174. $state = self::STATE_SPACE_INSIDE_TAG;
  175.  
  176. } else {
  177.  
  178. if (isset($html[$i + 1]) && in_array($html[$i + 1], array('"', "'"))) {
  179.  
  180. $attrValueBorderSymbol = $html[$i + 1];
  181. $i++;
  182.  
  183. } else {
  184.  
  185. $attrValueBorderSymbol = '';
  186.  
  187. }
  188.  
  189. $state = self::STATE_COLLECT_ATTR_VALUE;
  190.  
  191. }
  192.  
  193.  
  194. break;
  195.  
  196.  
  197. case self::STATE_COLLECT_ATTR_VALUE:
  198.  
  199. if ($html[$i] === $attrValueBorderSymbol) {
  200.  
  201. // если нет пробела между атрибутами (attr="value"attr="value"), надо вставить
  202. if (isset($html[$i + 1]) && !in_array($html[$i + 1], $spaceSymbols) && $html[$i + 1] !== '>') {
  203.  
  204. $html = substr($html, 0, $i + 1) . ' ' . substr($html, $i + 1);
  205.  
  206. }
  207.  
  208. $state = self::STATE_SPACE_INSIDE_TAG;
  209.  
  210. } elseif (empty($attrValueBorderSymbol) && isset($html[$i + 1]) && in_array($html[$i + 1], $spaceSymbols) && $html[$i + 1] !== '/') {
  211.  
  212. $state = self::STATE_SPACE_INSIDE_TAG;
  213.  
  214. } elseif (empty($attrValueBorderSymbol) && isset($html[$i + 1]) && $html[$i + 1] === '>') {
  215.  
  216. // Если у нас конец тега - уходим туда на след. итерации
  217. // Атрибут будет удален там
  218.  
  219. $state = self::STATE_ENDING_TAG;
  220.  
  221. }
  222.  
  223. // если не кавычка, добавить символ к значению атрибута
  224. if ($html[$i] !== $attrValueBorderSymbol) {
  225.  
  226. $currentAttrValue .= $html[$i];
  227.  
  228. }
  229.  
  230. break;
  231.  
  232.  
  233. case self::STATE_ENDING_TAG:
  234.  
  235. if (!empty($currentAttribute)) {
  236.  
  237. // Вариант завершения атрибута № 2: самый прозаический. У нас кончился тег.
  238.  
  239. self::_deleteAttribute($html, $i, $currentTag, $currentAttribute, $currentAttrValue, $attrStartPosition, $allowedAttributes);
  240.  
  241. }
  242.  
  243. $currentTag = '';
  244.  
  245. $state = self::STATE_WAIT_FOR_TAG;
  246.  
  247. break;
  248.  
  249.  
  250. case self::STATE_WAIT_FOR_CDATA_END:
  251.  
  252. if (substr($html, $i, 3) === ']]>') {
  253.  
  254. $state = self::STATE_WAIT_FOR_TAG;
  255. $i = $i + 2;
  256.  
  257. }
  258.  
  259. break;
  260.  
  261.  
  262. default:
  263. throw new Exception('Unexpected state on step ' . $i);
  264.  
  265.  
  266. }
  267.  
  268. }
  269.  
  270. // Если цикл закончился, а мы не снаружи всех html-тегов, то просто сносим последний тег
  271.  
  272. if ($state !== self::STATE_WAIT_FOR_TAG) {
  273.  
  274. // для CDATA особый случай
  275. if ($state === self::STATE_WAIT_FOR_CDATA_END) {
  276.  
  277. $html = substr($html, 0, strrpos($html, '<![CDATA['));
  278.  
  279. } else {
  280.  
  281. $html = substr($html, 0, strrpos($html, '<'));
  282.  
  283. }
  284.  
  285. }
  286.  
  287. return $html;
  288.  
  289. }
  290.  
  291. // this is removeAttribute helper
  292. private static function _deleteAttribute(&$html, &$i, &$currentTag, &$currentAttribute, &$currentAttrValue, &$attrStartPosition, &$allowedAttributes) {
  293.  
  294. // $i здесь всегда на символе после атрибута
  295. // $attrStartPosition - 1 для того, чтобы так же удалить пробел перед атрибутом
  296. // перед атрибутом всегда гарантированно есть минимум один пробел
  297.  
  298. $currentTag = strtolower($currentTag);
  299. $currentAttribute = strtolower($currentAttribute);
  300. $currentAttrValue = strtolower($currentAttrValue);
  301.  
  302. // если атрибута нет в allowedAttributes или значение атрибута является подозрительным
  303. if (
  304. (!((isset($allowedAttributes['*']) && in_array($currentAttribute, $allowedAttributes['*'])) || (isset($allowedAttributes[$currentTag]) && in_array($currentAttribute, $allowedAttributes[$currentTag]))))
  305. || (substr($currentAttrValue, 0, 11) === 'javascript:')
  306. )
  307. {
  308.  
  309. $html = substr($html, 0, $attrStartPosition - 1) . substr($html, $i);
  310.  
  311. $i = $attrStartPosition - 1;
  312.  
  313. }
  314.  
  315. $currentAttribute = '';
  316. $currentAttrValue = '';
  317.  
  318. }
  319.  
  320.  
  321.  
  322. }
  323.  
  324. $test = <<<EOL
  325. <img src="alert(String.fromCharCode(88,83,83))//';alert(String.fromCharCode(88,83,83))//";
  326. alert(String.fromCharCode(88,83,83))//";alert(String.fromCharCode(88,83,83))//--
  327. ></SCRIPT>">'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>">
  328. ';alert(String.fromCharCode(88,83,83))//';alert(String.fromCharCode(88,83,83))//";
  329. alert(String.fromCharCode(88,83,83))//";alert(String.fromCharCode(88,83,83))//--
  330. ></SCRIPT>">'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>
  331. '';!--"<XSS>=&{()}
  332. <img src="'';!--"<XSS>=&{()}">
  333. <SCRIPT SRC=http://x...content-available-to-author-only...s.rocks/xss.js></SCRIPT>
  334. <IMG SRC="javascript:alert('XSS');">
  335. <IMG SRC=javascript:alert('XSS')>
  336. <IMG SRC=JaVaScRiPt:alert('XSS')>
  337. <IMG SRC=javascript:alert("XSS")>
  338. <IMG SRC=`javascript:alert("RSnake says, 'XSS'")`>
  339. <a onmouseover="alert(document.cookie)">xxs link</a>
  340. <IMG """><SCRIPT>alert("XSS")</SCRIPT>">
  341. <IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>
  342. <IMG SRC=# onmouseover="alert('xxs')">
  343. <IMG SRC= onmouseover="alert('xxs')">
  344. <IMG onmouseover="alert('xxs')">
  345. <IMG SRC=/ onerror="alert(String.fromCharCode(88,83,83))"></img>
  346. <img src=x onerror="&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041">
  347. <IMG SRC=&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;
  348. &#39;&#88;&#83;&#83;&#39;&#41;>
  349. <IMG SRC=&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&
  350. #0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041>
  351. <IMG SRC=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29>
  352. <IMG SRC="jav ascript:alert('XSS');">
  353. <IMG SRC="jav&#x09;ascript:alert('XSS');">
  354. <IMG SRC="jav&#x0A;ascript:alert('XSS');">
  355. <IMG SRC="jav&#x0D;ascript:alert('XSS');">
  356. <IMG SRC="jav&#x00;ascript:alert('XSS');">
  357. <IMG SRC=" &#14; javascript:alert('XSS');">
  358. <SCRIPT/XSS SRC="http://x...content-available-to-author-only...s.rocks/xss.js"></SCRIPT>
  359. <BODY onload!#$%&()*~+-_.,:;?@[/|\]^`=alert("XSS")>
  360. <SCRIPT/SRC="http://x...content-available-to-author-only...s.rocks/xss.js"></SCRIPT>
  361. <<SCRIPT>alert("XSS");//<</SCRIPT>
  362. <SCRIPT SRC=http://x...content-available-to-author-only...s.rocks/xss.js?< B >
  363. <SCRIPT SRC=//xss.rocks/.j>
  364. <IMG SRC="javascript:alert('XSS')"
  365. EOL;
  366.  
  367. echo HTMLUtils::cleanHtmlUserInput($test);
Success #stdin #stdout 0.03s 52432KB
stdin
Standard input is empty
stdout
<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))//';alert(String.fromCharCode(88,83,83))//";
alert(String.fromCharCode(88,83,83))//";alert(String.fromCharCode(88,83,83))//--
>">'>alert(String.fromCharCode(88,83,83))
'';!--"=&{()}
<img src="'';!--" SRC=http://x...content-available-to-author-only...s.rocks/xss.js/SCRIPT SRC=`javascript:alert("RSnake >alert("XSS")">
<IMG>
<IMG SRC=#>
<IMG SRC=>
<IMG>
<IMG SRC=/></img>
<img src=x>
<IMG SRC=&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;>
<IMG SRC=&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&>
<IMG SRC=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29>
<IMG SRC="jav	ascript:alert('XSS');">
<IMG SRC="jav&#x09;ascript:alert('XSS');">
<IMG SRC="jav&#x0A;ascript:alert('XSS');">
<IMG SRC="jav&#x0D;ascript:alert('XSS');">
<IMG SRC="jav&#x00;ascript:alert('XSS');">
<IMG SRC=" &#14;  javascript:alert('XSS');">