0
0
mirror of https://github.com/ezyang/htmlpurifier.git synced 2024-12-22 16:31:53 +00:00

Tighter CSS selector validation.

Signed-off-by: Edward Z. Yang <ezyang@mit.edu>
This commit is contained in:
Edward Z. Yang 2012-01-14 03:08:02 -05:00
parent 9de0785448
commit 1c7fedff5a
8 changed files with 258 additions and 28 deletions

7
NEWS
View File

@ -15,6 +15,13 @@ NEWS ( CHANGELOG and HISTORY ) HTMLPurifier
to http. Reported by Neike Taika-Tessaro. to http. Reported by Neike Taika-Tessaro.
# Core.EscapeNonASCIICharacters now always transforms entities to # Core.EscapeNonASCIICharacters now always transforms entities to
entities, even if target encoding is UTF-8. entities, even if target encoding is UTF-8.
# Tighten up selector validation in ExtractStyleBlocks.
Non-syntactically valid selectors are now rejected, along with
some of the more obscure ones such as attribute selectors, the
:lang pseudoselector, and anything not in CSS2.1. Furthermore,
ID and class selectors now work properly with the relevant
configuration attributes. Also, mute errors when parsing CSS
with CSS Tidy.
! Added support for 'scope' attribute on tables. ! Added support for 'scope' attribute on tables.
! Added %HTML.TargetBlank, which adds target="blank" to all outgoing links. ! Added %HTML.TargetBlank, which adds target="blank" to all outgoing links.
! Properly handle sub-lists directly nested inside of lists in ! Properly handle sub-lists directly nested inside of lists in

View File

@ -323,23 +323,23 @@
</directive> </directive>
<directive id="Attr.EnableID"> <directive id="Attr.EnableID">
<file name="HTMLPurifier/AttrDef/HTML/ID.php"> <file name="HTMLPurifier/AttrDef/HTML/ID.php">
<line>20</line> <line>30</line>
</file> </file>
</directive> </directive>
<directive id="Attr.IDPrefix"> <directive id="Attr.IDPrefix">
<file name="HTMLPurifier/AttrDef/HTML/ID.php"> <file name="HTMLPurifier/AttrDef/HTML/ID.php">
<line>26</line> <line>36</line>
</file> </file>
</directive> </directive>
<directive id="Attr.IDPrefixLocal"> <directive id="Attr.IDPrefixLocal">
<file name="HTMLPurifier/AttrDef/HTML/ID.php"> <file name="HTMLPurifier/AttrDef/HTML/ID.php">
<line>28</line> <line>38</line>
<line>31</line> <line>41</line>
</file> </file>
</directive> </directive>
<directive id="Attr.IDBlacklistRegexp"> <directive id="Attr.IDBlacklistRegexp">
<file name="HTMLPurifier/AttrDef/HTML/ID.php"> <file name="HTMLPurifier/AttrDef/HTML/ID.php">
<line>54</line> <line>64</line>
</file> </file>
</directive> </directive>
<directive id="Attr."> <directive id="Attr.">
@ -411,17 +411,17 @@
</directive> </directive>
<directive id="Filter.ExtractStyleBlocks.TidyImpl"> <directive id="Filter.ExtractStyleBlocks.TidyImpl">
<file name="HTMLPurifier/Filter/ExtractStyleBlocks.php"> <file name="HTMLPurifier/Filter/ExtractStyleBlocks.php">
<line>41</line> <line>53</line>
</file> </file>
</directive> </directive>
<directive id="Filter.ExtractStyleBlocks.Scope"> <directive id="Filter.ExtractStyleBlocks.Scope">
<file name="HTMLPurifier/Filter/ExtractStyleBlocks.php"> <file name="HTMLPurifier/Filter/ExtractStyleBlocks.php">
<line>65</line> <line>77</line>
</file> </file>
</directive> </directive>
<directive id="Filter.ExtractStyleBlocks.Escaping"> <directive id="Filter.ExtractStyleBlocks.Escaping">
<file name="HTMLPurifier/Filter/ExtractStyleBlocks.php"> <file name="HTMLPurifier/Filter/ExtractStyleBlocks.php">
<line>123</line> <line>275</line>
</file> </file>
</directive> </directive>
<directive id="HTML.SafeIframe"> <directive id="HTML.SafeIframe">
@ -513,7 +513,7 @@
</directive> </directive>
<directive id="URI.HostBlacklist"> <directive id="URI.HostBlacklist">
<file name="HTMLPurifier/URIFilter/HostBlacklist.php"> <file name="HTMLPurifier/URIFilter/HostBlacklist.php">
<line>8</line> <line>12</line>
</file> </file>
</directive> </directive>
<directive id="URI.MungeResources"> <directive id="URI.MungeResources">

View File

@ -91,6 +91,7 @@ require 'HTMLPurifier/AttrDef/CSS/DenyElementDecorator.php';
require 'HTMLPurifier/AttrDef/CSS/Filter.php'; require 'HTMLPurifier/AttrDef/CSS/Filter.php';
require 'HTMLPurifier/AttrDef/CSS/Font.php'; require 'HTMLPurifier/AttrDef/CSS/Font.php';
require 'HTMLPurifier/AttrDef/CSS/FontFamily.php'; require 'HTMLPurifier/AttrDef/CSS/FontFamily.php';
require 'HTMLPurifier/AttrDef/CSS/Ident.php';
require 'HTMLPurifier/AttrDef/CSS/ImportantDecorator.php'; require 'HTMLPurifier/AttrDef/CSS/ImportantDecorator.php';
require 'HTMLPurifier/AttrDef/CSS/Length.php'; require 'HTMLPurifier/AttrDef/CSS/Length.php';
require 'HTMLPurifier/AttrDef/CSS/ListStyle.php'; require 'HTMLPurifier/AttrDef/CSS/ListStyle.php';

View File

@ -85,6 +85,7 @@ require_once $__dir . '/HTMLPurifier/AttrDef/CSS/DenyElementDecorator.php';
require_once $__dir . '/HTMLPurifier/AttrDef/CSS/Filter.php'; require_once $__dir . '/HTMLPurifier/AttrDef/CSS/Filter.php';
require_once $__dir . '/HTMLPurifier/AttrDef/CSS/Font.php'; require_once $__dir . '/HTMLPurifier/AttrDef/CSS/Font.php';
require_once $__dir . '/HTMLPurifier/AttrDef/CSS/FontFamily.php'; require_once $__dir . '/HTMLPurifier/AttrDef/CSS/FontFamily.php';
require_once $__dir . '/HTMLPurifier/AttrDef/CSS/Ident.php';
require_once $__dir . '/HTMLPurifier/AttrDef/CSS/ImportantDecorator.php'; require_once $__dir . '/HTMLPurifier/AttrDef/CSS/ImportantDecorator.php';
require_once $__dir . '/HTMLPurifier/AttrDef/CSS/Length.php'; require_once $__dir . '/HTMLPurifier/AttrDef/CSS/Length.php';
require_once $__dir . '/HTMLPurifier/AttrDef/CSS/ListStyle.php'; require_once $__dir . '/HTMLPurifier/AttrDef/CSS/ListStyle.php';

View File

@ -0,0 +1,24 @@
<?php
/**
* Validates based on {ident} CSS grammar production
*/
class HTMLPurifier_AttrDef_CSS_Ident extends HTMLPurifier_AttrDef
{
public function validate($string, $config, $context) {
$string = trim($string);
// early abort: '' and '0' (strings that convert to false) are invalid
if (!$string) return false;
$pattern = '/^(-?[A-Za-z_][A-Za-z_\-0-9]*)$/';
if (!preg_match($pattern, $string)) return false;
return $string;
}
}
// vim: et sw=4 sts=4

View File

@ -12,12 +12,22 @@
class HTMLPurifier_AttrDef_HTML_ID extends HTMLPurifier_AttrDef class HTMLPurifier_AttrDef_HTML_ID extends HTMLPurifier_AttrDef
{ {
// ref functionality disabled, since we also have to verify // selector is NOT a valid thing to use for IDREFs, because IDREFs
// whether or not the ID it refers to exists // *must* target IDs that exist, whereas selector #ids do not.
/**
* Determines whether or not we're validating an ID in a CSS
* selector context.
*/
protected $selector;
public function __construct($selector = false) {
$this->selector = $selector;
}
public function validate($id, $config, $context) { public function validate($id, $config, $context) {
if (!$config->get('Attr.EnableID')) return false; if (!$this->selector && !$config->get('Attr.EnableID')) return false;
$id = trim($id); // trim it first $id = trim($id); // trim it first
@ -33,10 +43,10 @@ class HTMLPurifier_AttrDef_HTML_ID extends HTMLPurifier_AttrDef
'%Attr.IDPrefix is set', E_USER_WARNING); '%Attr.IDPrefix is set', E_USER_WARNING);
} }
//if (!$this->ref) { if (!$this->selector) {
$id_accumulator =& $context->get('IDAccumulator'); $id_accumulator =& $context->get('IDAccumulator');
if (isset($id_accumulator->ids[$id])) return false; if (isset($id_accumulator->ids[$id])) return false;
//} }
// we purposely avoid using regex, hopefully this is faster // we purposely avoid using regex, hopefully this is faster
@ -56,7 +66,7 @@ class HTMLPurifier_AttrDef_HTML_ID extends HTMLPurifier_AttrDef
return false; return false;
} }
if (/*!$this->ref && */$result) $id_accumulator->add($id); if (!$this->selector && $result) $id_accumulator->add($id);
// if no change was made to the ID, return the result // if no change was made to the ID, return the result
// else, return the new id if stripping whitespace made it // else, return the new id if stripping whitespace made it

View File

@ -21,10 +21,22 @@ class HTMLPurifier_Filter_ExtractStyleBlocks extends HTMLPurifier_Filter
private $_styleMatches = array(); private $_styleMatches = array();
private $_tidy; private $_tidy;
private $_id_attrdef;
private $_class_attrdef;
private $_enum_attrdef;
public function __construct() { public function __construct() {
$this->_tidy = new csstidy(); $this->_tidy = new csstidy();
$this->_id_attrdef = new HTMLPurifier_AttrDef_HTML_ID(true);
$this->_class_attrdef = new HTMLPurifier_AttrDef_CSS_Ident();
$this->_enum_attrdef = new HTMLPurifier_AttrDef_Enum(array('first-child', 'link', 'visited', 'active', 'hover', 'focus'));
} }
/**
* Error-handler that mutes errors, alternative to shut-up operator.
*/
public static function muteErrorHandler() {}
/** /**
* Save the contents of CSS blocks to style matches * Save the contents of CSS blocks to style matches
* @param $matches preg_replace style $matches array * @param $matches preg_replace style $matches array
@ -77,27 +89,166 @@ class HTMLPurifier_Filter_ExtractStyleBlocks extends HTMLPurifier_Filter
$css = substr($css, 0, -3); $css = substr($css, 0, -3);
} }
$css = trim($css); $css = trim($css);
set_error_handler(array('HTMLPurifier_Filter_ExtractStyleBlocks', 'muteErrorHandler'));
$this->_tidy->parse($css); $this->_tidy->parse($css);
restore_error_handler();
$css_definition = $config->getDefinition('CSS'); $css_definition = $config->getDefinition('CSS');
$html_definition = $config->getDefinition('HTML');
$new_css = array();
foreach ($this->_tidy->css as $k => $decls) { foreach ($this->_tidy->css as $k => $decls) {
// $decls are all CSS declarations inside an @ selector // $decls are all CSS declarations inside an @ selector
$new_decls = array(); $new_decls = array();
foreach ($decls as $selector => $style) { foreach ($decls as $selector => $style) {
$selector = trim($selector); $selector = trim($selector);
if ($selector === '') continue; // should not happen if ($selector === '') continue; // should not happen
if ($selector[0] === '+') { // Parse the selector
if ($selector !== '' && $selector[0] === '+') continue; // Here is the relevant part of the CSS grammar:
} //
if (!empty($scopes)) { // ruleset
$new_selector = array(); // because multiple ones are possible // : selector [ ',' S* selector ]* '{' ...
// selector
// : simple_selector [ combinator selector | S+ [ combinator? selector ]? ]?
// combinator
// : '+' S*
// : '>' S*
// simple_selector
// : element_name [ HASH | class | attrib | pseudo ]*
// | [ HASH | class | attrib | pseudo ]+
// element_name
// : IDENT | '*'
// ;
// class
// : '.' IDENT
// ;
// attrib
// : '[' S* IDENT S* [ [ '=' | INCLUDES | DASHMATCH ] S*
// [ IDENT | STRING ] S* ]? ']'
// ;
// pseudo
// : ':' [ IDENT | FUNCTION S* [IDENT S*]? ')' ]
// ;
//
// For reference, here are the relevant tokens:
//
// HASH #{name}
// IDENT {ident}
// INCLUDES ==
// DASHMATCH |=
// STRING {string}
// FUNCTION {ident}\(
//
// And the lexical scanner tokens
//
// name {nmchar}+
// nmchar [_a-z0-9-]|{nonascii}|{escape}
// nonascii [\240-\377]
// escape {unicode}|\\[^\r\n\f0-9a-f]
// unicode \\{h}}{1,6}(\r\n|[ \t\r\n\f])?
// ident -?{nmstart}{nmchar*}
// nmstart [_a-z]|{nonascii}|{escape}
// string {string1}|{string2}
// string1 \"([^\n\r\f\\"]|\\{nl}|{escape})*\"
// string2 \'([^\n\r\f\\"]|\\{nl}|{escape})*\'
//
// We'll implement a subset (in order to reduce attack
// surface); in particular:
//
// - No Unicode support
// - No escapes support
// - No string support (by proxy no attrib support)
// - element_name is matched against allowed
// elements (some people might find this
// annoying...)
// - Pseudo-elements one of :first-child, :link,
// :visited, :active, :hover, :focus
// handle ruleset
$selectors = array_map('trim', explode(',', $selector)); $selectors = array_map('trim', explode(',', $selector));
foreach ($scopes as $s1) { $new_selectors = array();
foreach ($selectors as $s2) { foreach ($selectors as $sel) {
$new_selector[] = "$s1 $s2"; // split on +, > and spaces
$basic_selectors = preg_split('/\s*([+> ])\s*/', $sel, -1, PREG_SPLIT_DELIM_CAPTURE);
// even indices are chunks, odd indices are
// delimiters
$nsel = null;
$delim = null; // guaranteed to be non-null after
// two loop iterations
for ($i = 0, $c = count($basic_selectors); $i < $c; $i++) {
$x = $basic_selectors[$i];
if ($i % 2) {
// delimiter
if ($x === ' ') {
$delim = ' ';
} else {
$delim = ' ' . $x . ' ';
}
} else {
// simple selector
$components = preg_split('/([#.:])/', $x, -1, PREG_SPLIT_DELIM_CAPTURE);
$sdelim = null;
$nx = null;
for ($j = 0, $cc = count($components); $j < $cc; $j ++) {
$y = $components[$j];
if ($j === 0) {
if ($y === '*' || isset($html_definition->info[$y = strtolower($y)])) {
$nx = $y;
} else {
// $nx stays null; this matters
// if we don't manage to find
// any valid selector content,
// in which case we ignore the
// outer $delim
}
} elseif ($j % 2) {
// set delimiter
$sdelim = $y;
} else {
$attrdef = null;
if ($sdelim === '#') {
$attrdef = $this->_id_attrdef;
} elseif ($sdelim === '.') {
$attrdef = $this->_class_attrdef;
} elseif ($sdelim === ':') {
$attrdef = $this->_enum_attrdef;
} else {
throw new HTMLPurifier_Exception('broken invariant sdelim and preg_split');
}
$r = $attrdef->validate($y, $config, $context);
if ($r !== false) {
if ($r !== true) {
$y = $r;
}
if ($nx === null) {
$nx = '';
}
$nx .= $sdelim . $y;
} }
} }
$selector = implode(', ', $new_selector); // now it's a string
} }
if ($nx !== null) {
if ($nsel === null) {
$nsel = $nx;
} else {
$nsel .= $delim . $nx;
}
} else {
// delimiters to the left of invalid
// basic selector ignored
}
}
}
if ($nsel !== null) {
if (!empty($scopes)) {
foreach ($scopes as $s) {
$new_selectors[] = "$s $nsel";
}
} else {
$new_selectors[] = $nsel;
}
}
}
if (empty($new_selectors)) continue;
$selector = implode(', ', $new_selectors);
foreach ($style as $name => $value) { foreach ($style as $name => $value) {
if (!isset($css_definition->info[$name])) { if (!isset($css_definition->info[$name])) {
unset($style[$name]); unset($style[$name]);
@ -110,10 +261,11 @@ class HTMLPurifier_Filter_ExtractStyleBlocks extends HTMLPurifier_Filter
} }
$new_decls[$selector] = $style; $new_decls[$selector] = $style;
} }
$this->_tidy->css[$k] = $new_decls; $new_css[$k] = $new_decls;
} }
// remove stuff that shouldn't be used, could be reenabled // remove stuff that shouldn't be used, could be reenabled
// after security risks are analyzed // after security risks are analyzed
$this->_tidy->css = $new_css;
$this->_tidy->import = array(); $this->_tidy->import = array();
$this->_tidy->charset = null; $this->_tidy->charset = null;
$this->_tidy->namespace = null; $this->_tidy->namespace = null;

View File

@ -10,7 +10,7 @@ class HTMLPurifier_Filter_ExtractStyleBlocksTest extends HTMLPurifier_Harness
function test_tokenizeHTML_extractStyleBlocks() { function test_tokenizeHTML_extractStyleBlocks() {
$this->config->set('Filter.ExtractStyleBlocks', true); $this->config->set('Filter.ExtractStyleBlocks', true);
$purifier = new HTMLPurifier($this->config); $purifier = new HTMLPurifier($this->config);
$result = $purifier->purify('<style type="text/css">.foo {text-align:center;bogus:remove-me;}</style>Test<style>* {font-size:12pt;}</style>'); $result = $purifier->purify('<style type="text/css">.foo {text-align:center;bogus:remove-me;} body.class[foo="attr"] {text-align:right;}</style>Test<style>* {font-size:12pt;}</style>');
$this->assertIdentical($result, 'Test'); $this->assertIdentical($result, 'Test');
$this->assertIdentical($purifier->context->get('StyleBlocks'), $this->assertIdentical($purifier->context->get('StyleBlocks'),
array( array(
@ -153,7 +153,7 @@ class HTMLPurifier_Filter_ExtractStyleBlocksTest extends HTMLPurifier_Harness
$this->config->set('Filter.ExtractStyleBlocks.Scope', '#foo, .bar'); $this->config->set('Filter.ExtractStyleBlocks.Scope', '#foo, .bar');
$this->assertCleanCSS( $this->assertCleanCSS(
"p, div {\ntext-indent:1em;\n}", "p, div {\ntext-indent:1em;\n}",
"#foo p, #foo div, .bar p, .bar div {\ntext-indent:1em;\n}" "#foo p, .bar p, #foo div, .bar div {\ntext-indent:1em;\n}"
); );
} }
@ -191,6 +191,41 @@ text-align:right;
); );
} }
function test_atSelector() {
$this->assertCleanCSS(
"{
b { text-align: center; }
}",
""
);
}
function test_selectorValidation() {
$this->assertCleanCSS(
"&, & {
text-align: center;
}",
""
);
$this->assertCleanCSS(
"&, b {
text-align:center;
}",
"b {
text-align:center;
}"
);
$this->assertCleanCSS(
"& a #foo:hover.bar +b > i {
text-align:center;
}",
"a #foo:hover.bar + b \\3E i {
text-align:center;
}"
);
$this->assertCleanCSS("doesnt-exist { text-align:center }", "");
}
} }
// vim: et sw=4 sts=4 // vim: et sw=4 sts=4