diff --git a/core/lib/Drupal/Core/Render/ReadOnlyRenderArray.php b/core/lib/Drupal/Core/Render/ReadOnlyRenderArray.php new file mode 100644 index 0000000..b17a680 --- /dev/null +++ b/core/lib/Drupal/Core/Render/ReadOnlyRenderArray.php @@ -0,0 +1,79 @@ +getArrayCopy(); + } + + /** + * Magic getter since Twig sometimes tries to access properties. + * + * @param mixed $index + * The index with the value. + * + * @return \Drupal\Core\Render\ReadOnlyRenderArray|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 ReadOnlyRenderArray($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..55a4302 --- /dev/null +++ b/core/lib/Drupal/Core/Render/RenderArrayIterator.php @@ -0,0 +1,49 @@ +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 ReadOnlyRenderArray($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 ReadOnlyRenderArray) { + $context = $context->getArrayCopy(); + } + if (is_array($context)) { + foreach ($context as $key => $value) { + if (is_object($context[$key]) && $context[$key] instanceof ReadOnlyRenderArray) { + $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..d0d8779 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\ReadOnlyRenderArray; 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\ReadOnlyRenderArray * 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 ReadOnlyRenderArray($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\ReadOnlyRenderArray * 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 ReadOnlyRenderArray($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/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 @@ + '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/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,