diff --git a/core/CHANGELOG.txt b/core/CHANGELOG.txt index a565de6601b4366fabb5f4c503150731aec798a1..e28247e8391c17c953985b6434e2878135ca6e1b 100644 --- a/core/CHANGELOG.txt +++ b/core/CHANGELOG.txt @@ -97,6 +97,7 @@ Drupal 8.0, xxxx-xx-xx (development version) * Added language select form element in the Form API. - Added E-mail field type to core. - Added Link field type to core. +- Added local image input filter, to enable secure image posting. Drupal 7.0, 2011-01-05 ---------------------- diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module index 8b98bbe93e78d660f17a58919e332ae74b286b7d..ae187406c3332ee8ca7b716568cc1ca8697f33bd 100644 --- a/core/modules/filter/filter.module +++ b/core/modules/filter/filter.module @@ -73,6 +73,9 @@ function filter_theme() { 'filter_guidelines' => array( 'variables' => array('format' => NULL), ), + 'filter_html_image_secure_image' => array( + 'variables' => array('image' => NULL), + ), ); } @@ -1246,6 +1249,14 @@ function filter_filter_info() { ), 'tips callback' => '_filter_url_tips', ); + $filters['filter_html_image_secure'] = array( + 'title' => t('Restrict images to this site'), + 'description' => t('Disallows usage of <img> tag sources that are not hosted on this site by replacing them with a placeholder image.'), + 'process callback' => '_filter_html_image_secure_process', + 'tips callback' => '_filter_html_image_secure_tips', + // Supposed to run after other filters and before HTML corrector by default. + 'weight' => 9, + ); $filters['filter_htmlcorrector'] = array( 'title' => t('Correct faulty and chopped off HTML'), 'process callback' => '_filter_htmlcorrector', @@ -1765,6 +1776,78 @@ function _filter_html_escape_tips($filter, $format, $long = FALSE) { return t('No HTML tags allowed.'); } +/** + * Process callback for local image filter. + */ +function _filter_html_image_secure_process($text) { + // Find the path (e.g. '/') to Drupal root. + $base_path = base_path(); + $base_path_length = drupal_strlen($base_path); + + // Find the directory on the server where index.php resides. + $local_dir = DRUPAL_ROOT . '/'; + + $html_dom = filter_dom_load($text); + $images = $html_dom->getElementsByTagName('img'); + foreach ($images as $image) { + $src = $image->getAttribute('src'); + // Remove absolute URLs pointing to the local domain to prevent mixed + // content errors. + $image->setAttribute('src', preg_replace('|^https?://' . $_SERVER['HTTP_HOST'] . '|', '', $src)); + + // Verify that $src starts with $base_path. + // This also ensures that external images cannot be referenced. + $src = $image->getAttribute('src'); + if (drupal_substr($src, 0, $base_path_length) === $base_path) { + // Remove the $base_path to get the path relative to the Drupal root. + // Ensure the path refers to an actual image by prefixing the image source + // with the Drupal root and running getimagesize() on it. + $local_image_path = $local_dir . drupal_substr($src, $base_path_length); + if (@getimagesize($local_image_path)) { + // The image has the right path. Erroneous images are dealt with below. + continue; + } + } + // Replace an invalid image with an error indicator. + theme('filter_html_image_secure_image', array('image' => $image)); + } + $text = filter_dom_serialize($html_dom); + return $text; +} + +/** + * Formats an image DOM element that has an invalid source. + * + * @param DOMElement $image + * An IMG node to format, parsed from the filtered text. + * + * @return void + * Unlike other theme functions, the passed in $image is altered by reference. + * + * @see _filter_html_image_secure_process() + * @ingroup themeable + */ +function theme_filter_html_image_secure_image(&$variables) { + $image = $variables['image']; + + // Turn an invalid image into an error indicator. + $image->setAttribute('src', base_path() . 'core/misc/message-16-error.png'); + $image->setAttribute('alt', t('Image removed.')); + $image->setAttribute('title', t('This image has been removed. For security reasons, only images from the local domain are allowed.')); + + // Add a CSS class to aid in styling. + $class = ($image->getAttribute('class') ? trim($image->getAttribute('class')) . ' ' : ''); + $class .= 'filter-image-invalid'; + $image->setAttribute('class', $class); +} + +/** + * Filter tips callback for secure HTML image filter. + */ +function _filter_html_image_secure_tips($filter, $format, $long = FALSE) { + return t('Only images hosted on this site may be used in <img> tags.'); +} + /** * @} End of "defgroup standard_filters". */ diff --git a/core/modules/filter/lib/Drupal/filter/Tests/FilterHtmlImageSecureTest.php b/core/modules/filter/lib/Drupal/filter/Tests/FilterHtmlImageSecureTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a7e38accaaa37dd38ec0f311680ba9cbf491165b --- /dev/null +++ b/core/modules/filter/lib/Drupal/filter/Tests/FilterHtmlImageSecureTest.php @@ -0,0 +1,139 @@ +<?php + +/** + * @file + * Contains Drupal\filter\Tests\FilterHtmlImageSecureTest. + */ + +namespace Drupal\filter\Tests; + +use Drupal\simpletest\WebTestBase; + +/** + * Tests restriction of IMG tags in HTML input. + */ +class FilterHtmlImageSecureTest extends WebTestBase { + + /** + * Modules to enable. + * + * @var array + */ + public static $modules = array('filter', 'node', 'comment'); + + public static function getInfo() { + return array( + 'name' => 'Local image input filter', + 'description' => 'Tests restriction of IMG tags in HTML input.', + 'group' => 'Filter', + ); + } + + function setUp() { + parent::setUp(); + + // Setup Filtered HTML text format. + $filtered_html_format = array( + 'format' => 'filtered_html', + 'name' => 'Filtered HTML', + 'filters' => array( + 'filter_html' => array( + 'status' => 1, + 'settings' => array( + 'allowed_html' => '<img> <a>', + ), + ), + 'filter_autop' => array( + 'status' => 1, + ), + 'filter_html_image_secure' => array( + 'status' => 1, + ), + ), + ); + $filtered_html_format = (object) $filtered_html_format; + filter_format_save($filtered_html_format); + + // Setup users. + $this->checkPermissions(array(), TRUE); + $this->web_user = $this->drupalCreateUser(array( + 'access content', + 'access comments', + 'post comments', + 'skip comment approval', + filter_permission_name($filtered_html_format), + )); + $this->drupalLogin($this->web_user); + + // Setup a node to comment and test on. + $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page')); + $this->node = $this->drupalCreateNode(); + } + + /** + * Tests removal of images having a non-local source. + */ + function testImageSource() { + global $base_url; + + $public_files_path = variable_get('file_public_path', conf_path() . '/files'); + + $http_base_url = preg_replace('/^https?/', 'http', $base_url); + $https_base_url = preg_replace('/^https?/', 'https', $base_url); + $files_path = base_path() . $public_files_path; + $csrf_path = $public_files_path . '/' . implode('/', array_fill(0, substr_count($public_files_path, '/') + 1, '..')); + + $druplicon = 'core/misc/druplicon.png'; + $red_x_image = base_path() . 'core/misc/message-16-error.png'; + $alt_text = t('Image removed.'); + $title_text = t('This image has been removed. For security reasons, only images from the local domain are allowed.'); + + // Put a test image in the files directory. + $test_images = $this->drupalGetTestFiles('image'); + $test_image = $test_images[0]->filename; + + // Create a list of test image sources. + // The keys become the value of the IMG 'src' attribute, the values are the + // expected filter conversions. + $images = array( + $http_base_url . '/' . $druplicon => base_path() . $druplicon, + $https_base_url . '/' . $druplicon => base_path() . $druplicon, + base_path() . $druplicon => base_path() . $druplicon, + $files_path . '/' . $test_image => $files_path . '/' . $test_image, + $http_base_url . '/' . $public_files_path . '/' . $test_image => $files_path . '/' . $test_image, + $https_base_url . '/' . $public_files_path . '/' . $test_image => $files_path . '/' . $test_image, + $files_path . '/example.png' => $red_x_image, + 'http://example.com/' . $druplicon => $red_x_image, + 'https://example.com/' . $druplicon => $red_x_image, + 'javascript:druplicon.png' => $red_x_image, + $csrf_path . '/logout' => $red_x_image, + ); + $comment = array(); + foreach ($images as $image => $converted) { + // Output the image source as plain text for debugging. + $comment[] = $image . ':'; + // Hash the image source in a custom test attribute, because it might + // contain characters that confuse XPath. + $comment[] = '<img src="' . $image . '" testattribute="' . md5($image) . '" />'; + } + $edit = array( + 'comment_body[und][0][value]' => implode("\n", $comment), + ); + $this->drupalPost('node/' . $this->node->nid, $edit, t('Save')); + foreach ($images as $image => $converted) { + $found = FALSE; + foreach ($this->xpath('//img[@testattribute="' . md5($image) . '"]') as $element) { + $found = TRUE; + if ($converted == $red_x_image) { + $this->assertEqual((string) $element['src'], $red_x_image); + $this->assertEqual((string) $element['alt'], $alt_text); + $this->assertEqual((string) $element['title'], $title_text); + } + else { + $this->assertEqual((string) $element['src'], $converted); + } + } + $this->assertTrue($found, format_string('@image was found.', array('@image' => $image))); + } + } +}