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 @@
+ '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' => '
',
+ ),
+ ),
+ '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[] = '
';
+ }
+ $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)));
+ }
+ }
+}