diff --git a/CHANGELOG b/CHANGELOG index cc3a168deb..8059b09482 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ * 1.42.0 (2019-XX-XX) + * added the possibility to register classes/interfaces as being safe for the escaper ("EscaperExtension::addSafeClass()") * fixed the "filter" filter when the argument is \Traversable but does not implement \Iterator (\SimpleXmlElement for instance) * fixed a PHP fatal error when calling a macro imported in a block in a nested block * fixed a PHP fatal error when calling a macro imported in the template in another macro diff --git a/doc/api.rst b/doc/api.rst index b96b0d4e7f..323ee852bb 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -433,6 +433,24 @@ The escaping rules are implemented as follows: {% set text = "Twig
" %} {{ foo ? text|escape : "
Twig" }} {# the result of the expression won't be escaped #} +* Objects with a ``__toString`` method are converted to strings and + escaped. You can mark some classes and/or interfaces as being safe for some + strategies via ``EscaperExtension::addSafeClass()``: + + .. code-block:: twig + + // mark object of class Foo as safe for the HTML strategy + $escaper->addSafeClass('Foo', ['html']); + + // mark object of interface Foo as safe for the HTML strategy + $escaper->addSafeClass('FooInterface', ['html']); + + // mark object of class Foo as safe for the HTML and JS strategies + $escaper->addSafeClass('Foo', ['html', 'js']); + + // mark object of class Foo as safe for all strategies + $escaper->addSafeClass('Foo', ['all']); + * Escaping is applied before printing, after any other filter is applied: .. code-block:: twig diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 2e564ebe78..e0119f7370 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1022,6 +1022,38 @@ function twig_escape_filter(Environment $env, $string, $strategy = 'html', $char if (!\is_string($string)) { if (\is_object($string) && method_exists($string, '__toString')) { + if ($autoescape) { + static $safeLookup; + static $safeClasses; + + if (null === $safeLookup) { + $safeLookup = []; + $safeClasses = $env->getExtension('Twig\Extension\EscaperExtension')->getSafeClasses(); + foreach ($safeClasses as $class => $strategies) { + foreach ($strategies as $s) { + $safeLookup[$s][$class] = true; + } + } + } + + $c = get_class($string); + if (!isset($safeClasses[$c])) { + $safeClasses[$c] = []; + foreach (class_parents($string) + class_implements($string) as $class) { + if (isset($safeClasses[$class])) { + $safeClasses[$c] = array_unique(array_merge($safeClasses[$c], $safeClasses[$class])); + foreach ($safeClasses[$class] as $s) { + $safeLookup[$s][$c] = true; + } + } + } + } + + if (isset($safeLookup[$strategy][$c]) || isset($safeLookup['all'][$c])) { + return (string) $string; + } + } + $string = (string) $string; } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'])) { return $string; diff --git a/src/Extension/EscaperExtension.php b/src/Extension/EscaperExtension.php index fc7f6dfeea..d730714102 100644 --- a/src/Extension/EscaperExtension.php +++ b/src/Extension/EscaperExtension.php @@ -21,6 +21,10 @@ class EscaperExtension extends AbstractExtension { protected $defaultStrategy; + private $safeClasses = []; + + private static $safeClassesFrozen = false; + /** * @param string|false|callable $defaultStrategy An escaping strategy * @@ -100,6 +104,41 @@ public function getName() { return 'escaper'; } + + /** + * @internal + */ + public function getSafeClasses(): array + { + self::$safeClassesFrozen = true; + + return $this->safeClasses; + } + + public function setSafeClasses(array $safeClasses = []): void + { + if (self::$safeClassesFrozen) { + throw new \LogicException('Unable to set the safe classes for escaping as twig_escape_filter() has already been initialized.'); + } + + foreach ($safeClasses as $class => $strategies) { + $this->addSafeClass($class, $strategies); + } + } + + public function addSafeClass(string $class, array $strategies): void + { + if (self::$safeClassesFrozen) { + throw new \LogicException('Unable to set the safe classes for escaping as twig_escape_filter() has already been initialized.'); + } + + $class = ltrim($class, '\\'); + if (!isset($this->safeClasses[$class])) { + $this->safeClasses[$class] = []; + } + + $this->safeClasses[$class] = array_merge($this->safeClasses[$class], $strategies); + } } class_alias('Twig\Extension\EscaperExtension', 'Twig_Extension_Escaper'); diff --git a/test/Twig/Tests/Extension/CoreTest.php b/test/Twig/Tests/Extension/CoreTest.php index c29431824a..72c6c846fd 100644 --- a/test/Twig/Tests/Extension/CoreTest.php +++ b/test/Twig/Tests/Extension/CoreTest.php @@ -159,6 +159,31 @@ public function testUnknownCustomEscaper() twig_escape_filter(new Environment($this->getMockBuilder('\Twig\Loader\LoaderInterface')->getMock()), 'foo', 'bar'); } + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + * @dataProvider provideObjectsForEscaping + */ + public function testObjectEscaping(string $escapedHtml, string $escapedJs, array $safeClasses) + { + $obj = new Twig_Tests_Extension_TestClass(); + + $twig = new Environment($this->getMockBuilder('\Twig\Loader\LoaderInterface')->getMock()); + $twig->getExtension('\Twig\Extension\EscaperExtension')->setSafeClasses($safeClasses); + $this->assertSame($escapedHtml, twig_escape_filter($twig, $obj, 'html', null, true)); + $this->assertSame($escapedJs, twig_escape_filter($twig, $obj, 'js', null, true)); + } + + public function provideObjectsForEscaping() + { + return [ + ['<br />', '
', ['\Twig_Tests_Extension_TestClass' => ['js']]], + ['
', '\u003Cbr\u0020\/\u003E', ['\Twig_Tests_Extension_TestClass' => ['html']]], + ['<br />', '
', ['\Twig_Tests_Extension_SafeHtmlInterface' => ['js']]], + ['
', '
', ['\Twig_Tests_Extension_SafeHtmlInterface' => ['all']]], + ]; + } + /** * @dataProvider provideTwigFirstCases */ @@ -369,3 +394,15 @@ public function valid() return isset($this->arrayKeys[$this->position]); } } + +interface Twig_Tests_Extension_SafeHtmlInterface +{ +} + +class Twig_Tests_Extension_TestClass implements Twig_Tests_Extension_SafeHtmlInterface +{ + public function __toString() + { + return '
'; + } +}