diff --git a/core/lib/Drupal/Component/Utility/Xss.php b/core/lib/Drupal/Component/Utility/Xss.php
index 3cd3a33..de51728 100644
--- a/core/lib/Drupal/Component/Utility/Xss.php
+++ b/core/lib/Drupal/Component/Utility/Xss.php
@@ -192,6 +192,7 @@ protected static function attributes($attributes) {
     $mode = 0;
     $attribute_name = '';
     $skip = FALSE;
+    $harmless = FALSE;
 
     while (strlen($attributes) != 0) {
       // Was the last operation successful?
@@ -203,6 +204,10 @@ protected static function attributes($attributes) {
           if (preg_match('/^([-a-zA-Z]+)/', $attributes, $match)) {
             $attribute_name = strtolower($match[1]);
             $skip = ($attribute_name == 'style' || substr($attribute_name, 0, 2) == 'on');
+            $harmless = substr($attribute_name, 0, 5) === 'data-' || in_array($attribute_name, array(
+              'title',
+              'alt',
+            ));
             $working = $mode = 1;
             $attributes = preg_replace('/^[-a-zA-Z]+/', '', $attributes);
           }
@@ -228,7 +233,7 @@ protected static function attributes($attributes) {
         case 2:
           // Attribute value, a URL after href= for instance.
           if (preg_match('/^"([^"]*)"(\s+|$)/', $attributes, $match)) {
-            $thisval = UrlHelper::filterBadProtocol($match[1]);
+            $thisval = $harmless ? $match[1] : UrlHelper::filterBadProtocol($match[1]);
 
             if (!$skip) {
               $attributes_array[] = "$attribute_name=\"$thisval\"";
@@ -240,7 +245,7 @@ protected static function attributes($attributes) {
           }
 
           if (preg_match("/^'([^']*)'(\s+|$)/", $attributes, $match)) {
-            $thisval = UrlHelper::filterBadProtocol($match[1]);
+            $thisval = $harmless ? $match[1] : UrlHelper::filterBadProtocol($match[1]);
 
             if (!$skip) {
               $attributes_array[] = "$attribute_name='$thisval'";
@@ -251,7 +256,7 @@ protected static function attributes($attributes) {
           }
 
           if (preg_match("%^([^\s\"']+)(\s+|$)%", $attributes, $match)) {
-            $thisval = UrlHelper::filterBadProtocol($match[1]);
+            $thisval = $harmless ? $match[1] : UrlHelper::filterBadProtocol($match[1]);
 
             if (!$skip) {
               $attributes_array[] = "$attribute_name=\"$thisval\"";
diff --git a/core/modules/filter/src/Plugin/Filter/FilterCaption.php b/core/modules/filter/src/Plugin/Filter/FilterCaption.php
index b68e6d3..32977ec 100644
--- a/core/modules/filter/src/Plugin/Filter/FilterCaption.php
+++ b/core/modules/filter/src/Plugin/Filter/FilterCaption.php
@@ -9,7 +9,6 @@
 
 use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\SafeMarkup;
-use Drupal\Component\Utility\String;
 use Drupal\Component\Utility\Unicode;
 use Drupal\Component\Utility\Xss;
 use Drupal\filter\FilterProcessResult;
@@ -40,12 +39,12 @@ public function process($text, $langcode) {
       $xpath = new \DOMXPath($dom);
       foreach ($xpath->query('//*[@data-caption]') as $node) {
         // Read the data-caption attribute's value, then delete it.
-        $caption = String::checkPlain($node->getAttribute('data-caption'));
+        $caption = SafeMarkup::checkPlain($node->getAttribute('data-caption'));
         $node->removeAttribute('data-caption');
 
         // Sanitize caption: decode HTML encoding, limit allowed HTML tags; only
         // allow inline tags that are allowed by default, plus <br>.
-        $caption = String::decodeEntities($caption);
+        $caption = Html::decodeEntities($caption);
         $caption = Xss::filter($caption, array('a', 'em', 'strong', 'cite', 'code', 'br'));
 
         // The caption must be non-empty.
diff --git a/core/modules/filter/src/Tests/FilterUnitTest.php b/core/modules/filter/src/Tests/FilterUnitTest.php
index 2c2a5c9..a72a050 100644
--- a/core/modules/filter/src/Tests/FilterUnitTest.php
+++ b/core/modules/filter/src/Tests/FilterUnitTest.php
@@ -9,6 +9,7 @@
 
 use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\String;
+use Drupal\Component\Utility\Xss;
 use Drupal\filter\FilterPluginCollection;
 use Drupal\simpletest\KernelTestBase;
 
@@ -176,6 +177,69 @@ function testCaptionFilter() {
     $output = $test($input);
     $this->assertIdentical($expected, $output->getProcessedText());
     $this->assertIdentical($attached_library, $output->getAssets());
+
+    // So far we've tested that the caption filter works correctly. But we also
+    // want to make sure that it works well in tandem with the "Limit allowed
+    // HTML tags" filter, which is typically used with.
+    $html_filter = $this->filters['filter_html'];
+    $html_filter->setConfiguration(array(
+      'settings' => array(
+        'allowed_html' => '<img>',
+        'filter_html_help' => 1,
+        'filter_html_nofollow' => 0,
+      )
+    ));
+    $test_with_html_filter = function($input) use ($filter, $html_filter) {
+      // 1. Apply HTML filter's processing step.
+      $output = $html_filter->process($input, 'und');
+      // 2. Apply caption filter's processing step.
+      $output = $filter->process($output, 'und');
+      return $output->getProcessedText();
+    };
+    // Editor XSS filter.
+    $test_editor_xss_filter = function ($input) {
+      $allowed_tags = array('img');
+      return Xss::filter($input, $allowed_tags);
+    };
+
+    // All the tricky cases encountered at https://drupal.org/node/2105841.
+    // A plain URL preceded by text.
+    $input = '<img data-caption="See http://drupal.org" src="llama.jpg" />';
+    $expected = '<figure><img src="llama.jpg" /><figcaption>See http://drupal.org</figcaption></figure>';
+    $this->assertIdentical($expected, $test_with_html_filter($input));
+    $this->assertIdentical($input, $test_editor_xss_filter($input));
+    // An anchor.
+    $input = '<img data-caption="This is a &lt;a href=&quot;http://drupal.org&quot;&gt;quick&lt;/a&gt; test…" src="llama.jpg" />';
+    $expected = '<figure><img src="llama.jpg" /><figcaption>This is a <a href="http://drupal.org">quick</a> test…</figcaption></figure>';
+    $this->assertIdentical($expected, $test_with_html_filter($input));
+    $this->assertIdentical($input, $test_editor_xss_filter($input));
+    // A plain URL surrounded by parentheses.
+    $input = '<img data-caption="(http://drupal.org)" src="llama.jpg" />';
+    $expected = '<figure><img src="llama.jpg" /><figcaption>(http://drupal.org)</figcaption></figure>';
+    $this->assertIdentical($expected, $test_with_html_filter($input));
+    $this->assertIdentical($input, $test_editor_xss_filter($input));
+    // A source being credited.
+    $input = '<img data-caption="Source: Wikipedia" src="llama.jpg" />';
+    $expected = '<figure><img src="llama.jpg" /><figcaption>Source: Wikipedia</figcaption></figure>';
+    $this->assertIdentical($expected, $test_with_html_filter($input));
+    $this->assertIdentical($input, $test_editor_xss_filter($input));
+    // A source being credited, with a typo.
+    $input = '<img data-caption="Source:Wikipedia" src="llama.jpg" />';
+    $expected = '<figure><img src="llama.jpg" /><figcaption>Source:Wikipedia</figcaption></figure>';
+    $this->assertIdentical($expected, $test_with_html_filter($input));
+    $expected_xss_filtered = '<img data-caption="Source:Wikipedia" src="llama.jpg" />';
+    $this->assertIdentical($expected_xss_filtered, $test_editor_xss_filter($input));
+    // A pretty crazy edge case where we have two colons.
+    $input = '<img data-caption="Interesting (Scope resolution operator ::)" src="llama.jpg" />';
+    $expected = '<figure><img src="llama.jpg" /><figcaption>Interesting (Scope resolution operator ::)</figcaption></figure>';
+    $this->assertIdentical($expected, $test_with_html_filter($input));
+    $this->assertIdentical($input, $test_editor_xss_filter($input));
+    // An evil anchor (to ensure XSS filtering is applied to the caption also).
+    $input = '<img data-caption="This is an &lt;a href=&quot;javascript:alert(&quot;foo&quot;)&gt;evil&lt;/a&gt; test…" src="llama.jpg" />';
+    $expected = '<figure><img src="llama.jpg" /><figcaption>This is an <a>evil</a> test…</figcaption></figure>';
+    $this->assertIdentical($expected, $test_with_html_filter($input));
+    $expected_xss_filtered = '<img data-caption="This is an &lt;a href=&quot;javascript:alert(&quot;foo&quot;)&gt;evil&lt;/a&gt; test…" src="llama.jpg" />';
+    $this->assertIdentical($expected_xss_filtered, $test_editor_xss_filter($input));
   }
 
   /**
diff --git a/core/tests/Drupal/Tests/Component/Utility/XssTest.php b/core/tests/Drupal/Tests/Component/Utility/XssTest.php
index a14827f..e862014 100644
--- a/core/tests/Drupal/Tests/Component/Utility/XssTest.php
+++ b/core/tests/Drupal/Tests/Component/Utility/XssTest.php
@@ -489,6 +489,37 @@ public function testQuestionSign() {
   }
 
   /**
+   * Check that strings in HTML attributes are are correctly processed.
+   *
+   * @covers ::attributes
+   * @dataProvider providerTestAttributes
+   */
+  public function testAttribute($value, $expected, $message, $allowed_tags = NULL) {
+    $value = Xss::filter($value, $allowed_tags);
+    $this->assertEquals($expected, $value, $message);
+  }
+
+  /**
+   * Data provider for testFilterXssAdminNotNormalized().
+   */
+  public function providerTestAttributes() {
+    return array(
+      array(
+        '<img src="http://example.com/foo.jpg" title="Example: title" alt="Example: alt">',
+        '<img src="http://example.com/foo.jpg" title="Example: title" alt="Example: alt">',
+        'Image tag with alt and title attribute',
+        array('img')
+      ),
+      array(
+        '<img src="http://example.com/foo.jpg" data-caption="Drupal 8: The best release ever.">',
+        '<img src="http://example.com/foo.jpg" data-caption="Drupal 8: The best release ever.">',
+        'Image tag with data attribute',
+        array('img')
+      ),
+    );
+  }
+
+  /**
    * Checks that \Drupal\Component\Utility\Xss::filterAdmin() correctly strips unallowed tags.
    */
   public function testFilterXSSAdmin() {
