 .../ckeditor/Plugin/ckeditor/plugin/Internal.php   |   50 ++++++++-
 .../lib/Drupal/ckeditor/Tests/CKEditorTest.php     |   21 +++-
 core/modules/filter/filter.api.php                 |   39 +++++++
 core/modules/filter/filter.module                  |  113 +++++++++++++++++++-
 .../lib/Drupal/filter/Tests/FilterAPITest.php      |   97 +++++++++++++++--
 .../filter/tests/filter_test/filter_test.module    |   15 ++-
 6 files changed, 318 insertions(+), 17 deletions(-)

diff --git a/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/ckeditor/plugin/Internal.php b/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/ckeditor/plugin/Internal.php
index 6c8fd54..a3e106d 100644
--- a/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/ckeditor/plugin/Internal.php
+++ b/core/modules/ckeditor/lib/Drupal/ckeditor/Plugin/ckeditor/plugin/Internal.php
@@ -56,7 +56,11 @@ public function getConfig(Editor $editor) {
       ),
     );
 
-    // Next, add the format_tags setting, if its button is enabled.
+    // Add the allowedContent setting, which ensures CKEditor only allows tags
+    // and attributes that are allowed by the text format for this text editor.
+    $config['allowedContent'] = $this->generateAllowedContentSetting($editor);
+
+    // Add the format_tags setting, if its button is enabled.
     $toolbar_buttons = array_unique(NestedArray::mergeDeepArray($editor->settings['toolbar']['buttons']));
     if (in_array('Format', $toolbar_buttons)) {
       $config['format_tags'] = $this->generateFormatTagsSetting($editor);
@@ -243,6 +247,7 @@ public function getButtons() {
    *
    * @param \Drupal\editor\Plugin\Core\Entity\Editor $editor
    *   A configured text editor object.
+   *
    * @return array
    *   An array containing the "format_tags" configuration.
    */
@@ -265,4 +270,47 @@ protected function generateFormatTagsSetting(Editor $editor) {
     return implode(';', $format_tags);
   }
 
+  /**
+   * Builds the "allowedContent" configuration part of the CKEditor JS settings.
+   *
+   * This ensures that CKEditor obeys the HTML restrictions defined by Drupal's
+   * filter system, by enabling CKEditor's Advanced Content Filter (ACF)
+   * functionality: http://ckeditor.com/blog/CKEditor-4.1-RC-Released.
+   *
+   * @see getConfig()
+   *
+   * @param \Drupal\editor\Plugin\Core\Entity\Editor $editor
+   *   A configured text editor object.
+   *
+   * @return string|TRUE
+   *   The "allowedContent" configuration: a well-formatted string or TRUE. The
+   *   latter indicates that anything is allowed.
+   */
+  protected function generateAllowedContentSetting(Editor $editor) {
+    $filter_types = filter_get_filter_types_by_format($editor->format);
+
+    // When nothing is disallowed, set allowedContent to true.
+    if (!in_array(FILTER_TYPE_HTML_RESTRICTOR, $filter_types)) {
+      return TRUE;
+    }
+    // Generate setting that accurately reflects allowed tags and attributes.
+    else {
+      $allowed_html = filter_get_allowed_html_by_format($editor->format);
+      // When all HTML is allowed, also set allowedContent to true.
+      if ($allowed_html === TRUE) {
+        return TRUE;
+      }
+      $setting = array();
+      foreach($allowed_html as $tag => $attributes) {
+        if ($attributes === TRUE) {
+          $setting[] = $tag . '[*]';
+        }
+        else {
+          $setting[] = $tag . '[' . implode(',', $attributes) . ']';
+        }
+      }
+      return implode(';', $setting);
+    }
+  }
+
 }
diff --git a/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php b/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php
index db4dbec..8b3277f 100644
--- a/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php
+++ b/core/modules/ckeditor/lib/Drupal/ckeditor/Tests/CKEditorTest.php
@@ -53,6 +53,9 @@ function setUp() {
       'filters' => array(
         'filter_html' => array(
           'status' => 1,
+          'settings' => array(
+            'allowed_html' => '<h4> <h5> <h6> <p> <br> <strong> <a>',
+          )
         ),
       ),
     ));
@@ -76,6 +79,7 @@ function testGetJSSettings() {
 
     // Default toolbar.
     $expected_config = $this->getDefaultInternalConfig() + array(
+      'allowedContent' => $this->getDefaultAllowedContentConfig(),
       'toolbar' => $this->getDefaultToolbarConfig(),
       'contentsCss' => $this->getDefaultContentsCssConfig(),
       'extraPlugins' => '',
@@ -104,13 +108,21 @@ function testGetJSSettings() {
     $expected_config['keystrokes'] = array(array(1114187, 'link'), array(1114188, NULL));
     $this->assertEqual($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.');
 
-    // Change the allowed HTML tags; the "format_tags" setting for CKEditor
-    // should automatically be updated as well.
+    // Change the allowed HTML tags; the "allowedContent" and format_tags"
+    // settings for CKEditor should automatically be updated as well.
     $format = entity_load('filter_format', 'filtered_html');
     $format->filters['filter_html']['settings']['allowed_html'] .= '<pre> <h3>';
     $format->save();
+    $expected_config['allowedContent'] = 'h4[*];h5[*];h6[*];p[*];br[*];strong[*];a[*];pre[*];h3[*]';
     $expected_config['format_tags'] = 'p;h3;h4;h5;h6;pre';
     $this->assertEqual($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.');
+
+    // Remove the filtered_html filter: allow *all *tags.
+    unset($format->filters['filter_html']);
+    $format->save();
+    $expected_config['allowedContent'] = TRUE;
+    $expected_config['format_tags'] = 'p;h1;h2;h3;h4;h5;h6;pre';
+    $this->assertEqual($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.');
   }
 
   /**
@@ -166,6 +178,7 @@ function testInternalGetConfig() {
 
     // Default toolbar.
     $expected = $this->getDefaultInternalConfig();
+    $expected['allowedContent'] = $this->getDefaultAllowedContentConfig();
     $this->assertIdentical($expected, $internal_plugin->getConfig($editor), '"Internal" plugin configuration built correctly for default toolbar.');
 
     // Format dropdown/button enabled: new setting should be present.
@@ -230,6 +243,10 @@ protected function getDefaultInternalConfig() {
     );
   }
 
+  protected function getDefaultAllowedContentConfig() {
+    return 'h4[*];h5[*];h6[*];p[*];br[*];strong[*];a[*]';
+  }
+
   protected function getDefaultToolbarConfig() {
     return array(
       0 => array('items' => array('Bold', 'Italic')),
diff --git a/core/modules/filter/filter.api.php b/core/modules/filter/filter.api.php
index 49311c8..cb929fa 100644
--- a/core/modules/filter/filter.api.php
+++ b/core/modules/filter/filter.api.php
@@ -82,6 +82,10 @@
  *   - tips callback: The name of a function that returns end-user-facing
  *     filter usage guidelines for the filter. See hook_filter_FILTER_tips()
  *     for details.
+ *   - allowed html callback: The name of a function that returns allowed HTML
+ *     tags and attributes for the filter. May be implemented by filters of the
+ *     type FILTER_TYPE_HTML_RESTRICTOR. See hook_filter_FILTER_allowed_html()
+ *     for details.
  *   - weight: A default weight for the filter in new text formats.
  *
  * @see filter_example.module
@@ -184,6 +188,41 @@ function hook_filter_FILTER_settings($form, &$form_state, $filter, $format, $def
 }
 
 /**
+ * Filter allowed HTML callback for hook_filter_info().
+ *
+ * Note: This is not really a hook. The function name is manually specified via
+ * 'allowed html callback' in hook_filter_info(), with this recommended callback
+ * name pattern. It is called from filter_get_allowed_html_by_format().
+ *
+ * This callback function is only necessary for filters that strip away HTML
+ * tags (and possibly attributes) and allows other modules to gain insight in a
+ * generic manner into which HTML tags and attributes are allowed by a format.
+ *
+ * @param $filter
+ *   The filter object containing settings for the given format.
+ *
+ * @return array
+ *   A nested array with the allowed tags as keys, and for each of those tags
+ *   (keys) the corresponding allowed attributes. An empty array for allowed
+ *   attributes means no attributes are allowed, TRUE means all attributes are
+ *   allowed.
+ *
+ * @see filter_get_allowed_html_by_format()
+ */
+function hook_filter_FILTER_allowed_html($filter) {
+  return array(
+    // Allows no attributes on the <p> tag.
+    'p' => array(),
+    // Only allows the 'href' attribute on the <a> tag.
+    'a' => array('href'),
+    // Only allows the 'src' and 'alt' attributes on the <alt> tag.
+    'img' => array('src', 'alt'),
+    // Allows any attribute on the <div> tag.
+    'div' => TRUE,
+  );
+}
+
+/**
  * Prepare callback for hook_filter_info().
  *
  * Note: This is not really a hook. The function name is manually specified via
diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module
index 725d931..7f1fa22 100644
--- a/core/modules/filter/filter.module
+++ b/core/modules/filter/filter.module
@@ -495,6 +495,90 @@ function filter_get_filter_types_by_format($format_id) {
 }
 
 /**
+ * Retrieve all HTML tags and attributes allowed by a given text format.
+ *
+ * @param string $format_id
+ *   A text format ID.
+ *
+ * @return array|TRUE
+ *   An array of HTML tags (in "p", not "<p>" format) that are allowed by the
+ *   text format. The empty array implies no tags are allowed. TRUE implies all
+ *   tags are allowed.
+ */
+function filter_get_allowed_html_by_format($format_id) {
+  $filters = filter_list_format($format_id);
+
+  // Ignore filters that are disabled or don't have an "allowed html" setting.
+  $filters_info = filter_get_filters();
+  $filters = array_filter($filters, function($filter) use ($filters_info) {
+    if (!$filter->status) {
+      return FALSE;
+    }
+    if (!empty($filters_info[$filter->name]['allowed html callback'])) {
+      return TRUE;
+    }
+    return FALSE;
+  });
+
+  if (empty($filters)) {
+    return TRUE;
+  }
+  else {
+    // From the set of remaining filters (they were filtered by array_filter()
+    // above), collect the list of tags and attributes that are allowed by all
+    // filters, i.e. the intersection of all allowed tags and attributes.
+    $allowed_html = array_reduce($filters, function($intersection, $filter) {
+      $filters_info = filter_get_filters();
+      $function = $filters_info[$filter->name]['allowed html callback'];
+      $new_allowed_html = $function($filter);
+
+      // The first filter with an "allowed html" setting provides the initial
+      // set.
+      if (!isset($intersection)) {
+        return $new_allowed_html;
+      }
+      // Subsequent filters with an "allowed html" setting must be intersected
+      // with the existing set, to ensure we only end up with the tags that are
+      // allowed by *all* filters with an "allowed html" setting.
+      else {
+        foreach ($intersection as $tag => $attributes) {
+          // If the current tag is disallowed by the new filter, then it's
+          // outside of the intersection.
+          if (!array_key_exists($tag, $new_allowed_html)) {
+            unset($intersection[$tag]);
+          }
+          // The tag is in the intersection, but now we most calculate the
+          // intersection of the allowed attributes.
+          else {
+            $current_attributes = $intersection[$tag];
+            $new_attributes = $new_allowed_html[$tag];
+            // The new filter allows less attributes; assign new.
+            if ($current_attributes == TRUE && is_array($new_attributes)) {
+              $intersection[$tag] = $new_attributes;
+            }
+            // The new filter allows more attributes; retain current.
+            else if (is_array($current_attributes) && $new_attributes == TRUE) {
+              continue;
+            }
+            // The new filter allows the same attributes; retain current.
+            else if ($current_attributes == $new_attributes) {
+              continue;
+            }
+            // Both list an array of allowed attributes; do an intersection.
+            else {
+              $intersection[$tag] = array_intersect($intersection[$tag], $new_attributes);
+            }
+          }
+        }
+        return $intersection;
+      }
+    }, NULL);
+
+    return $allowed_html;
+  }
+}
+
+/**
  * Returns the ID of the fallback text format that all users have access to.
  *
  * The fallback text format is a regular text format in every respect, except
@@ -1229,6 +1313,7 @@ function filter_filter_info() {
       'filter_html_nofollow' => 0,
     ),
     'tips callback' => '_filter_html_tips',
+    'allowed html callback' => '_filter_html_allowed_html',
     'weight' => -10,
   );
   $filters['filter_autop'] = array(
@@ -1267,6 +1352,7 @@ function filter_filter_info() {
     'type' => FILTER_TYPE_HTML_RESTRICTOR,
     'process callback' => '_filter_html_escape',
     'tips callback' => '_filter_html_escape_tips',
+    'allowed html callback' => '_filter_html_escape_allowed_html',
     'weight' => -10,
   );
   return $filters;
@@ -1302,10 +1388,25 @@ function _filter_html_settings($form, &$form_state, $filter, $format, $defaults)
 }
 
 /**
+ * Filter allowed HTML callback for the HTML content filter.
+ *
+ * See hook_filter_FILTER_allowed_html() for documentation of parameters and
+ * return value.
+ */
+function _filter_html_allowed_html($filter) {
+  $allowed_html = array();
+  $tags = preg_split('/\s+|<|>/', $filter->settings['allowed_html'], -1, PREG_SPLIT_NO_EMPTY);
+  foreach ($tags as $tag) {
+    $allowed_html[$tag] = TRUE;
+  }
+  return $allowed_html;
+}
+
+/**
  * Provides filtering of input into accepted HTML.
  */
 function _filter_html($text, $filter) {
-  $allowed_tags = preg_split('/\s+|<|>/', $filter->settings['allowed_html'], -1, PREG_SPLIT_NO_EMPTY);
+  $allowed_tags = array_keys(_filter_html_allowed_html($filter));
   $text = filter_xss($text, $allowed_tags);
 
   if ($filter->settings['filter_html_nofollow']) {
@@ -1763,6 +1864,16 @@ function _filter_autop_tips($filter, $format, $long = FALSE) {
 }
 
 /**
+ * Filter allowed HTML callback for the HTML content filter.
+ *
+ * See hook_filter_FILTER_allowed_html() for documentation of parameters and
+ * return value.
+ */
+function _filter_html_escape_allowed_html($filter) {
+  return array();
+}
+
+/**
  * Escapes all HTML tags, so they will be visible instead of being effective.
  */
 function _filter_html_escape($text) {
diff --git a/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php b/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php
index 3918ea9..c57280f 100644
--- a/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php
+++ b/core/modules/filter/lib/Drupal/filter/Tests/FilterAPITest.php
@@ -2,17 +2,19 @@
 
 /**
  * @file
- * Definition of Drupal\filter\Tests\FilterAPITest.
+ * Contains \Drupal\filter\Tests\FilterAPITest.
  */
 
 namespace Drupal\filter\Tests;
 
-use Drupal\simpletest\WebTestBase;
+use Drupal\simpletest\DrupalUnitTestBase;
 
 /**
  * Tests the behavior of Filter's API.
  */
-class FilterAPITest extends WebTestBase {
+class FilterAPITest extends DrupalUnitTestBase {
+
+  public static $modules = array('system', 'filter', 'filter_test');
 
   public static function getInfo() {
     return array(
@@ -25,6 +27,8 @@ public static function getInfo() {
   function setUp() {
     parent::setUp();
 
+    $this->installConfig(array('system'));
+
     // Create Filtered HTML format.
     $filtered_html_format = entity_create('filter_format', array(
       'format' => 'filtered_html',
@@ -38,6 +42,9 @@ function setUp() {
         // Note that the filter_html filter is of the type FILTER_TYPE_HTML_RESTRICTOR.
         'filter_html' => array(
           'status' => 1,
+          'settings' => array(
+            'allowed_html' => '<p> <br> <strong> <a>',
+          ),
         ),
       )
     ));
@@ -48,12 +55,7 @@ function setUp() {
       'format' => 'full_html',
       'name' => 'Full HTML',
       'weight' => 1,
-      'filters' => array(
-        'filter_htmlcorrector' => array(
-          'weight' => 10,
-          'status' => 1,
-        ),
-      ),
+      'filters' => array(),
     ));
     $full_html_format->save();
   }
@@ -88,11 +90,18 @@ function testCheckMarkup() {
   }
 
   /**
-   * Tests the function filter_get_filter_types_by_format().
+   * Tests the following functions for a variety of formats:
+   *   - filter_get_allowed_html_by_format()
+   *   - filter_get_filter_types_by_format()
    */
   function testFilterFormatAPI() {
     // Test on filtered_html.
     $this->assertEqual(
+      filter_get_allowed_html_by_format('filtered_html'),
+      array('p' => TRUE, 'br' => TRUE, 'strong' => TRUE, 'a' => TRUE),
+      'filter_get_allowed_html_by_format() works as expected for the filtered_html format.'
+    );
+    $this->assertEqual(
       filter_get_filter_types_by_format('filtered_html'),
       array(FILTER_TYPE_HTML_RESTRICTOR, FILTER_TYPE_MARKUP_LANGUAGE),
       'filter_get_filter_types_by_format() works as expected for the filtered_html format.'
@@ -100,10 +109,76 @@ function testFilterFormatAPI() {
 
     // Test on full_html.
     $this->assertEqual(
+      filter_get_allowed_html_by_format('full_html'),
+      TRUE, // Every tag is allowed.
+      'filter_get_allowed_html_by_format() works as expected for the full_html format.'
+    );
+    $this->assertEqual(
       filter_get_filter_types_by_format('full_html'),
-      array(FILTER_TYPE_HTML_RESTRICTOR),
+      array(),
       'filter_get_filter_types_by_format() works as expected for the full_html format.'
     );
+
+    // Test on stupid_filtered_html, where nothing is disallowed.
+    $stupid_filtered_html_format = entity_create('filter_format', array(
+      'format' => 'stupid_filtered_html',
+      'name' => 'Stupid Filtered HTML',
+      'filters' => array(
+        'filter_html' => array(
+          'status' => 1,
+          'settings' => array(
+            'allowed_html' => '', // Nothing is allowed.
+          ),
+        ),
+      ),
+    ));
+    $stupid_filtered_html_format->save();
+    $this->assertEqual(
+      filter_get_allowed_html_by_format('stupid_filtered_html'),
+      array(), // No tag is allowed.
+      'filter_get_allowed_html_by_format() works as expected for the stupid_filtered_html format.'
+    );
+    $this->assertEqual(
+      filter_get_filter_types_by_format('stupid_filtered_html'),
+      array(FILTER_TYPE_HTML_RESTRICTOR),
+      'filter_get_filter_types_by_format() works as expected for the stupid_filtered_html format.'
+    );
+
+    // Test on very_restricted_html, where there's two different filters of the
+    // FILTER_TYPE_HTML_RESTRICTOR type, each restricting in different ways.
+    $very_restricted_html = entity_create('filter_format', array(
+      'format' => 'very_restricted_html',
+      'name' => 'Very Restricted HTML',
+      'filters' => array(
+        'filter_html' => array(
+          'status' => 1,
+          'settings' => array(
+            'allowed_html' => 'p br a strong',
+          ),
+        ),
+        'filter_test_restrict_tags_and_attributes' => array(
+          'status' => 1,
+          'settings' => array(
+            'restrictions' => array(
+              'p' => TRUE,
+              'br' => array(),
+              'a' => array('href'),
+            )
+          ),
+        ),
+      )
+    ));
+    $very_restricted_html->save();
+    $this->assertEqual(
+      filter_get_allowed_html_by_format('very_restricted_html'),
+      array('p' => TRUE, 'br' => array(), 'a' => array('href')),
+      'filter_get_allowed_html_by_format() works as expected for the very_restricted_html format.'
+    );
+    $this->assertEqual(
+      filter_get_filter_types_by_format('very_restricted_html'),
+      array(FILTER_TYPE_HTML_RESTRICTOR),
+      'filter_get_filter_types_by_format() works as expected for the very_restricted_html format.'
+    );
   }
 
 }
diff --git a/core/modules/filter/tests/filter_test/filter_test.module b/core/modules/filter/tests/filter_test/filter_test.module
index a61941a..4cd9ddb 100644
--- a/core/modules/filter/tests/filter_test/filter_test.module
+++ b/core/modules/filter/tests/filter_test/filter_test.module
@@ -40,7 +40,12 @@ function filter_test_filter_info() {
     'title' => 'Testing filter',
     'type' => FILTER_TYPE_TRANSFORM_IRREVERSIBLE,
     'description' => 'Replaces all content with filter and text format information.',
-    'process callback' => 'filter_test_replace',
+    'process callback' => '_filter_test_replace',
+  );
+  $filters['filter_test_restrict_tags_and_attributes'] = array(
+    'title' => 'Tag & attribute restricting filter',
+    'type' => FILTER_TYPE_HTML_RESTRICTOR,
+    'allowed html callback' => '_filter_restrict_tags_and_attributes_allowed_html',
   );
   return $filters;
 }
@@ -50,7 +55,7 @@ function filter_test_filter_info() {
  *
  * Replaces all text with filter and text format information.
  */
-function filter_test_replace($text, $filter, $format, $langcode, $cache, $cache_id) {
+function _filter_test_replace($text, $filter, $format, $langcode, $cache, $cache_id) {
   $text = array();
   $text[] = 'Filter: ' . $filter->title . ' (' . $filter->name . ')';
   $text[] = 'Format: ' . $format->name . ' (' . $format->format . ')';
@@ -62,3 +67,9 @@ function filter_test_replace($text, $filter, $format, $langcode, $cache, $cache_
   return implode("<br />\n", $text);
 }
 
+/**
+ * Filter allowed HTML callback.
+ */
+function _filter_restrict_tags_and_attributes_allowed_html($filter) {
+  return $filter->settings['restrictions'];
+}
