<?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'),
            'a'   => array('href')
        );            
        
        $html = strip_tags($html, $allowedTags);
        
        $html = HTMLUtils::removeAttributes($html, $allowedAttributes);
        
        return trim($html);
        
    }
    
    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'),
                'a'   => array('href')
            );            
            
        }
        
        $specialSymbols = array('<', '>', '=');
        $spaceSymbols = array(' ', "\n", "\r", "\t", "\v", "\f", '/'); // браузерные движки трактуют слэш так же, как пробел
        
        $currentTag = '';
        $currentAttribute = '';
        $currentAttrValue = '';
        $attrStartPosition = 0;
        $attrValueBorderSymbol = '';
                
        $states = array();
        
        // первая вложенность - состояние, оставшееся или установленное в предыдущей итерации
        // вторая вложенность - текущий спецсимвол, 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 {
                        
                        if (isset($html[$i + 1]) && in_array($html[$i + 1], array('"', "'"))) {
                            
                            $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) {
                
                $html = substr($html, 0, strrpos($html, '<![CDATA['));
                
            } else {
                
                $html = substr($html, 0, strrpos($html, '<'));
                
            }
            
        }
        
        return $html;
        
    }
    
    // this is removeAttribute helper
    private static function _deleteAttribute(&$html, &$i, &$currentTag, &$currentAttribute, &$currentAttrValue, &$attrStartPosition, &$allowedAttributes) {
        
        // $i здесь всегда на символе после атрибута
        // $attrStartPosition - 1 для того, чтобы так же удалить пробел перед атрибутом
        // перед атрибутом всегда гарантированно есть минимум один пробел
        
        $currentTag = strtolower($currentTag);
        $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="&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041">
<IMG SRC=&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;
&#39;&#88;&#83;&#83;&#39;&#41;>
<IMG SRC=&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&
#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041>
<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');">
<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);