HTML/Safe.php
[pear] / HTML / Safe.php
1 <?php
2 /* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
3
4 /**
5  * Loosely Based onHTML_Safe Parser
6  *
7  * PHP versions 4 and 5
8  *
9  * @category   HTML
10  * @package    HTML_Safe
11  * @author     Roman Ivanov <thingol@mail.ru>
12  * @copyright  2004-2005 Roman Ivanov
13  * @license    http://www.debian.org/misc/bsd.license  BSD License (3 Clause)
14  * @version    CVS: $Id:$
15  * @link       http://pear.php.net/package/HTML_Safe
16  */
17  
18 /**
19  *
20  * HTML_Safe Parser
21  *
22  * This parser strips down all potentially dangerous content within HTML:
23  * <ul>
24  * <li>opening tag without its closing tag</li>
25  * <li>closing tag without its opening tag</li>
26  * <li>any of these tags: "base", "basefont", "head", "html", "body", "applet", 
27  * "object", "iframe", "frame", "frameset", "script", "layer", "ilayer", "embed", 
28  * "bgsound", "link", "meta", "style", "title", "blink", "xml" etc.</li>
29  * <li>any of these attributes: on*, data*, dynsrc</li>
30  * <li>javascript:/vbscript:/about: etc. protocols</li>
31  * <li>expression/behavior etc. in styles</li>
32  * <li>any other active content</li>
33  * </ul>
34  * It also tries to convert code to XHTML valid, but htmltidy is far better 
35  * solution for this task.
36  *
37  * <b>Example:</b>
38  * <pre>
39  * $parser =& new HTML_Safe();
40  * $result = $parser->parse($doc);
41  * </pre>
42  *
43  * @category   HTML
44  * @package    HTML_Safe
45  * @author     Roman Ivanov <thingol@mail.ru>
46  * @copyright  1997-2005 Roman Ivanov
47  * @license    http://www.debian.org/misc/bsd.license  BSD License (3 Clause)
48  * @version    Release: @package_version@
49  * @link       http://pear.php.net/package/HTML_Safe
50  */
51 class HTML_Safe 
52 {
53     
54      
55    
56     /**
57      * Array of prepared regular expressions for protocols (schemas) matching
58      *
59      * @var array
60      * @access private
61      */
62     var $_protoRegexps = array();
63     
64     /**
65      * Array of prepared regular expressions for CSS matching
66      *
67      * @var array
68      * @access private
69      */
70     var $_cssRegexps = array();
71
72     /**
73      * List of single tags ("<tag />")
74      *
75      * @var array
76      * @access public
77      */
78     var $singleTags = array('area', 'br', 'img', 'input', 'hr', 'wbr', );
79
80     /**
81      * List of dangerous tags (such tags will be deleted)
82      *
83      * @var array
84      * @access public
85      */
86     var $deleteTags = array(
87         'applet', 'base',   'basefont', 'bgsound', 'blink',  'body', 
88         'embed',  'frame',  'frameset', 'head',    'html',   'ilayer', 
89         'iframe', 'layer',  'link',     'meta',    'object', 'style', 
90         'title',  'script', 
91         );
92
93     /**
94      * List of dangerous tags (such tags will be deleted, and all content 
95      * inside this tags will be also removed)
96      *
97      * @var array
98      * @access public
99      */
100     var $deleteTagsContent = array('script', 'style', 'title', 'xml', );
101
102     /**
103      * Type of protocols filtering ('white' or 'black')
104      *
105      * @var string
106      * @access public
107      */
108     var $protocolFiltering = 'white';
109
110     /**
111      * List of "dangerous" protocols (used for blacklist-filtering)
112      *
113      * @var array
114      * @access public
115      */
116     var $blackProtocols = array(
117         'about',   'chrome',     'data',       'disk',     'hcp',     
118         'help',    'javascript', 'livescript', 'lynxcgi',  'lynxexec', 
119         'ms-help', 'ms-its',     'mhtml',      'mocha',    'opera',   
120         'res',     'resource',   'shell',      'vbscript', 'view-source', 
121         'vnd.ms.radio',          'wysiwyg', 
122         );
123
124     /**
125      * List of "safe" protocols (used for whitelist-filtering)
126      *
127      * @var array
128      * @access public
129      */
130     var $whiteProtocols = array(
131         'ed2k',   'file', 'ftp',  'gopher', 'http',  'https', 
132         'irc',    'mailto', 'news', 'nntp', 'telnet', 'webcal', 
133         'xmpp',   'callto',
134         );
135
136     /**
137      * List of attributes that can contain protocols
138      *
139      * @var array
140      * @access public
141      */
142     var $protocolAttributes = array(
143         'action', 'background', 'codebase', 'dynsrc', 'href', 'lowsrc', 'src', 
144         );
145
146     /**
147      * List of dangerous CSS keywords
148      *
149      * Whole style="" attribute will be removed, if parser will find one of 
150      * these keywords
151      *
152      * @var array
153      * @access public
154      */
155     var $cssKeywords = array(
156         'absolute', 'behavior',       'behaviour',   'content', 'expression', 
157         'fixed',    'include-source', 'moz-binding',
158         );
159
160     /**
161      * List of tags that can have no "closing tag"
162      *
163      * @var array
164      * @access public
165      * @deprecated XHTML does not allow such tags
166      */
167     var $noClose = array();
168
169     /**
170      * List of block-level tags that terminates paragraph
171      *
172      * Paragraph will be closed when this tags opened
173      *
174      * @var array
175      * @access public
176      */
177     var $closeParagraph = array(
178         'address', 'blockquote', 'center', 'dd',      'dir',       'div', 
179         'dl',      'dt',         'h1',     'h2',      'h3',        'h4', 
180         'h5',      'h6',         'hr',     'isindex', 'listing',   'marquee', 
181         'menu',    'multicol',   'ol',     'p',       'plaintext', 'pre', 
182         'table',   'ul',         'xmp', 
183         );
184
185     /**
186      * List of table tags, all table tags outside a table will be removed
187      *
188      * @var array
189      * @access public
190      */
191     var $tableTags = array(
192         'caption', 'col', 'colgroup', 'tbody', 'td', 'tfoot', 'th', 
193         'thead',   'tr', 
194         );
195
196     /**
197      * List of list tags
198      *
199      * @var array
200      * @access public
201      */
202     var $listTags = array('dir', 'menu', 'ol', 'ul', 'dl', );
203
204     /**
205      * List of dangerous attributes
206      *
207      * @var array
208      * @access public
209      */
210     var $attributes = array('dynsrc', 'id', 'name', );
211
212     /**
213      * List of allowed "namespaced" attributes
214      *
215      * @var array
216      * @access public
217      */
218     var $attributesNS = array('xml:lang', );
219
220     /**
221      * Constructs class
222      *
223      * @access public
224      */
225     function HTML_Safe($opts = array()) 
226     {
227         
228         foreach ($opts as $k =>$v) {
229             $this->$k = $v;
230         }
231         
232         //making regular expressions based on Proto & CSS arrays
233         foreach ($this->blackProtocols as $proto) {
234             $preg = "/[\s\x01-\x1F]*";
235             for ($i=0; $i<strlen($proto); $i++) {
236                 $preg .= $proto{$i} . "[\s\x01-\x1F]*";
237             }
238             $preg .= ":/i";
239             $this->_protoRegexps[] = $preg;
240         }
241
242         foreach ($this->cssKeywords as $css) {
243             $this->_cssRegexps[] = '/' . $css . '/i';
244         }
245         return true;
246     }
247
248     /**
249      * Handles the writing of attributes - called from $this->_openHandler()
250      *
251      * @param array $attrs array of attributes $name => $value
252      * @return boolean
253      * @access private
254      */
255     function _writeAttrs ($attrs) 
256     {
257         $ret = '';
258         if (is_array($attrs)) {
259             foreach ($attrs as $name => $value) {
260
261                 $name = strtolower($name);
262
263                 if (strpos($name, 'on') === 0) {
264                     continue;
265                 }
266                 if (strpos($name, 'data') === 0) {
267                     continue;
268                 }
269                 if (in_array($name, $this->attributes)) {
270                     continue;
271                 }
272                 if (!preg_match("/^[a-z0-9]+$/i", $name)) {
273                     if (!in_array($name, $this->attributesNS)) {
274                         continue;
275                     }
276                 }
277
278                 if (($value === TRUE) || (is_null($value))) {
279                     $value = $name;
280                 }
281
282                 if ($name == 'style') {
283                    
284                    // removes insignificant backslahes
285                    $value = str_replace("\\", '', $value);
286
287                    // removes CSS comments
288                     while (1)
289                     {
290                         $_value = preg_replace("!/\*.*?\*/!s", '', $value);
291                         if ($_value == $value) break;
292                         $value = $_value;
293                     }
294                    
295                     // replace all & to &amp;
296                     $value = str_replace('&amp;', '&', $value);
297                     $value = str_replace('&', '&amp;', $value);
298                     $value = $this->cleanStyle($value);
299                 }
300
301                 $tempval = preg_replace('/&#(\d+);?/me', "chr('\\1')", $value); //"'
302                 $tempval = preg_replace('/&#x([0-9a-f]+);?/mei', "chr(hexdec('\\1'))", $tempval);
303
304                 if ((in_array($name, $this->protocolAttributes)) && 
305                     (strpos($tempval, ':') !== false)) 
306                 {
307                     if ($this->protocolFiltering == 'black') {
308                         foreach ($this->_protoRegexps as $proto) {
309                             if (preg_match($proto, $tempval)) continue 2;
310                         }
311                     } else {
312                         $_tempval = explode(':', $tempval);
313                         $proto = $_tempval[0];
314                         if (!in_array($proto, $this->whiteProtocols)) {
315                             continue;
316                         }
317                     }
318                 }
319
320                 $value = str_replace("\"", "&quot;", $value);
321                 $ret .= ' ' . $name . '="' . $value . '"';
322             }
323         }
324         return $ret;
325     }
326     
327     function cleanStyle ($str)
328     {
329         static $is = false;
330         if (!$is) {
331             require_once 'HTML/CSS/InlineStyle.php';
332             $is = new HTML_CSS_InlineStyle();
333         }
334         $ar = $is->_styleToArray($str);
335         foreach($ar as $k=>$v) {
336             if (in_array(strtolower(trim($k)), $this->cssKeywords)) {
337                 echo "Trashing BL css keyword $k=$v <br/>";
338                 unset($ar[$k]);
339             }
340             foreach ($this->_protoRegexps as $proto) {
341                 if (preg_match($proto, $v)) {
342                     echo "$proto - Trashing $k=$v <br/>";
343                     unset($ar[$k]);
344                     continue 2;
345                 }
346             }
347              
348         }
349         $st = array();
350         foreach($ar as $prop => $val) {
351             $st[] = "{$prop}:{$val}";
352         }
353         return implode(';', $st);
354         
355     }
356     
357
358     /**
359      * Opening tag handler - called from HTMLSax
360      *
361      * @param object $parser HTML Parser
362      * @param string $name   tag name
363      * @param array  $attrs  tag attributes
364      * @return boolean
365      * @access private
366      */
367     function _openHandler($name, $attrs) 
368     {
369         $name = strtolower($name);
370
371         if (in_array($name, $this->deleteTagsContent)) {
372             return true;
373         }
374         
375         if (in_array($name, $this->deleteTags)) {
376             return false;
377         }
378         
379         if (!preg_match("/^[a-z0-9]+$/i", $name)) {
380             return false;
381             /*if (preg_match("!(?:\@|://)!i", $name)) {
382                 return '&lt;' . $name . '&gt;';
383                 $this->_xhtml .= '&lt;' . $name . '&gt;';
384             }
385             return true;
386             */
387         }
388         if (in_array(strtolower($name), $this->singleTags)) {
389             return '<' . $name . $this->_writeAttrs($attrs) . '/>';
390         }    
391         return '<' . $name . $this->_writeAttrs($attrs) . '>';
392         
393     }
394   
395     /*
396      * Main parsing fuction
397      *
398      * @param string $doc HTML document for processing
399      * @return string Processed (X)HTML document
400      * @access public
401      */
402     function parse($doc) 
403     {
404
405        // Save all '<' symbols
406        //$doc = preg_replace("/<(?=[^a-zA-Z\/\!\?\%])/", '&lt;', $doc);
407
408        // Web documents shouldn't contains \x00 symbol
409        //$doc = str_replace("\x00", '', $doc);
410
411        // Opera6 bug workaround
412        //$doc = str_replace("\xC0\xBC", '&lt;', $doc);
413
414        // UTF-7 encoding ASCII decode
415        //$doc = $this->repackUTF7($doc);
416
417         if (!extension_loaded('tidy')) {
418             dl('tidy.so');
419         }
420         // too large!!!?
421         if (strlen($doc) > 100000) {
422             $doc = substr($doc, 0, 100000);
423         }
424         $tree = tidy_parse_string($doc,array(),'UTF8');
425         
426         return $this->tidyTree($tree->root());
427        // use tidy!!!!
428        
429         
430
431     }
432     
433     function parseFile($fn) 
434     {
435
436        // Save all '<' symbols
437        //$doc = preg_replace("/<(?=[^a-zA-Z\/\!\?\%])/", '&lt;', $doc);
438
439        // Web documents shouldn't contains \x00 symbol
440        //$doc = str_replace("\x00", '', $doc);
441
442        // Opera6 bug workaround
443        //$doc = str_replace("\xC0\xBC", '&lt;', $doc);
444
445        // UTF-7 encoding ASCII decode
446        //$doc = $this->repackUTF7($doc);
447
448         if (!extension_loaded('tidy')) {
449             die("Add tidy extension to extension.ini");
450         }
451         $tree = tidy_parse_file($fn,array(),'UTF8');
452         
453         
454         
455         return $this->tidyTree($tree->root());
456        // use tidy!!!!
457        
458         
459
460     }
461     
462     function tidyTree($node) {
463         // print_r($node);
464         
465         switch ($node->type) {
466             case TIDY_NODETYPE_TEXT:
467                 if (strlen(trim($node->value))) {
468                     $this->hasText = 1;
469                 }
470                 //echo htmlspecialchars($node->value);
471                 
472                 return $node->value;
473             case TIDY_NODETYPE_STARTEND:
474             case TIDY_NODETYPE_START:
475                 if (!empty($this->filter)) {
476                     $this->filter->apply($node);
477                 }
478                 break;
479             case TIDY_NODETYPE_END: // handled by start / singleTags..
480                 return;
481                 //$this->out .= "<". htmlspecialchars($node->name) .'/>';
482                 //return;
483             
484             case TIDY_NODETYPE_ROOT:
485                 break;
486             default:
487                 return;
488         }
489         //echo $node->name ."\n";
490         $add = '';
491         $begin = '';
492         $end = '';
493         if ($node->type != TIDY_NODETYPE_ROOT) {
494             //echo htmlspecialchars(print_r($node ,true));
495             $add = $this->_openHandler($node->name, empty($node->attribute) ? array() : $node->attribute);
496             if (is_string($add)) {
497                 $begin .= $add;
498                 if (!in_array(strtolower($node->name), $this->singleTags)) {
499                     $cr = strtolower($node->name) == 'pre' ? '' : "\n";
500                     $end = $cr . '</' . $node->name . '>';
501                 }
502                  
503             }
504             if ($add === true) {
505                 return ''; // delete this tag and all the contents..
506             }
507         }
508          
509                 // include children...
510         if(!$node->hasChildren()){
511             return $begin . $end;
512         }
513         foreach($node->child as $child){
514            // echo "child of ". $node->name . ':' . $child->type . "\n";
515             $begin .= $this->tidyTree($child);
516         }
517         return $begin . $end;
518              
519             
520             
521     }
522
523     /**
524      * UTF-7 decoding fuction
525      *
526      * @param string $str HTML document for recode ASCII part of UTF-7 back to ASCII
527      * @return string Decoded document
528      * @access private
529      */
530     function repackUTF7($str)
531     {
532        return preg_replace_callback('!\+([0-9a-zA-Z/]+)\-!', array($this, 'repackUTF7Callback'), $str);
533     }
534
535     /**
536      * Additional UTF-7 decoding fuction
537      *
538      * @param string $str String for recode ASCII part of UTF-7 back to ASCII
539      * @return string Recoded string
540      * @access private
541      */
542     function repackUTF7Callback($str)
543     {
544        $str = base64_decode($str[1]);
545        $str = preg_replace_callback('/^((?:\x00.)*)((?:[^\x00].)+)/', array($this, 'repackUTF7Back'), $str);
546        return preg_replace('/\x00(.)/', '$1', $str);
547     }
548
549     /**
550      * Additional UTF-7 encoding fuction
551      *
552      * @param string $str String for recode ASCII part of UTF-7 back to ASCII
553      * @return string Recoded string
554      * @access private
555      */
556     function repackUTF7Back($str)
557     {
558        return $str[1].'+'.rtrim(base64_encode($str[2]), '=').'-';
559     }
560 }
561
562 /*
563  * Local variables:
564  * tab-width: 4
565  * c-basic-offset: 4
566  * c-hanging-comment-ender-p: nil
567  * End:
568  */
569