diff --git a/core/lib/Drupal/Core/Render/Element/HtmlTag.php b/core/lib/Drupal/Core/Render/Element/HtmlTag.php
index f0c78bb..8dd73c6 100644
--- a/core/lib/Drupal/Core/Render/Element/HtmlTag.php
+++ b/core/lib/Drupal/Core/Render/Element/HtmlTag.php
@@ -9,6 +9,7 @@
 
 use Drupal\Component\Utility\SafeMarkup;
 use Drupal\Component\Utility\Xss;
+use Drupal\Core\Render\SafeString;
 use Drupal\Core\Template\Attribute;
 
 /**
@@ -46,30 +47,23 @@ public function getInfo() {
   /**
    * Pre-render callback: Renders a generic HTML tag with attributes into #markup.
    *
-   * Note: It is the caller's responsibility to sanitize any input parameters.
-   * This callback does not perform sanitization. Despite the result of this
-   * pre-render callback being a #markup element, it is not passed through
-   * \Drupal\Component\Utility\Xss::filterAdmin(). This is because it is marked
-   * safe here, which causes
-   * \Drupal\Core\Render\Renderer::xssFilterAdminIfUnsafe() to regard it as safe
-   * and bypass the call to \Drupal\Component\Utility\Xss::filterAdmin().
-   *
    * @param array $element
    *   An associative array containing:
    *   - #tag: The tag name to output. Typical tags added to the HTML HEAD:
    *     - meta: To provide meta information, such as a page refresh.
    *     - link: To refer to stylesheets and other contextual information.
    *     - script: To load JavaScript.
-   *     The value of #tag is not escaped or sanitized, so do not pass in user
-   *     input.
+   *     The value of #tag is escaped.
    *   - #attributes: (optional) An array of HTML attributes to apply to the
    *     tag.
    *   - #value: (optional) A string containing tag content, such as inline
-   *     CSS.
+   *     CSS. The value of #value will be XSS admin filtered if it is not safe.
    *   - #value_prefix: (optional) A string to prepend to #value, e.g. a CDATA
-   *     wrapper prefix.
+   *     wrapper prefix. The value of #value_prefix cannot be filtered and is
+   *     assumed to be safe.
    *   - #value_suffix: (optional) A string to append to #value, e.g. a CDATA
-   *     wrapper suffix.
+   *     wrapper suffix. The value of #value_suffix cannot be filtered and is
+   *     assumed to be safe.
    *   - #noscript: (optional) If TRUE, the markup (including any prefix or
    *     suffix) will be wrapped in a <noscript> element. (Note that passing
    *     any non-empty value here will add the <noscript> tag.)
@@ -79,35 +73,30 @@ public function getInfo() {
   public static function preRenderHtmlTag($element) {
     $attributes = isset($element['#attributes']) ? new Attribute($element['#attributes']) : '';
 
+    // An html tag should not contain any special characters. Escape them to
+    // ensure this can not be abused.
+    $escaped_tag = htmlspecialchars($element['#tag'], ENT_QUOTES, 'UTF-8');
+    $markup = '<' . $escaped_tag . $attributes;
     // Construct a void element.
     if (in_array($element['#tag'], self::$voidElements)) {
-      // This function is intended for internal use, so we assume that no unsafe
-      // values are passed in #tag. The attributes are already safe because
-      // Attribute output is already automatically sanitized.
-      // @todo Escape this properly instead? https://www.drupal.org/node/2296101
-      $markup = SafeMarkup::set('<' . $element['#tag'] . $attributes . " />\n");
+      $markup .= " />\n";
     }
     // Construct all other elements.
     else {
-      $markup = '<' . $element['#tag'] . $attributes . '>';
+      $markup .= '>';
       if (isset($element['#value_prefix'])) {
-        $markup .= $element['#value_prefix'];
+        $markup .= static::xssFilterAdminIfUnsafe($element['#value_prefix']);
       }
-      $markup .= $element['#value'];
+      $markup .= static::xssFilterAdminIfUnsafe($element['#value']);
       if (isset($element['#value_suffix'])) {
-        $markup .= $element['#value_suffix'];
+        $markup .= static::xssFilterAdminIfUnsafe($element['#value_suffix']);
       }
-      $markup .= '</' . $element['#tag'] . ">\n";
-      // @todo We cannot actually guarantee this markup is safe. Consider a fix
-      //   in: https://www.drupal.org/node/2296101
-      $markup = SafeMarkup::set($markup);
+      $markup .= '</' . $escaped_tag . ">\n";
     }
     if (!empty($element['#noscript'])) {
-      $element['#markup'] = SafeMarkup::format('<noscript>@markup</noscript>', ['@markup' => $markup]);
-    }
-    else {
-      $element['#markup'] = $markup;
+      $markup = "<noscript>$markup</noscript>";
     }
+    $element['#markup'] = SafeMarkup::set($markup);
     return $element;
   }
 
@@ -193,4 +182,24 @@ public static function preRenderConditionalComments($element) {
     return $element;
   }
 
+  /**
+   * Applies a very permissive XSS/HTML filter for admin-only use.
+   *
+   * Note: This method only filters if $string is not marked safe already. This
+   * ensures that HTML intended for display is not filtered.
+   *
+   * @param string|\Drupal\Core\Render\SafeString $string
+   *   A string.
+   *
+   * @return \Drupal\Core\Render\SafeString
+   *   The escaped string wrapped in a SafeString object. If
+   *   SafeMarkup::isSafe($string) returns TRUE, it won't be escaped again.
+   */
+  protected static function xssFilterAdminIfUnsafe($string) {
+    if (!SafeMarkup::isSafe($string)) {
+      $string = Xss::filterAdmin($string);
+    }
+    return SafeString::create($string);
+  }
+
 }
diff --git a/core/tests/Drupal/Tests/Core/Render/Element/HtmlTagTest.php b/core/tests/Drupal/Tests/Core/Render/Element/HtmlTagTest.php
index 53abbbe..9e1a7a1 100644
--- a/core/tests/Drupal/Tests/Core/Render/Element/HtmlTagTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/Element/HtmlTagTest.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Tests\Core\Render\Element;
 
+use Drupal\Core\Render\SafeString;
 use Drupal\Tests\UnitTestCase;
 use Drupal\Core\Render\Element\HtmlTag;
 
@@ -34,7 +35,7 @@ public function testGetInfo() {
   public function testPreRenderHtmlTag($element, $expected) {
     $result = HtmlTag::preRenderHtmlTag($element);
     $this->assertArrayHasKey('#markup', $result);
-    $this->assertSame($expected, $result['#markup']);
+    $this->assertEquals($expected, $result['#markup']);
   }
 
   /**
@@ -77,6 +78,36 @@ public function providerPreRenderHtmlTag() {
     $element['#noscript'] = TRUE;
     $tags[] = array($element, '<noscript><div class="test" id="id">value</div>' . "\n" . '</noscript>');
 
+    // Ensure that #tag is sanitised.
+    $element = array(
+      '#tag' => 'p><script>alert()</script><p',
+      '#value' => 'value',
+    );
+    $tags[] = array($element, "<p&gt;&lt;script&gt;alert()&lt;/script&gt;&lt;p>value</p&gt;&lt;script&gt;alert()&lt;/script&gt;&lt;p>\n");
+
+    // Ensure that #value is not filtered if it is marked as safe.
+    $element = array(
+      '#tag' => 'p',
+      '#value' => SafeString::create('<script>value</script>'),
+    );
+    $tags[] = array($element, "<p><script>value</script></p>\n");
+
+    // Ensure that #value is filtered if they are not safe.
+    $element = array(
+      '#tag' => 'p',
+      '#value' => '<script>value</script>',
+    );
+    $tags[] = array($element, "<p>value</p>\n");
+
+    // Ensure that #value_prefix and #value_suffix are not filtered.
+    $element = array(
+      '#tag' => 'p',
+      '#value' => 'value',
+      '#value_prefix' => '<script>value</script>',
+      '#value_suffix' => '<script>value</script>',
+    );
+    $tags[] = array($element, "<p><script>value</script>value<script>value</script></p>\n");
+
     return $tags;
   }
 
