diff --git a/core/lib/Drupal/Core/Render/ProtectedRenderArray.php b/core/lib/Drupal/Core/Render/ProtectedRenderArray.php
new file mode 100644
index 0000000..7e675c4
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/ProtectedRenderArray.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\Core\Render;
+
+/**
+ * Provides a render array object where indexes are read-only.
+ *
+ * @ingroup utility
+ */
+class ProtectedRenderArray extends \ArrayObject implements RenderableInterface {
+
+  /**
+   * ProtectedRenderArray constructor.
+   *
+   * @param array $input
+   *   The render array to wrap into a read-only object.
+   */
+  public function __construct(array $input = []) {
+    parent::__construct($input, 0, RenderArrayIterator::class);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function toRenderable() {
+    return $this->getArrayCopy();
+  }
+
+  /**
+   * Magic getter since Twig sometimes tries to access properties.
+   *
+   * @param mixed $index
+   *   The index with the value.
+   *
+   * @return \Drupal\Core\Render\ProtectedRenderArray|mixed
+   */
+  public function __get($index) {
+    return $this->offsetGet($index);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetGet($index) {
+    $value = parent::offsetGet($index);
+    if (is_array($value) && !empty($value)) {
+      $value = new ProtectedRenderArray($value);
+    }
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIterator() {
+    return new RenderArrayIterator($this->getArrayCopy());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetSet($index, $newval) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function append($values) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function unserialize($serialized) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function exchangeArray($serialized) {}
+
+}
diff --git a/core/lib/Drupal/Core/Render/RenderArrayIterator.php b/core/lib/Drupal/Core/Render/RenderArrayIterator.php
new file mode 100644
index 0000000..e7ffded
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/RenderArrayIterator.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\Core\Render;
+
+/**
+ * Provides an array iterator where indexes are read-only.
+ *
+ * @ingroup utility
+ */
+class RenderArrayIterator extends \ArrayIterator {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetGet($index) {
+    $value = parent::offsetGet($index);
+    if (is_array($value) && !empty($value)) {
+      $value = new ProtectedRenderArray($value);
+    }
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function current() {
+    $value = parent::current();
+    if (is_array($value) && !empty($value)) {
+      $value = new ProtectedRenderArray($value);
+    }
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function offsetSet($index, $newval) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function append($values) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function unserialize($serialized) {}
+
+}
diff --git a/core/lib/Drupal/Core/Template/TwigEnvironment.php b/core/lib/Drupal/Core/Template/TwigEnvironment.php
index 21755a5..91ddcc1 100644
--- a/core/lib/Drupal/Core/Template/TwigEnvironment.php
+++ b/core/lib/Drupal/Core/Template/TwigEnvironment.php
@@ -4,6 +4,7 @@
 
 use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\Core\Render\Markup;
+use Drupal\Core\Render\ProtectedRenderArray;
 use Drupal\Core\State\StateInterface;
 
 /**
@@ -140,7 +141,43 @@ public function getTemplateClass($name, $index = NULL) {
   public function renderInline($template_string, array $context = []) {
     // Prefix all inline templates with a special comment.
     $template_string = '{# inline_template_start #}' . $template_string;
+    $this->wrapContext($context);
     return Markup::create($this->loadTemplate($template_string, NULL)->render($context));
   }
 
+  /**
+   * Wraps render arrays with objects, to prevent modification from Twig.
+   *
+   * @param array &$context
+   *   An array of parameters.
+   */
+  public function wrapContext(&$context) {
+    if (is_array($context)) {
+      foreach ($context as $key => $value) {
+        if (is_array($context[$key]) && !empty($context[$key])) {
+          $context[$key] = new ProtectedRenderArray($context[$key]);
+        }
+      }
+    }
+  }
+
+  /**
+   * Unwraps read only objects into render arrays.
+   *
+   * @param array &$context
+   *   An array of parameters.
+   */
+  public function unWrapContext(&$context) {
+    if (is_object($context) && $context instanceof ProtectedRenderArray) {
+      $context = $context->getArrayCopy();
+    }
+    if (is_array($context)) {
+      foreach ($context as $key => $value) {
+        if (is_object($context[$key]) && $context[$key] instanceof ProtectedRenderArray) {
+          $context[$key] = $context[$key]->getArrayCopy();
+        }
+      }
+    }
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Template/TwigExtension.php b/core/lib/Drupal/Core/Template/TwigExtension.php
index 520a8be..014c2e3 100644
--- a/core/lib/Drupal/Core/Template/TwigExtension.php
+++ b/core/lib/Drupal/Core/Template/TwigExtension.php
@@ -9,6 +9,7 @@
 use Drupal\Core\Render\AttachmentsInterface;
 use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Core\Render\Markup;
+use Drupal\Core\Render\ProtectedRenderArray;
 use Drupal\Core\Render\RenderableInterface;
 use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\Routing\UrlGeneratorInterface;
@@ -247,7 +248,7 @@ public function getPath($name, $parameters = [], $options = []) {
    *   (optional) An associative array of additional options. The 'absolute'
    *   option is forced to be TRUE.
    *
-   * @return string
+   * @return \Drupal\Core\Render\ProtectedRenderArray
    *   The generated absolute URL for the given route.
    *
    * @todo Add an option for scheme-relative URLs.
@@ -260,7 +261,7 @@ public function getUrl($name, $parameters = [], $options = []) {
     // Return as render array, so we can bubble the bubbleable metadata.
     $build = ['#markup' => $generated_url->getGeneratedUrl()];
     $generated_url->applyTo($build);
-    return $build;
+    return new ProtectedRenderArray($build);
   }
 
   /**
@@ -273,7 +274,7 @@ public function getUrl($name, $parameters = [], $options = []) {
    * @param array|\Drupal\Core\Template\Attribute $attributes
    *   An optional array or Attribute object of link attributes.
    *
-   * @return array
+   * @return \Drupal\Core\Render\ProtectedRenderArray
    *   A render array representing a link to the given URL.
    */
   public function getLink($text, $url, $attributes = []) {
@@ -300,7 +301,7 @@ public function getLink($text, $url, $attributes = []) {
       '#title' => $text,
       '#url' => $url,
     ];
-    return $build;
+    return new ProtectedRenderArray($build);
   }
 
   /**
@@ -451,6 +452,12 @@ public function escapeFilter(\Twig_Environment $env, $arg, $strategy = 'html', $
     elseif (is_object($arg)) {
       if ($arg instanceof RenderableInterface) {
         $arg = $arg->toRenderable();
+        // Early return if this element was pre-rendered (no need to re-render).
+        if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
+          return $arg['#markup'];
+        }
+        $arg['#printed'] = FALSE;
+        return $this->renderer->render($arg);
       }
       elseif (method_exists($arg, '__toString')) {
         $return = (string) $arg;
@@ -465,6 +472,9 @@ public function escapeFilter(\Twig_Environment $env, $arg, $strategy = 'html', $
         throw new \Exception('Object of type ' . get_class($arg) . ' cannot be printed.');
       }
     }
+    else {
+      throw new \Exception('Variable of type ' . gettype($arg) . ' cannot be printed.');
+    }
 
     // We have a string or an object converted to a string: Autoescape it!
     if (isset($return)) {
@@ -478,16 +488,6 @@ public function escapeFilter(\Twig_Environment $env, $arg, $strategy = 'html', $
       }
       return twig_escape_filter($env, $return, $strategy, $charset, $autoescape);
     }
-
-    // This is a normal render array, which is safe by definition, with
-    // special simple cases already handled.
-
-    // Early return if this element was pre-rendered (no need to re-render).
-    if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
-      return $arg['#markup'];
-    }
-    $arg['#printed'] = FALSE;
-    return $this->renderer->render($arg);
   }
 
   /**
@@ -566,6 +566,13 @@ public function renderVar($arg) {
       $this->bubbleArgMetadata($arg);
       if ($arg instanceof RenderableInterface) {
         $arg = $arg->toRenderable();
+        // This is a render array, with special simple cases already handled.
+        // Early return if this element was pre-rendered (no need to re-render).
+        if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
+          return $arg['#markup'];
+        }
+        $arg['#printed'] = FALSE;
+        return $this->renderer->render($arg);
       }
       elseif (method_exists($arg, '__toString')) {
         return (string) $arg;
@@ -580,14 +587,7 @@ public function renderVar($arg) {
         throw new \Exception('Object of type ' . get_class($arg) . ' cannot be printed.');
       }
     }
-
-    // This is a render array, with special simple cases already handled.
-    // Early return if this element was pre-rendered (no need to re-render).
-    if (isset($arg['#printed']) && $arg['#printed'] == TRUE && isset($arg['#markup']) && strlen($arg['#markup']) > 0) {
-      return $arg['#markup'];
-    }
-    $arg['#printed'] = FALSE;
-    return $this->renderer->render($arg);
+    throw new \Exception('Variable of type ' . gettype($arg) . ' cannot be printed.');
   }
 
   /**
diff --git a/core/modules/forum/forum.module b/core/modules/forum/forum.module
index c2de3c9..60391ff 100644
--- a/core/modules/forum/forum.module
+++ b/core/modules/forum/forum.module
@@ -7,8 +7,10 @@
 
 use Drupal\comment\CommentInterface;
 use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
+use Drupal\Component\Utility\Xss;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Render\Markup;
 use Drupal\Core\Url;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
@@ -545,7 +547,9 @@ function template_preprocess_forum_list(&$variables) {
   $row = 0;
   // Sanitize each forum so that the template can safely print the data.
   foreach ($variables['forums'] as $id => $forum) {
-    $variables['forums'][$id]->description = ['#markup' => $forum->description->value];
+    // Work-around the fact that this is an object instead of a render array
+    // by setting the property to a filtered Markup object so it's not escaped.
+    $variables['forums'][$id]->description = Markup::create(Xss::filter($forum->description->value));
     $variables['forums'][$id]->link = forum_uri($forum);
     $variables['forums'][$id]->name = $forum->label();
     $variables['forums'][$id]->is_container = !empty($forum->forum_container->value);
diff --git a/core/modules/system/src/Tests/Theme/TwigReadOnlyTest.php b/core/modules/system/src/Tests/Theme/TwigReadOnlyTest.php
new file mode 100644
index 0000000..6785e05
--- /dev/null
+++ b/core/modules/system/src/Tests/Theme/TwigReadOnlyTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\system\Tests\Theme;
+
+use Drupal\simpletest\WebTestBase;
+
+/**
+ * Tests that variables passed to Twig cannot be modified.
+ *
+ * @group Theme
+ */
+class TwigReadOnlyTest extends WebTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['twig_theme_test'];
+
+  /**
+   * Tests that Twig variables are read-only.
+   */
+  public function testReadOnlyTwigVariables() {
+    $filter_test = [
+      '#theme' => 'twig_theme_test_read_only',
+      '#renderable' => [
+        '#markup' => 'Original',
+      ],
+    ];
+
+    // To modify a ReadOnlyRenderArray, Twig will convert it to an array and
+    // attempt to use that for rendering. This should throw an error.
+    try {
+      $result = \Drupal::service('renderer')->renderRoot($filter_test);
+    }
+    catch (\Exception $e) {}
+    $this->assertFalse(isset($result), 'Variables cannot be modified.');
+  }
+
+}
diff --git a/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.read_only.html.twig b/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.read_only.html.twig
new file mode 100644
index 0000000..4bfeec8
--- /dev/null
+++ b/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.read_only.html.twig
@@ -0,0 +1,2 @@
+{% set renderable = renderable|merge({'#markup': 'Modified'}) %}
+{{ renderable }}
diff --git a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module
index a8b4086..337dca6 100644
--- a/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module
+++ b/core/modules/system/tests/modules/twig_theme_test/twig_theme_test.module
@@ -73,6 +73,12 @@ function twig_theme_test_theme($existing, $type, $theme, $path) {
     ],
     'template' => 'twig_theme_test.renderable',
   ];
+  $items['twig_theme_test_read_only'] = [
+    'variables' => [
+      'renderable' => NULL,
+    ],
+    'template' => 'twig_theme_test.read_only',
+  ];
   return $items;
 }
 
diff --git a/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php b/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php
index c4805bc..4024a8a 100644
--- a/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php
+++ b/core/tests/Drupal/Tests/Core/Template/TwigExtensionTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\Tests\Core\Template;
 
 use Drupal\Core\GeneratedLink;
+use Drupal\Core\Render\ProtectedRenderArray;
 use Drupal\Core\Render\RenderableInterface;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\Template\Loader\StringLoader;
@@ -218,16 +219,16 @@ public function testSafeJoin() {
     $markup->__toString()->willReturn('<em>will be markup</em>');
     $markup = $markup->reveal();
 
-    $items = [
+    $items = new ProtectedRenderArray([
       '<em>will be escaped</em>',
       $markup,
       ['#markup' => '<strong>will be rendered</strong>'],
-    ];
+    ]);
     $result = $this->systemUnderTest->safeJoin($twig_environment, $items, '<br/>');
     $this->assertEquals('&lt;em&gt;will be escaped&lt;/em&gt;<br/><em>will be markup</em><br/><strong>will be rendered</strong>', $result);
 
     // Ensure safe_join Twig filter supports Traversable variables.
-    $items = new \ArrayObject([
+    $items = new ProtectedRenderArray([
       '<em>will be escaped</em>',
       $markup,
       ['#markup' => '<strong>will be rendered</strong>'],
diff --git a/core/themes/engines/twig/twig.engine b/core/themes/engines/twig/twig.engine
index 791f908..760d366 100644
--- a/core/themes/engines/twig/twig.engine
+++ b/core/themes/engines/twig/twig.engine
@@ -52,7 +52,7 @@ function twig_init(Extension $theme) {
  *   The output generated by the template, plus any debug information.
  */
 function twig_render_template($template_file, array $variables) {
-  /** @var \Twig_Environment $twig_service */
+  /** @var \Drupal\Core\Template\TwigEnvironment $twig_service */
   $twig_service = \Drupal::service('twig');
   $output = [
     'debug_prefix' => '',
@@ -61,7 +61,9 @@ function twig_render_template($template_file, array $variables) {
     'debug_suffix' => '',
   ];
   try {
+    $twig_service->wrapContext($variables);
     $output['rendered_markup'] = $twig_service->loadTemplate($template_file)->render($variables);
+    $twig_service->unWrapContext($variables);
   }
   catch (\Twig_Error_Runtime $e) {
     // In case there is a previous exception, re-throw the previous exception,
