diff --git a/common/defaults/config.php b/common/defaults/config.php index 1afb6a510..3ea37f761 100644 --- a/common/defaults/config.php +++ b/common/defaults/config.php @@ -103,6 +103,7 @@ return array( 'mediafilter' => array( 'iframe' => array(), 'object' => array(), + 'classes' => array(), ), 'mobile' => array( 'enabled' => true, diff --git a/common/framework/filters/htmlfilter.php b/common/framework/filters/htmlfilter.php index 06a1f854e..e11da5b21 100644 --- a/common/framework/filters/htmlfilter.php +++ b/common/framework/filters/htmlfilter.php @@ -2,6 +2,7 @@ namespace Rhymix\Framework\Filters; +use Rhymix\Framework\Config; use Rhymix\Framework\Security; use Rhymix\Framework\Storage; @@ -11,9 +12,9 @@ use Rhymix\Framework\Storage; class HTMLFilter { /** - * HTMLPurifier instance is cached here. + * HTMLPurifier instances are cached here. */ - protected static $_htmlpurifier; + protected static $_instances = array(); /** * Pre-processing and post-processing filters are stored here. @@ -69,18 +70,26 @@ class HTMLFilter * Filter HTML content to block XSS attacks. * * @param string $input + * @param bool $allow_editor_components (optional) + * @param bool $allow_widgets (optional) * @return string */ - public static function clean($input) + public static function clean($input, $allow_editor_components = true, $allow_widgets = false) { foreach (self::$_preproc as $callback) { $input = $callback($input); } - $input = self::_preprocess($input); - $output = self::getHTMLPurifier()->purify($input); - $output = self::_postprocess($output); + $allowed_classes = Config::get('mediafilter.classes') ?: array(); + if ($allow_widgets) + { + $allowed_classes[] = 'zbxe_widget_output'; + } + + $input = self::_preprocess($input, $allow_editor_components, $allow_widgets); + $output = self::getHTMLPurifier($allowed_classes)->purify($input); + $output = self::_postprocess($output, $allow_editor_components, $allow_widgets); foreach (self::$_postproc as $callback) { @@ -93,18 +102,24 @@ class HTMLFilter /** * Get an instance of HTMLPurifier. * + * @param array $allowed_classes (optional) * @return object */ - public static function getHTMLPurifier() + public static function getHTMLPurifier($allowed_classes = array()) { + // Keep separate instances for different sets of allowed classes. + $allowed_classes = array_unique($allowed_classes); + sort($allowed_classes); + $key = sha1(serialize($allowed_classes)); + // Create an instance with reasonable defaults. - if (self::$_htmlpurifier === null) + if (!isset(self::$_instances[$key])) { // Get the default configuration. $config = \HTMLPurifier_Config::createDefault(); // Customize the default configuration. - $config->set('Attr.AllowedClasses', array()); + $config->set('Attr.AllowedClasses', $allowed_classes); $config->set('Attr.AllowedFrameTargets', array('_blank')); $config->set('Attr.DefaultImageAlt', ''); $config->set('Attr.EnableID', true); @@ -144,11 +159,11 @@ class HTMLFilter self::_supportCSS3($config); // Cache our instance of HTMLPurifier. - self::$_htmlpurifier = new \HTMLPurifier($config); + self::$_instances[$key] = new \HTMLPurifier($config); } // Return the cached instance. - return self::$_htmlpurifier; + return self::$_instances[$key]; } /** @@ -379,12 +394,17 @@ class HTMLFilter * Rhymix-specific preprocessing method. * * @param string $content + * @param bool $allow_editor_components (optional) + * @param bool $allow_widgets (optional) * @return string */ - protected static function _preprocess($content) + protected static function _preprocess($content, $allow_editor_components = true, $allow_widgets = false) { // Encode widget and editor component properties so that they are not removed by HTMLPurifier. - $content = self::_encodeWidgetsAndEditorComponents($content); + if ($allow_editor_components || $allow_widgets) + { + $content = self::_encodeWidgetsAndEditorComponents($content, $allow_editor_components, $allow_widgets); + } return $content; } @@ -392,9 +412,11 @@ class HTMLFilter * Rhymix-specific postprocessing method. * * @param string $content + * @param bool $allow_editor_components (optional) + * @param bool $allow_widgets (optional) * @return string */ - protected static function _postprocess($content) + protected static function _postprocess($content, $allow_editor_components = true, $allow_widgets = false) { // Define acts to allow and deny. $allow_acts = array('procFileDownload'); @@ -436,7 +458,7 @@ class HTMLFilter }, $content); // Restore widget and editor component properties. - $content = self::_decodeWidgetsAndEditorComponents($content); + $content = self::_decodeWidgetsAndEditorComponents($content, $allow_editor_components, $allow_widgets); return $content; } @@ -444,11 +466,27 @@ class HTMLFilter * Encode widgets and editor components before processing. * * @param string $content + * @param bool $allow_editor_components (optional) + * @param bool $allow_widgets (optional) * @return string */ - protected static function _encodeWidgetsAndEditorComponents($content) + protected static function _encodeWidgetsAndEditorComponents($content, $allow_editor_components = true, $allow_widgets = false) { - return preg_replace_callback('!<(div|img)([^>]*)(editor_component="[^"]+"|class="zbxe_widget_output")([^>]*)>!i', function($match) { + $regexp = array(); + if ($allow_editor_components) + { + $regexp[] = 'editor_component="[^"]+"'; + } + if ($allow_widgets) + { + $regexp[] = 'class="zbxe_widget_output"'; + } + if (!count($regexp)) + { + return $content; + } + + return preg_replace_callback('!<(div|img)([^>]*)(' . implode('|', $regexp) . ')([^>]*)>!i', function($match) { $tag = strtolower($match[1]); $attrs = array(); $html = preg_replace_callback('!([a-zA-Z0-9_-]+)="([^"]+)"!', function($attr) use($tag, &$attrs) { @@ -477,10 +515,25 @@ class HTMLFilter * Decode widgets and editor components after processing. * * @param string $content + * @param bool $allow_editor_components (optional) + * @param bool $allow_widgets (optional) * @return string */ - protected static function _decodeWidgetsAndEditorComponents($content) + protected static function _decodeWidgetsAndEditorComponents($content, $allow_editor_components = true, $allow_widgets = false) { + if (!$allow_editor_components) + { + $content = preg_replace('!(<(?:div|img)[^>]*)\s(editor_component="(?:[^"]+)")!i', '$1', $content); + } + if (!$allow_widgets) + { + $content = preg_replace('!(<(?:div|img)[^>]*)\s(widget="(?:[^"]+)")!i', '$1blocked-$2', $content); + } + if (!$allow_editor_components && !$allow_widgets) + { + return $content; + } + return preg_replace_callback('!<(div|img)([^>]*)(\srx_encoded_properties="([^"]+)")!i', function($match) { $attrs = array(); $decoded_properties = Security::decrypt($match[4]); diff --git a/modules/admin/admin.admin.controller.php b/modules/admin/admin.admin.controller.php index b7b3b007e..cbe6475fa 100644 --- a/modules/admin/admin.admin.controller.php +++ b/modules/admin/admin.admin.controller.php @@ -690,6 +690,14 @@ class adminAdminController extends admin natcasesort($object_whitelist); Rhymix\Framework\Config::set('mediafilter.object', array_values($object_whitelist)); + // HTML classes + $classes = $vars->mediafilter_classes; + $classes = array_filter(array_map('trim', preg_split('/[\r\n]/', $classes)), function($item) { + return preg_match('/^[a-zA-Z0-9_-]+$/u', $item); + }); + natcasesort($classes); + Rhymix\Framework\Config::set('mediafilter.classes', array_values($classes)); + // Remove old embed filter $config = Rhymix\Framework\Config::getAll(); unset($config['embedfilter']); diff --git a/modules/admin/admin.admin.view.php b/modules/admin/admin.admin.view.php index 653a55053..c27a12693 100644 --- a/modules/admin/admin.admin.view.php +++ b/modules/admin/admin.admin.view.php @@ -490,6 +490,7 @@ class adminAdminView extends admin // Load embed filter. context::set('mediafilter_iframe', implode(PHP_EOL, Rhymix\Framework\Filters\MediaFilter::getIframeWhitelist())); context::set('mediafilter_object', implode(PHP_EOL, Rhymix\Framework\Filters\MediaFilter::getObjectWhitelist())); + context::set('mediafilter_classes', implode(PHP_EOL, Rhymix\Framework\Config::get('mediafilter.classes') ?: array())); // Admin IP access control $allowed_ip = Rhymix\Framework\Config::get('admin.allow'); diff --git a/modules/admin/tpl/config_security.html b/modules/admin/tpl/config_security.html index a8050b94c..ce672748e 100644 --- a/modules/admin/tpl/config_security.html +++ b/modules/admin/tpl/config_security.html @@ -19,6 +19,12 @@ +
+ +
+ +
+
diff --git a/tests/unit/framework/filters/HTMLFilterTest.php b/tests/unit/framework/filters/HTMLFilterTest.php index d7ff12125..07a28ab4d 100644 --- a/tests/unit/framework/filters/HTMLFilterTest.php +++ b/tests/unit/framework/filters/HTMLFilterTest.php @@ -59,6 +59,7 @@ class HTMLFilterTest extends \Codeception\TestCase\Test ) ); + config('mediafilter.classes', array()); foreach ($tests as $test) { $this->assertEquals($test[1], Rhymix\Framework\Filters\HTMLFilter::clean($test[0])); @@ -142,6 +143,19 @@ class HTMLFilterTest extends \Codeception\TestCase\Test $this->assertEquals($target, Rhymix\Framework\Filters\HTMLFilter::clean($source)); } + public function testHTMLFilterAllowedClasses() + { + config('mediafilter.classes', array()); + $source = '

Hello World

'; + $target = '

Hello World

'; + $this->assertEquals($target, Rhymix\Framework\Filters\HTMLFilter::clean($source)); + + config('mediafilter.classes', array('mytest')); + $source = '

Hello World

'; + $target = '

Hello World

'; + $this->assertEquals($target, Rhymix\Framework\Filters\HTMLFilter::clean($source)); + } + public function testHTMLFilterEditorComponent() { $source = 'My Picture'; @@ -159,6 +173,29 @@ class HTMLFilterTest extends \Codeception\TestCase\Test $source = '
'; $target = '
'; $this->assertEquals($target, Rhymix\Framework\Filters\HTMLFilter::clean($source)); + + $source = 'My Picture'; + $target = 'My Picture'; + $this->assertEquals($target, Rhymix\Framework\Filters\HTMLFilter::clean($source, false)); + + $source = ''; + $target = ''; + $this->assertEquals($target, Rhymix\Framework\Filters\HTMLFilter::clean($source, false)); + } + + public function testHTMLFilterWidgetCode() + { + $source = '

Hello World

'; + $target = '

Hello World

'; + $this->assertEquals($target, Rhymix\Framework\Filters\HTMLFilter::clean($source)); + + $source = '

Hello World

'; + $target = '

Hello World

'; + $this->assertEquals($target, Rhymix\Framework\Filters\HTMLFilter::clean($source, true, true)); + + $source = '

Hello World

'; + $target = '

Hello World

'; + $this->assertEquals($target, Rhymix\Framework\Filters\HTMLFilter::clean($source, true, true)); } public function testHTMLFilterUserContentID()